Added category spend and more dynamic charts.
This commit is contained in:
parent
bed9562d6b
commit
e7ccd25549
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -97,4 +81,73 @@ void computeAccountBalanceTimeSeries(Profile profile, ProfileRepository profileR
|
||||||
profile.username,
|
profile.username,
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,8 @@ SysTime[] generateTimeSeriesTimestamps(Duration intervalSize, int intervalCount)
|
||||||
timestamps[i + 1] = timestamp;
|
timestamps[i + 1] = timestamp;
|
||||||
}
|
}
|
||||||
return timestamps;
|
return timestamps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ulong toUnixMillis(in SysTime ts) {
|
||||||
|
return (ts - SysTime(unixTimeToStdTime(0))).total!"msecs";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue