Add more flexible analytics.
Build and Deploy Web App / build-and-deploy (push) Successful in 20s Details

This commit is contained in:
andrewlalis 2025-12-02 20:05:40 -05:00
parent fe2934e2f3
commit 26f0d88f5d
3 changed files with 177 additions and 108 deletions

View File

@ -1,126 +1,89 @@
<script setup lang="ts">
import { AccountApiClient } from '@/api/account'
import { type Account, AccountApiClient } from '@/api/account'
import HomeModule from '@/components/HomeModule.vue'
import {
CategoryScale,
Chart,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
TimeScale,
Title,
Tooltip,
type ChartData,
type ChartDataset,
type ChartOptions,
} from 'chart.js'
import { getTime } from 'date-fns'
import { onMounted, ref } from 'vue'
import { Line } from 'vue-chartjs'
import { computed, onMounted, ref, type ComputedRef } from 'vue'
import { useRoute } from 'vue-router'
import 'chartjs-adapter-date-fns'
import { integerMoneyToFloat } from '@/api/data'
import { AnalyticsApiClient } from '@/api/analytics'
import type { Currency } from '@/api/data'
import { AnalyticsApiClient, type BalanceTimeSeriesAnalytics } from '@/api/analytics'
import BalanceTimeSeriesChart from './analytics/BalanceTimeSeriesChart.vue'
import type { TimeFrame, BalanceTimeSeries } from './analytics/util'
import FormGroup from '@/components/common/form/FormGroup.vue'
import FormControl from '@/components/common/form/FormControl.vue'
import { isAfter, isBefore, startOfMonth, startOfYear, sub } from 'date-fns'
const route = useRoute()
const chartData = ref<ChartData<'line'> | undefined>()
const chartOptions = ref<ChartOptions<'line'> | undefined>()
const accounts = ref<Account[]>([])
const balanceTimeSeriesData = ref<BalanceTimeSeriesAnalytics>()
const currency = ref<Currency>()
const timeFrame = ref<TimeFrame>({})
Chart.register(
Title,
Tooltip,
Legend,
LineElement,
CategoryScale,
LinearScale,
PointElement,
TimeScale,
Filler,
)
const COLORS = [
[255, 69, 69],
[255, 160, 69],
[255, 223, 69],
[118, 255, 69],
[69, 255, 188],
[69, 237, 255],
[69, 125, 255],
[139, 69, 255],
[251, 69, 255],
[255, 69, 167],
]
const availableCurrencies = computed(() => {
const currencies: Currency[] = []
for (const acc of accounts.value) {
if (currencies.findIndex(c => c.code === acc.currency.code) === -1) {
currencies.push(acc.currency)
}
}
return currencies
})
const accountBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
if (!balanceTimeSeriesData.value) return []
const eligibleAccounts = accounts.value.filter(a => a.currency.code === currency.value?.code)
const series: BalanceTimeSeries[] = []
for (const accountData of balanceTimeSeriesData.value.accounts) {
const account = eligibleAccounts.find(a => a.id === accountData.accountId)
if (account !== undefined) {
const filteredTimeSeries = accountData.balanceTimeSeries.filter(s => {
if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) {
return false
}
if (timeFrame.value.end && !isBefore(s.timestamp, timeFrame.value.end)) {
return false
}
return true
})
series.push({
label: account.name,
snapshots: filteredTimeSeries
})
}
}
return series
})
onMounted(async () => {
const api = new AccountApiClient(route)
const analyticsApi = new AnalyticsApiClient(route)
const accounts = await api.getAccounts()
const timeSeriesData = await analyticsApi.getBalanceTimeSeries()
const datasets: ChartDataset<'line'>[] = []
// const timeZoneOffset = -(new Date().getTimezoneOffset())
let colorIdx = 0
for (const accountData of timeSeriesData.accounts) {
if (accountData.currencyCode !== 'USD') continue
const account = accounts.find((a) => a.id === accountData.accountId)
if (!account) {
console.warn("Couldn't find account id " + accountData.accountId)
continue
}
const color = COLORS[colorIdx++]
const points = accountData.balanceTimeSeries.map((p) => {
return {
x: getTime(p.timestamp),
y: integerMoneyToFloat(p.balance, account.currency),
}
})
datasets.push({
label: 'Account #' + account.numberSuffix,
data: points,
cubicInterpolationMode: 'monotone',
borderColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.25)`,
pointRadius: 0,
borderWidth: 2,
pointHoverRadius: 5,
pointHitRadius: 5,
})
}
chartData.value = {
datasets: datasets,
}
chartOptions.value = {
plugins: {
title: {
display: true,
text: 'Account Balances',
},
},
scales: {
x: {
type: 'time',
time: {
unit: 'day',
},
},
},
accounts.value = await api.getAccounts()
balanceTimeSeriesData.value = await analyticsApi.getBalanceTimeSeries()
if (accounts.value.length > 0) {
currency.value = accounts.value[0].currency
}
})
</script>
<template>
<HomeModule
title="Analytics"
style="min-width: 500px"
>
<Line
v-if="chartData && chartOptions"
:data="chartData"
:options="chartOptions"
/>
<HomeModule title="Analytics">
<BalanceTimeSeriesChart v-if="currency && balanceTimeSeriesData" title="Account Balances" :currency="currency"
:time-frame="timeFrame" :data="accountBalancesData" />
<FormGroup>
<FormControl label="Currency">
<select v-model="currency" :disabled="availableCurrencies.length < 2">
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency">
{{ currency.code }}
</option>
</select>
</FormControl>
<FormControl label="Time Frame">
<select v-model="timeFrame">
<option :value="{}" selected>All Time</option>
<option :value="{ start: sub(new Date(), { days: 30 }) }">Last 30 days</option>
<option :value="{ start: startOfMonth(new Date()) }">This Month</option>
<option :value="{ start: startOfYear(new Date()) }">Year to Date</option>
</select>
</FormControl>
</FormGroup>
</HomeModule>
</template>

View File

@ -0,0 +1,95 @@
<script setup lang="ts">
import { CategoryScale, Chart, type ChartData, type ChartDataset, type ChartOptions, Filler, Legend, LinearScale, LineElement, PointElement, TimeScale, Title, Tooltip } from 'chart.js';
import { computed, onMounted, ref } from 'vue';
import type { BalanceTimeSeries, TimeFrame } from './util';
import { integerMoneyToFloat, type Currency } from '@/api/data';
import { getTime } from 'date-fns';
import { Line } from 'vue-chartjs';
const props = defineProps<{
title: string,
timeFrame: TimeFrame,
currency: Currency,
data: BalanceTimeSeries[]
}>()
const chartData = computed(() => buildChartData())
const chartOptions = ref<ChartOptions<'line'> | undefined>()
Chart.register(
Title,
Tooltip,
Legend,
LineElement,
CategoryScale,
LinearScale,
PointElement,
TimeScale,
Filler
)
const COLORS = [
[255, 69, 69],
[255, 160, 69],
[255, 223, 69],
[118, 255, 69],
[69, 255, 188],
[69, 237, 255],
[69, 125, 255],
[139, 69, 255],
[251, 69, 255],
[255, 69, 167],
]
onMounted(() => {
chartOptions.value = {
plugins: {
title: {
display: true,
text: props.title
}
},
scales: {
x: {
type: 'time',
time: {
unit: 'day'
}
}
}
}
})
function buildChartData(): ChartData<'line'> {
const datasets: ChartDataset<'line'>[] = []
let colorIdx = 0
for (const series of props.data) {
const color = COLORS[colorIdx++]
if (colorIdx >= COLORS.length) colorIdx = 0
const points = series.snapshots.map((p) => {
return {
x: getTime(p.timestamp),
y: integerMoneyToFloat(p.balance, props.currency)
}
})
datasets.push({
label: series.label,
data: points,
cubicInterpolationMode: 'monotone',
borderColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.25)`,
pointRadius: 0,
borderWidth: 2,
pointHoverRadius: 5,
pointHitRadius: 5,
})
}
return { datasets: datasets }
}
</script>
<template>
<Line v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" />
</template>

View File

@ -0,0 +1,11 @@
import type { BalanceSnapshot } from '@/api/analytics'
export interface TimeFrame {
start?: Date
end?: Date
}
export interface BalanceTimeSeries {
label: string
snapshots: BalanceSnapshot[]
}