diff --git a/finnow-api/source/analytics/api.d b/finnow-api/source/analytics/api.d index 94bddd0..57e65ed 100644 --- a/finnow-api/source/analytics/api.d +++ b/finnow-api/source/analytics/api.d @@ -9,6 +9,11 @@ void handleGetBalanceTimeSeries(ref ServerHttpRequest request, ref ServerHttpRes 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 * directly from the user's profile properties table and writing it to the diff --git a/finnow-api/source/analytics/balances.d b/finnow-api/source/analytics/balances.d index 24aff47..1baa62b 100644 --- a/finnow-api/source/analytics/balances.d +++ b/finnow-api/source/analytics/balances.d @@ -1,6 +1,6 @@ module analytics.balances; -import handy_http_primitives; +import handy_http_primitives : Optional, mapIfPresent; import std.datetime; import std.stdio; import std.path; @@ -14,26 +14,28 @@ import account.data; import account.model; import account.service; import analytics.util; +import transaction.model; +import transaction.dto; +import util.pagination; -struct BalanceSnapshot { - long balance; - string timestamp; +struct TimeSeriesPoint { + /// The millisecond UTC timestamp. + ulong x; + /// The value at this timestamp. + long y; } -struct AccountBalanceTimeSeries { +alias CurrencyGroupedTimeSeries = TimeSeriesPoint[][string]; + +struct AccountBalanceData { ulong accountId; string currencyCode; - BalanceSnapshot[] balanceTimeSeries; -} - -struct TotalBalanceTimeSeries { - string currencyCode; - BalanceSnapshot[] balanceTimeSeries; + TimeSeriesPoint[] data; } struct BalanceTimeSeriesAnalytics { - AccountBalanceTimeSeries[] accounts; - TotalBalanceTimeSeries[] totals; + AccountBalanceData[] accounts; + CurrencyGroupedTimeSeries totals; } 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. BalanceTimeSeriesAnalytics data; foreach (account; accounts) { - data.accounts ~= AccountBalanceTimeSeries( - account.id, - account.currency.code.idup, - [] - ); + AccountBalanceData accountData; + accountData.accountId = account.id; + accountData.currencyCode = account.currency.code.idup; + data.accounts ~= accountData; } foreach (timestamp; generateTimeSeriesTimestamps(days(1), 365)) { @@ -55,39 +56,22 @@ void computeAccountBalanceTimeSeries(Profile profile, ProfileRepository profileR foreach (idx, account; accounts) { auto balance = getBalance(ds, account.id, timestamp); if (!balance.isNull) { - data.accounts[idx].balanceTimeSeries ~= BalanceSnapshot( - balance.value, - timestamp.toISOExtString() + data.accounts[idx].data ~= TimeSeriesPoint( + timestamp.toUnixMillis(), + balance.value ); } } // Compute total balances for this timestamp. auto totalBalances = getTotalBalanceForAllAccounts(ds, timestamp); - foreach (bal; totalBalances) { - // Assign the balance to one of our running totals. - 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())] - ); - } + foreach (CurrencyBalance bal; totalBalances) { + data.totals[bal.currency.code.idup] ~= TimeSeriesPoint(timestamp.toUnixMillis(), bal.balance); } } ds.doTransaction(() { - ds.getPropertiesRepository().deleteAllByPrefix("analytics"); + ds.getPropertiesRepository().deleteProperty("analytics.balanceTimeSeries"); ds.getPropertiesRepository().setProperty( "analytics.balanceTimeSeries", serializeToJsonPretty(data) @@ -97,4 +81,73 @@ void computeAccountBalanceTimeSeries(Profile profile, ProfileRepository profileR profile.username, profile.name ); -} \ No newline at end of file +} + +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; +} diff --git a/finnow-api/source/analytics/util.d b/finnow-api/source/analytics/util.d index 96dc269..e104825 100644 --- a/finnow-api/source/analytics/util.d +++ b/finnow-api/source/analytics/util.d @@ -16,4 +16,8 @@ SysTime[] generateTimeSeriesTimestamps(Duration intervalSize, int intervalCount) timestamps[i + 1] = timestamp; } return timestamps; -} \ No newline at end of file +} + +ulong toUnixMillis(in SysTime ts) { + return (ts - SysTime(unixTimeToStdTime(0))).total!"msecs"; +} diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index b2d8ef6..8ff9612 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -95,6 +95,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { // Analytics endpoints: import analytics.api; 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; // Various other data endpoints: diff --git a/finnow-api/source/app.d b/finnow-api/source/app.d index 100a146..ab9cf71 100644 --- a/finnow-api/source/app.d +++ b/finnow-api/source/app.d @@ -25,6 +25,7 @@ void main() { jobScheduler.addJob(() { info("Computing account balance time series analytics for all users..."); doForAllUserProfiles(&computeAccountBalanceTimeSeries); + doForAllUserProfiles(&computeCategorySpendTimeSeries); info("Done computing analytics!"); }, analyticsSchedule); jobScheduler.start(); diff --git a/finnow-api/source/util/pagination.d b/finnow-api/source/util/pagination.d index 3e8f4ab..d463f83 100644 --- a/finnow-api/source/util/pagination.d +++ b/finnow-api/source/util/pagination.d @@ -40,17 +40,17 @@ struct PageRequest { * The requested page number, starting from 1 for the first page, or zero * for an unpaged request. */ - immutable uint page; + uint page; /** * The maximum number of items to include in each page of results. */ - immutable ushort size; + ushort size; /** * A list of sorts to apply. */ - immutable Sort[] sorts; + Sort[] sorts; bool isUnpaged() const { return page < 1; @@ -60,7 +60,7 @@ struct PageRequest { 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.array; uint pg = request.getParamAs!uint("page", defaults.page); @@ -74,7 +74,7 @@ struct PageRequest { if (s.length == 0 && defaults.sorts.length > 0) { s = defaults.sorts.dup; } - return PageRequest(pg, sz, s.idup); + return PageRequest(pg, sz, s.dup); } string toSql() const { @@ -107,13 +107,13 @@ struct PageRequest { } PageRequest next() const { - if (isUnpaged) return this; - return PageRequest(page + 1, size, sorts); + if (isUnpaged) return PageRequest(page, size, sorts.dup); + return PageRequest(page + 1, size, sorts.dup); } PageRequest prev() const { - if (isUnpaged) return this; - return PageRequest(page - 1, size, sorts); + if (isUnpaged) return PageRequest(page, size, sorts.dup); + 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); } - 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); return Page( items, - pageRequest, + PageRequest(pageRequest.page, pageRequest.size, pageRequest.sorts.dup), totalCount, pageCount, pageRequest.page == 1, diff --git a/web-app/src/api/analytics.ts b/web-app/src/api/analytics.ts index 8eb803e..9963eb2 100644 --- a/web-app/src/api/analytics.ts +++ b/web-app/src/api/analytics.ts @@ -2,25 +2,33 @@ import type { RouteLocation } from 'vue-router' import { ApiClient } from './base' import { getSelectedProfile } from './profile' -export interface BalanceTimeSeriesAnalytics { - accounts: AccountBalanceTimeSeries[] - totals: TotalBalanceTimeSeries[] +export interface TimeSeriesPoint { + x: number + y: number } -export interface AccountBalanceTimeSeries { +export type CurrencyGroupedTimeSeries = Record + +export interface AccountBalanceData { accountId: number currencyCode: string - balanceTimeSeries: BalanceSnapshot[] + data: TimeSeriesPoint[] } -export interface TotalBalanceTimeSeries { - currencyCode: string - balanceTimeSeries: BalanceSnapshot[] +export interface BalanceTimeSeriesAnalytics { + accounts: AccountBalanceData[] + totals: CurrencyGroupedTimeSeries } -export interface BalanceSnapshot { - balance: number - timestamp: string +export interface CategorySpendData { + categoryId: number + categoryName: string + categoryColor: string + dataByCurrency: CurrencyGroupedTimeSeries +} + +export interface CategorySpendTimeSeriesAnalytics { + categories: CategorySpendData[] } export class AnalyticsApiClient extends ApiClient { @@ -36,4 +44,8 @@ export class AnalyticsApiClient extends ApiClient { getBalanceTimeSeries(): Promise { return super.getJson(this.path + '/balance-time-series') } + + getCategorySpendTimeSeries(): Promise { + return super.getJson(this.path + '/category-spend-time-series') + } } diff --git a/web-app/src/pages/home/AnalyticsModule.vue b/web-app/src/pages/home/AnalyticsModule.vue index 4e25793..ca6dff1 100644 --- a/web-app/src/pages/home/AnalyticsModule.vue +++ b/web-app/src/pages/home/AnalyticsModule.vue @@ -4,18 +4,26 @@ import HomeModule from '@/components/HomeModule.vue' import { computed, onMounted, ref, type ComputedRef } from 'vue' import 'chartjs-adapter-date-fns' 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 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' import { useRoute } from 'vue-router' +import CategorySpendPieChart from './analytics/CategorySpendPieChart.vue' const route = useRoute() const accounts = ref([]) + const balanceTimeSeriesData = ref() +const categorySpendTimeSeriesData = ref() + const currency = ref() const timeFrame = ref({}) @@ -26,7 +34,8 @@ interface AnalyticsChartType { const AnalyticsChartTypes: AnalyticsChartType[] = [ { 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(AnalyticsChartTypes[0]) @@ -48,18 +57,18 @@ const accountBalancesData: ComputedRef = computed(() => { 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)) { + const filteredTimeSeries = accountData.data.filter((s) => { + if (timeFrame.value.start && !isAfter(s.x, timeFrame.value.start)) { 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 true }) series.push({ label: account.name, - snapshots: filteredTimeSeries, + data: filteredTimeSeries, }) } } @@ -67,21 +76,20 @@ const accountBalancesData: ComputedRef = computed(() => { }) const totalBalancesData: ComputedRef = computed(() => { - if (!balanceTimeSeriesData.value) return [] - const totalsData = balanceTimeSeriesData.value.totals.find( - (t) => t.currencyCode === currency.value?.code, - ) - if (!totalsData) return [] - const filteredTimeSeries = totalsData.balanceTimeSeries.filter((s) => { - if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) { + if (!balanceTimeSeriesData.value || !currency.value) return [] + const currencyCode = currency.value.code + if (!(currencyCode in balanceTimeSeriesData.value.totals)) return [] + const totalsData = balanceTimeSeriesData.value.totals[currencyCode] + const filteredTimeSeries = totalsData.filter((s) => { + if (timeFrame.value.start && !isAfter(s.x, timeFrame.value.start)) { 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 true }) - return [{ label: totalsData.currencyCode, snapshots: filteredTimeSeries }] + return [{ label: currencyCode, data: filteredTimeSeries }] }) onMounted(async () => { @@ -89,6 +97,7 @@ onMounted(async () => { const analyticsApi = new AnalyticsApiClient(route) accounts.value = await api.getAccounts() balanceTimeSeriesData.value = await analyticsApi.getBalanceTimeSeries() + categorySpendTimeSeriesData.value = await analyticsApi.getCategorySpendTimeSeries() if (accounts.value.length > 0) { currency.value = accounts.value[0].currency } @@ -128,6 +137,14 @@ onMounted(async () => { :time-frame="timeFrame" :data="totalBalancesData" /> + + +