Added category spend and more dynamic charts.
Build and Deploy Web App / build-and-deploy (push) Successful in 20s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m45s Details

This commit is contained in:
andrewlalis 2025-12-04 17:41:27 -05:00
parent bed9562d6b
commit e7ccd25549
11 changed files with 256 additions and 107 deletions

View File

@ -9,6 +9,11 @@ void handleGetBalanceTimeSeries(ref ServerHttpRequest request, ref ServerHttpRes
serveJsonFromProperty(response, ds, "analytics.balanceTimeSeries"); serveJsonFromProperty(response, ds, "analytics.balanceTimeSeries");
} }
void handleGetCategorySpendTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request);
serveJsonFromProperty(response, ds, "analytics.categorySpendTimeSeries");
}
/** /**
* Helper method to serve JSON analytics data to a client by fetching it * Helper method to serve JSON analytics data to a client by fetching it
* directly from the user's profile properties table and writing it to the * directly from the user's profile properties table and writing it to the

View File

@ -1,6 +1,6 @@
module analytics.balances; module analytics.balances;
import handy_http_primitives; import handy_http_primitives : Optional, mapIfPresent;
import std.datetime; import std.datetime;
import std.stdio; import std.stdio;
import std.path; import std.path;
@ -14,26 +14,28 @@ import account.data;
import account.model; import account.model;
import account.service; import account.service;
import analytics.util; import analytics.util;
import transaction.model;
import transaction.dto;
import util.pagination;
struct BalanceSnapshot { struct TimeSeriesPoint {
long balance; /// The millisecond UTC timestamp.
string timestamp; ulong x;
/// The value at this timestamp.
long y;
} }
struct AccountBalanceTimeSeries { alias CurrencyGroupedTimeSeries = TimeSeriesPoint[][string];
struct AccountBalanceData {
ulong accountId; ulong accountId;
string currencyCode; string currencyCode;
BalanceSnapshot[] balanceTimeSeries; TimeSeriesPoint[] data;
}
struct TotalBalanceTimeSeries {
string currencyCode;
BalanceSnapshot[] balanceTimeSeries;
} }
struct BalanceTimeSeriesAnalytics { struct BalanceTimeSeriesAnalytics {
AccountBalanceTimeSeries[] accounts; AccountBalanceData[] accounts;
TotalBalanceTimeSeries[] totals; CurrencyGroupedTimeSeries totals;
} }
void computeAccountBalanceTimeSeries(Profile profile, ProfileRepository profileRepo) { void computeAccountBalanceTimeSeries(Profile profile, ProfileRepository profileRepo) {
@ -43,11 +45,10 @@ void computeAccountBalanceTimeSeries(Profile profile, ProfileRepository profileR
// Initialize the data structure that'll store the analytics info. // Initialize the data structure that'll store the analytics info.
BalanceTimeSeriesAnalytics data; BalanceTimeSeriesAnalytics data;
foreach (account; accounts) { foreach (account; accounts) {
data.accounts ~= AccountBalanceTimeSeries( AccountBalanceData accountData;
account.id, accountData.accountId = account.id;
account.currency.code.idup, accountData.currencyCode = account.currency.code.idup;
[] data.accounts ~= accountData;
);
} }
foreach (timestamp; generateTimeSeriesTimestamps(days(1), 365)) { foreach (timestamp; generateTimeSeriesTimestamps(days(1), 365)) {
@ -55,39 +56,22 @@ void computeAccountBalanceTimeSeries(Profile profile, ProfileRepository profileR
foreach (idx, account; accounts) { foreach (idx, account; accounts) {
auto balance = getBalance(ds, account.id, timestamp); auto balance = getBalance(ds, account.id, timestamp);
if (!balance.isNull) { if (!balance.isNull) {
data.accounts[idx].balanceTimeSeries ~= BalanceSnapshot( data.accounts[idx].data ~= TimeSeriesPoint(
balance.value, timestamp.toUnixMillis(),
timestamp.toISOExtString() balance.value
); );
} }
} }
// Compute total balances for this timestamp. // Compute total balances for this timestamp.
auto totalBalances = getTotalBalanceForAllAccounts(ds, timestamp); auto totalBalances = getTotalBalanceForAllAccounts(ds, timestamp);
foreach (bal; totalBalances) { foreach (CurrencyBalance bal; totalBalances) {
// Assign the balance to one of our running totals. data.totals[bal.currency.code.idup] ~= TimeSeriesPoint(timestamp.toUnixMillis(), bal.balance);
bool currencyFound = false;
foreach (ref currencyTotal; data.totals) {
if (currencyTotal.currencyCode == bal.currency.code) {
currencyTotal.balanceTimeSeries ~= BalanceSnapshot(
bal.balance,
timestamp.toISOExtString()
);
currencyFound = true;
break;
}
}
if (!currencyFound) {
data.totals ~= TotalBalanceTimeSeries(
bal.currency.code.idup,
[BalanceSnapshot(bal.balance, timestamp.toISOExtString())]
);
}
} }
} }
ds.doTransaction(() { ds.doTransaction(() {
ds.getPropertiesRepository().deleteAllByPrefix("analytics"); ds.getPropertiesRepository().deleteProperty("analytics.balanceTimeSeries");
ds.getPropertiesRepository().setProperty( ds.getPropertiesRepository().setProperty(
"analytics.balanceTimeSeries", "analytics.balanceTimeSeries",
serializeToJsonPretty(data) serializeToJsonPretty(data)
@ -98,3 +82,72 @@ void computeAccountBalanceTimeSeries(Profile profile, ProfileRepository profileR
profile.name profile.name
); );
} }
struct CategorySpendData {
ulong categoryId;
string categoryName;
string categoryColor;
CurrencyGroupedTimeSeries dataByCurrency;
}
struct CategorySpendTimeSeriesAnalytics {
CategorySpendData[] categories;
}
void computeCategorySpendTimeSeries(Profile profile, ProfileRepository profileRepo) {
ProfileDataSource ds = profileRepo.getDataSource(profile);
TransactionCategory[] rootCategories = ds.getTransactionCategoryRepository()
.findAllByParentId(Optional!ulong.empty);
CategorySpendTimeSeriesAnalytics data;
PageRequest pr = PageRequest(1, 100, [Sort("txn.timestamp", SortDir.ASC)]);
Page!TransactionsListItem page = ds.getTransactionRepository().findAll(pr);
while (page.items.length > 0) {
foreach (TransactionsListItem txn; page.items) {
if (!isTypicalSpendingTransaction(txn)) continue;
ulong categoryId = txn.category.mapIfPresent!(c => c.id).orElse(0);
TimeSeriesPoint dataPoint = TimeSeriesPoint(
SysTime.fromISOExtString(txn.timestamp).toUnixMillis(),
txn.amount
);
bool foundCategory = false;
foreach (ref cd; data.categories) {
if (cd.categoryId == categoryId) {
cd.dataByCurrency[txn.currency.code.idup] ~= dataPoint;
foundCategory = true;
break;
}
}
if (!foundCategory) {
CategorySpendData cd;
cd.categoryId = categoryId;
cd.categoryName = txn.category.mapIfPresent!(c => c.name).orElse("None");
cd.categoryColor = txn.category.mapIfPresent!(c => c.color).orElse("FFFFFF");
cd.dataByCurrency[txn.currency.code.idup] ~= dataPoint;
data.categories ~= cd;
}
}
page = ds.getTransactionRepository().findAll(page.pageRequest.next());
}
ds.doTransaction(() {
ds.getPropertiesRepository().deleteProperty("analytics.categorySpendTimeSeries");
ds.getPropertiesRepository().setProperty(
"analytics.categorySpendTimeSeries",
serializeToJsonPretty(data)
);
});
infoF!"Computed category spend analytics for user %s, profile %s."(
profile.username,
profile.name
);
}
private bool isTypicalSpendingTransaction(in TransactionsListItem txn) {
return !txn.creditedAccount.isNull &&
txn.debitedAccount.isNull &&
!txn.internalTransfer;
}

View File

@ -17,3 +17,7 @@ SysTime[] generateTimeSeriesTimestamps(Duration intervalSize, int intervalCount)
} }
return timestamps; return timestamps;
} }
ulong toUnixMillis(in SysTime ts) {
return (ts - SysTime(unixTimeToStdTime(0))).total!"msecs";
}

View File

@ -95,6 +95,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
// Analytics endpoints: // Analytics endpoints:
import analytics.api; import analytics.api;
a.map(HttpMethod.GET, PROFILE_PATH ~ "/analytics/balance-time-series", &handleGetBalanceTimeSeries); a.map(HttpMethod.GET, PROFILE_PATH ~ "/analytics/balance-time-series", &handleGetBalanceTimeSeries);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/analytics/category-spend-time-series", &handleGetCategorySpendTimeSeries);
import data_api; import data_api;
// Various other data endpoints: // Various other data endpoints:

View File

@ -25,6 +25,7 @@ void main() {
jobScheduler.addJob(() { jobScheduler.addJob(() {
info("Computing account balance time series analytics for all users..."); info("Computing account balance time series analytics for all users...");
doForAllUserProfiles(&computeAccountBalanceTimeSeries); doForAllUserProfiles(&computeAccountBalanceTimeSeries);
doForAllUserProfiles(&computeCategorySpendTimeSeries);
info("Done computing analytics!"); info("Done computing analytics!");
}, analyticsSchedule); }, analyticsSchedule);
jobScheduler.start(); jobScheduler.start();

View File

@ -40,17 +40,17 @@ struct PageRequest {
* The requested page number, starting from 1 for the first page, or zero * The requested page number, starting from 1 for the first page, or zero
* for an unpaged request. * for an unpaged request.
*/ */
immutable uint page; uint page;
/** /**
* The maximum number of items to include in each page of results. * The maximum number of items to include in each page of results.
*/ */
immutable ushort size; ushort size;
/** /**
* A list of sorts to apply. * A list of sorts to apply.
*/ */
immutable Sort[] sorts; Sort[] sorts;
bool isUnpaged() const { bool isUnpaged() const {
return page < 1; return page < 1;
@ -60,7 +60,7 @@ struct PageRequest {
return PageRequest(0, 0, []); return PageRequest(0, 0, []);
} }
static PageRequest parse(in ServerHttpRequest request, PageRequest defaults) { static PageRequest parse(in ServerHttpRequest request, in PageRequest defaults) {
import std.algorithm; import std.algorithm;
import std.array; import std.array;
uint pg = request.getParamAs!uint("page", defaults.page); uint pg = request.getParamAs!uint("page", defaults.page);
@ -74,7 +74,7 @@ struct PageRequest {
if (s.length == 0 && defaults.sorts.length > 0) { if (s.length == 0 && defaults.sorts.length > 0) {
s = defaults.sorts.dup; s = defaults.sorts.dup;
} }
return PageRequest(pg, sz, s.idup); return PageRequest(pg, sz, s.dup);
} }
string toSql() const { string toSql() const {
@ -107,13 +107,13 @@ struct PageRequest {
} }
PageRequest next() const { PageRequest next() const {
if (isUnpaged) return this; if (isUnpaged) return PageRequest(page, size, sorts.dup);
return PageRequest(page + 1, size, sorts); return PageRequest(page + 1, size, sorts.dup);
} }
PageRequest prev() const { PageRequest prev() const {
if (isUnpaged) return this; if (isUnpaged) return PageRequest(page, size, sorts.dup);
return PageRequest(page - 1, size, sorts); return PageRequest(page - 1, size, sorts.dup);
} }
} }
@ -137,11 +137,11 @@ struct Page(T) {
return Page!(U)(items.map!(fn).array, pageRequest, totalElements, totalPages, isFirst, isLast); return Page!(U)(items.map!(fn).array, pageRequest, totalElements, totalPages, isFirst, isLast);
} }
static Page of(T[] items, PageRequest pageRequest, ulong totalCount) { static Page of(T[] items, in PageRequest pageRequest, ulong totalCount) {
ulong pageCount = getTotalPageCount(totalCount, pageRequest.size); ulong pageCount = getTotalPageCount(totalCount, pageRequest.size);
return Page( return Page(
items, items,
pageRequest, PageRequest(pageRequest.page, pageRequest.size, pageRequest.sorts.dup),
totalCount, totalCount,
pageCount, pageCount,
pageRequest.page == 1, pageRequest.page == 1,

View File

@ -2,25 +2,33 @@ import type { RouteLocation } from 'vue-router'
import { ApiClient } from './base' import { ApiClient } from './base'
import { getSelectedProfile } from './profile' import { getSelectedProfile } from './profile'
export interface BalanceTimeSeriesAnalytics { export interface TimeSeriesPoint {
accounts: AccountBalanceTimeSeries[] x: number
totals: TotalBalanceTimeSeries[] y: number
} }
export interface AccountBalanceTimeSeries { export type CurrencyGroupedTimeSeries = Record<string, TimeSeriesPoint[]>
export interface AccountBalanceData {
accountId: number accountId: number
currencyCode: string currencyCode: string
balanceTimeSeries: BalanceSnapshot[] data: TimeSeriesPoint[]
} }
export interface TotalBalanceTimeSeries { export interface BalanceTimeSeriesAnalytics {
currencyCode: string accounts: AccountBalanceData[]
balanceTimeSeries: BalanceSnapshot[] totals: CurrencyGroupedTimeSeries
} }
export interface BalanceSnapshot { export interface CategorySpendData {
balance: number categoryId: number
timestamp: string categoryName: string
categoryColor: string
dataByCurrency: CurrencyGroupedTimeSeries
}
export interface CategorySpendTimeSeriesAnalytics {
categories: CategorySpendData[]
} }
export class AnalyticsApiClient extends ApiClient { export class AnalyticsApiClient extends ApiClient {
@ -36,4 +44,8 @@ export class AnalyticsApiClient extends ApiClient {
getBalanceTimeSeries(): Promise<BalanceTimeSeriesAnalytics> { getBalanceTimeSeries(): Promise<BalanceTimeSeriesAnalytics> {
return super.getJson(this.path + '/balance-time-series') return super.getJson(this.path + '/balance-time-series')
} }
getCategorySpendTimeSeries(): Promise<CategorySpendTimeSeriesAnalytics> {
return super.getJson(this.path + '/category-spend-time-series')
}
} }

View File

@ -4,18 +4,26 @@ import HomeModule from '@/components/HomeModule.vue'
import { computed, onMounted, ref, type ComputedRef } from 'vue' import { computed, onMounted, ref, type ComputedRef } from 'vue'
import 'chartjs-adapter-date-fns' import 'chartjs-adapter-date-fns'
import type { Currency } from '@/api/data' import type { Currency } from '@/api/data'
import { AnalyticsApiClient, type BalanceTimeSeriesAnalytics } from '@/api/analytics' import {
AnalyticsApiClient,
type CategorySpendTimeSeriesAnalytics,
type BalanceTimeSeriesAnalytics,
} from '@/api/analytics'
import BalanceTimeSeriesChart from './analytics/BalanceTimeSeriesChart.vue' import BalanceTimeSeriesChart from './analytics/BalanceTimeSeriesChart.vue'
import type { TimeFrame, BalanceTimeSeries } from './analytics/util' import type { TimeFrame, BalanceTimeSeries } from './analytics/util'
import FormGroup from '@/components/common/form/FormGroup.vue' import FormGroup from '@/components/common/form/FormGroup.vue'
import FormControl from '@/components/common/form/FormControl.vue' import FormControl from '@/components/common/form/FormControl.vue'
import { isAfter, isBefore, startOfMonth, startOfYear, sub } from 'date-fns' import { isAfter, isBefore, startOfMonth, startOfYear, sub } from 'date-fns'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import CategorySpendPieChart from './analytics/CategorySpendPieChart.vue'
const route = useRoute() const route = useRoute()
const accounts = ref<Account[]>([]) const accounts = ref<Account[]>([])
const balanceTimeSeriesData = ref<BalanceTimeSeriesAnalytics>() const balanceTimeSeriesData = ref<BalanceTimeSeriesAnalytics>()
const categorySpendTimeSeriesData = ref<CategorySpendTimeSeriesAnalytics>()
const currency = ref<Currency>() const currency = ref<Currency>()
const timeFrame = ref<TimeFrame>({}) const timeFrame = ref<TimeFrame>({})
@ -26,7 +34,8 @@ interface AnalyticsChartType {
const AnalyticsChartTypes: AnalyticsChartType[] = [ const AnalyticsChartTypes: AnalyticsChartType[] = [
{ id: 'account-balances', name: 'Account Balances' }, { id: 'account-balances', name: 'Account Balances' },
{ id: 'total-balances', name: 'Total Balances' }, { id: 'total-balances', name: 'Total Balance' },
{ id: 'category-spend', name: 'Category Spend' },
] ]
const selectedChart = ref<AnalyticsChartType>(AnalyticsChartTypes[0]) const selectedChart = ref<AnalyticsChartType>(AnalyticsChartTypes[0])
@ -48,18 +57,18 @@ const accountBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
for (const accountData of balanceTimeSeriesData.value.accounts) { for (const accountData of balanceTimeSeriesData.value.accounts) {
const account = eligibleAccounts.find((a) => a.id === accountData.accountId) const account = eligibleAccounts.find((a) => a.id === accountData.accountId)
if (account !== undefined) { if (account !== undefined) {
const filteredTimeSeries = accountData.balanceTimeSeries.filter((s) => { const filteredTimeSeries = accountData.data.filter((s) => {
if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) { if (timeFrame.value.start && !isAfter(s.x, timeFrame.value.start)) {
return false return false
} }
if (timeFrame.value.end && !isBefore(s.timestamp, timeFrame.value.end)) { if (timeFrame.value.end && !isBefore(s.x, timeFrame.value.end)) {
return false return false
} }
return true return true
}) })
series.push({ series.push({
label: account.name, label: account.name,
snapshots: filteredTimeSeries, data: filteredTimeSeries,
}) })
} }
} }
@ -67,21 +76,20 @@ const accountBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
}) })
const totalBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => { const totalBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
if (!balanceTimeSeriesData.value) return [] if (!balanceTimeSeriesData.value || !currency.value) return []
const totalsData = balanceTimeSeriesData.value.totals.find( const currencyCode = currency.value.code
(t) => t.currencyCode === currency.value?.code, if (!(currencyCode in balanceTimeSeriesData.value.totals)) return []
) const totalsData = balanceTimeSeriesData.value.totals[currencyCode]
if (!totalsData) return [] const filteredTimeSeries = totalsData.filter((s) => {
const filteredTimeSeries = totalsData.balanceTimeSeries.filter((s) => { if (timeFrame.value.start && !isAfter(s.x, timeFrame.value.start)) {
if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) {
return false return false
} }
if (timeFrame.value.end && !isBefore(s.timestamp, timeFrame.value.end)) { if (timeFrame.value.end && !isBefore(s.x, timeFrame.value.end)) {
return false return false
} }
return true return true
}) })
return [{ label: totalsData.currencyCode, snapshots: filteredTimeSeries }] return [{ label: currencyCode, data: filteredTimeSeries }]
}) })
onMounted(async () => { onMounted(async () => {
@ -89,6 +97,7 @@ onMounted(async () => {
const analyticsApi = new AnalyticsApiClient(route) const analyticsApi = new AnalyticsApiClient(route)
accounts.value = await api.getAccounts() accounts.value = await api.getAccounts()
balanceTimeSeriesData.value = await analyticsApi.getBalanceTimeSeries() balanceTimeSeriesData.value = await analyticsApi.getBalanceTimeSeries()
categorySpendTimeSeriesData.value = await analyticsApi.getCategorySpendTimeSeries()
if (accounts.value.length > 0) { if (accounts.value.length > 0) {
currency.value = accounts.value[0].currency currency.value = accounts.value[0].currency
} }
@ -128,6 +137,14 @@ onMounted(async () => {
:time-frame="timeFrame" :time-frame="timeFrame"
:data="totalBalancesData" :data="totalBalancesData"
/> />
<CategorySpendPieChart
v-if="currency && categorySpendTimeSeriesData && selectedChart.id === 'category-spend'"
:currency="currency"
:time-frame="timeFrame"
:data="categorySpendTimeSeriesData"
/>
<FormGroup> <FormGroup>
<FormControl label="Currency"> <FormControl label="Currency">
<select <select

View File

@ -1,23 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
CategoryScale,
Chart, Chart,
type ChartData, type ChartData,
type ChartDataset, type ChartDataset,
type ChartOptions, type ChartOptions,
Filler, registerables,
Legend,
LinearScale,
LineElement,
PointElement,
TimeScale,
Title,
Tooltip,
} from 'chart.js' } from 'chart.js'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import type { BalanceTimeSeries, TimeFrame } from './util' import type { BalanceTimeSeries, TimeFrame } from './util'
import { integerMoneyToFloat, type Currency } from '@/api/data' import { integerMoneyToFloat, type Currency } from '@/api/data'
import { getTime } from 'date-fns'
import { Line } from 'vue-chartjs' import { Line } from 'vue-chartjs'
const props = defineProps<{ const props = defineProps<{
@ -30,17 +21,7 @@ const props = defineProps<{
const chartData = computed(() => buildChartData()) const chartData = computed(() => buildChartData())
const chartOptions = ref<ChartOptions<'line'> | undefined>() const chartOptions = ref<ChartOptions<'line'> | undefined>()
Chart.register( Chart.register(...registerables)
Title,
Tooltip,
Legend,
LineElement,
CategoryScale,
LinearScale,
PointElement,
TimeScale,
Filler,
)
const COLORS = [ const COLORS = [
[255, 69, 69], [255, 69, 69],
@ -82,10 +63,10 @@ function buildChartData(): ChartData<'line'> {
const color = COLORS[colorIdx++] const color = COLORS[colorIdx++]
if (colorIdx >= COLORS.length) colorIdx = 0 if (colorIdx >= COLORS.length) colorIdx = 0
const points = series.snapshots.map((p) => { const points = series.data.map((p) => {
return { return {
x: getTime(p.timestamp), x: p.x,
y: integerMoneyToFloat(p.balance, props.currency), y: integerMoneyToFloat(p.y, props.currency),
} }
}) })
datasets.push({ datasets.push({
@ -100,7 +81,6 @@ function buildChartData(): ChartData<'line'> {
pointHitRadius: 5, pointHitRadius: 5,
}) })
} }
return { datasets: datasets } return { datasets: datasets }
} }
</script> </script>

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { integerMoneyToFloat, type Currency } from '@/api/data'
import type { TimeFrame } from './util'
import type { CategorySpendTimeSeriesAnalytics } from '@/api/analytics'
import { computed, onMounted, ref } from 'vue'
import { type ChartOptions, type ChartData, Chart, registerables } from 'chart.js'
import { Pie } from 'vue-chartjs'
import { isAfter, isBefore } from 'date-fns'
const props = defineProps<{
timeFrame: TimeFrame
currency: Currency
data: CategorySpendTimeSeriesAnalytics
}>()
const chartData = computed(() => buildChartData())
const chartOptions = ref<ChartOptions<'pie'>>()
Chart.register(...registerables)
onMounted(() => {
chartOptions.value = {
plugins: {
title: {
display: true,
text: 'Category Spend',
},
},
}
})
function buildChartData(): ChartData<'pie'> {
const labels: string[] = []
const data: number[] = []
const colors: string[] = []
for (const categoryData of props.data.categories) {
labels.push(categoryData.categoryName)
colors.push('#' + categoryData.categoryColor)
if (!(props.currency.code in categoryData.dataByCurrency)) {
data.push(0)
continue
}
const sumOverTimeFrame = categoryData.dataByCurrency[props.currency.code]
.filter((d) => {
if (props.timeFrame.start && !isAfter(d.x, props.timeFrame.start)) {
return false
}
if (props.timeFrame.end && !isBefore(d.x, props.timeFrame.end)) {
return false
}
return true
})
.reduce((acc, v) => acc + v.y, 0)
data.push(integerMoneyToFloat(sumOverTimeFrame, props.currency))
}
return {
labels: labels,
datasets: [
{
data: data,
backgroundColor: colors,
},
],
}
}
</script>
<template>
<Pie
id="pie"
v-if="chartData && chartOptions"
:data="chartData"
:options="chartOptions"
/>
</template>

View File

@ -1,4 +1,4 @@
import type { BalanceSnapshot } from '@/api/analytics' import type { TimeSeriesPoint } from '@/api/analytics'
export interface TimeFrame { export interface TimeFrame {
start?: Date start?: Date
@ -7,5 +7,5 @@ export interface TimeFrame {
export interface BalanceTimeSeries { export interface BalanceTimeSeries {
label: string label: string
snapshots: BalanceSnapshot[] data: TimeSeriesPoint[]
} }