Refactored category spend and balance analytics.
This commit is contained in:
parent
fb7850c181
commit
3394869410
|
|
@ -8,7 +8,7 @@ import std.datetime;
|
||||||
import profile.data;
|
import profile.data;
|
||||||
import profile.service;
|
import profile.service;
|
||||||
import profile.api : PROFILE_PATH;
|
import profile.api : PROFILE_PATH;
|
||||||
import analytics.balances;
|
import analytics.util;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.data;
|
import util.data;
|
||||||
|
|
||||||
|
|
@ -19,11 +19,24 @@ void handleGetCategorySpendTimeSeries(ref ServerHttpRequest request, ref ServerH
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(PROFILE_PATH ~ "/analytics/balance-time-series")
|
@GetMapping(PROFILE_PATH ~ "/analytics/balance-time-series")
|
||||||
void handleGetBalanceTimeSeriesV2(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleGetBalanceTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
import analytics.modules.balances;
|
||||||
auto ds = getProfileDataSource(request);
|
auto ds = getProfileDataSource(request);
|
||||||
Currency currency = Currency.ofCode(request.getParamAs!string("currency", Currencies.USD.code));
|
Currency currency = Currency.ofCode(request.getParamAs!string("currency", Currencies.USD.code));
|
||||||
TimeRange timeRange = TimeRange(Optional!(SysTime).empty(), Optional!(SysTime).empty());
|
auto data = computeBalanceTimeSeries(ds, currency, getTimeRange(request));
|
||||||
auto data = computeBalanceTimeSeriesV2(ds, currency, timeRange);
|
writeJsonBody(response, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(PROFILE_PATH ~ "/analytics/category-spend")
|
||||||
|
void handleGetCategorySpend(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
import analytics.modules.category_spend;
|
||||||
|
auto ds = getProfileDataSource(request);
|
||||||
|
Currency currency = Currency.ofCode(request.getParamAs!string("currency", Currencies.USD.code));
|
||||||
|
long categoryParentId = request.getParamAs!long("parentId", -1);
|
||||||
|
Optional!ulong categoryParentIdOpt = categoryParentId == -1
|
||||||
|
? Optional!(ulong).empty()
|
||||||
|
: Optional!(ulong).of(categoryParentId);
|
||||||
|
auto data = computeCategorySpend(ds, currency, getTimeRange(request), categoryParentIdOpt);
|
||||||
writeJsonBody(response, data);
|
writeJsonBody(response, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,3 +59,16 @@ private void serveJsonFromProperty(ref ServerHttpResponse response, ref ProfileD
|
||||||
response.writeBodyString(jsonStr, ContentTypes.APPLICATION_JSON);
|
response.writeBodyString(jsonStr, ContentTypes.APPLICATION_JSON);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TimeRange getTimeRange(in ServerHttpRequest request) {
|
||||||
|
long fromTimestampMs = request.getParamAs!long("from", -1);
|
||||||
|
long toTimestampMs = request.getParamAs!long("to", -1);
|
||||||
|
return TimeRange(
|
||||||
|
fromTimestampMs == -1
|
||||||
|
? Optional!(SysTime).empty()
|
||||||
|
: Optional!(SysTime).of(toSysTime(fromTimestampMs)),
|
||||||
|
toTimestampMs == -1
|
||||||
|
? Optional!(SysTime).empty()
|
||||||
|
: Optional!(SysTime).of(toSysTime(toTimestampMs))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
module analytics.data;
|
module analytics.data;
|
||||||
|
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
import handy_http_primitives : Optional;
|
||||||
|
import asdf : serdeTransformOut;
|
||||||
|
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.data;
|
import util.data;
|
||||||
import analytics.balances;
|
|
||||||
import account.model;
|
import account.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A common time-series data point.
|
||||||
|
*/
|
||||||
|
struct TimeSeriesPoint {
|
||||||
|
/// The millisecond UTC timestamp.
|
||||||
|
ulong x;
|
||||||
|
/// The value at this timestamp.
|
||||||
|
long y;
|
||||||
|
}
|
||||||
|
|
||||||
struct JournalEntryStub {
|
struct JournalEntryStub {
|
||||||
SysTime timestamp;
|
SysTime timestamp;
|
||||||
ulong accountId;
|
ulong accountId;
|
||||||
|
|
@ -21,6 +32,15 @@ struct BalanceRecordStub {
|
||||||
long value;
|
long value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CategorySpendData {
|
||||||
|
ulong categoryId;
|
||||||
|
string categoryName;
|
||||||
|
string categoryColor;
|
||||||
|
@serdeTransformOut!serializeOptional
|
||||||
|
Optional!ulong parentCategoryId;
|
||||||
|
long amount;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository that provides various functions for fetching data that's used in
|
* Repository that provides various functions for fetching data that's used in
|
||||||
* the calculation of analytics, separate from usual app functionality.
|
* the calculation of analytics, separate from usual app functionality.
|
||||||
|
|
@ -34,4 +54,8 @@ interface AnalyticsRepository {
|
||||||
in Currency currency,
|
in Currency currency,
|
||||||
in TimeRange timeRange
|
in TimeRange timeRange
|
||||||
);
|
);
|
||||||
|
CategorySpendData[] getCategorySpendData(
|
||||||
|
in Currency currency,
|
||||||
|
in TimeRange timeRange
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import util.money;
|
||||||
import util.data;
|
import util.data;
|
||||||
import util.sqlite;
|
import util.sqlite;
|
||||||
import account.model : AccountJournalEntryType, AccountType;
|
import account.model : AccountJournalEntryType, AccountType;
|
||||||
import analytics.balances;
|
import analytics.modules.balances;
|
||||||
import analytics.data;
|
import analytics.data;
|
||||||
|
|
||||||
class SqliteAnalyticsRepository : AnalyticsRepository {
|
class SqliteAnalyticsRepository : AnalyticsRepository {
|
||||||
|
|
@ -101,4 +101,53 @@ class SqliteAnalyticsRepository : AnalyticsRepository {
|
||||||
}
|
}
|
||||||
return app[];
|
return app[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CategorySpendData[] getCategorySpendData(
|
||||||
|
in Currency currency,
|
||||||
|
in TimeRange timeRange
|
||||||
|
) {
|
||||||
|
QueryBuilder qb = QueryBuilder("transaction_category c")
|
||||||
|
.select("c.id,c.name,c.color,c.parent_id")
|
||||||
|
.select(`
|
||||||
|
(
|
||||||
|
SUM(CASE WHEN je.type LIKE 'DEBIT' THEN je.amount ELSE 0 END)
|
||||||
|
- SUM(CASE WHEN je.type LIKE 'CREDIT' THEN je.amount ELSE 0 END)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.join("LEFT JOIN \"transaction\" t ON t.category_id = c.id")
|
||||||
|
.join("LEFT JOIN account_journal_entry je ON je.transaction_id = t.id");
|
||||||
|
qb.where("t.internal_transfer = false");
|
||||||
|
qb.where("t.currency = ?");
|
||||||
|
qb.withArgBinding((ref stmt, ref idx) {
|
||||||
|
stmt.bind(idx++, currency.codeString);
|
||||||
|
});
|
||||||
|
if (timeRange.fromTime) {
|
||||||
|
qb.where("t.timestamp >= ?")
|
||||||
|
.withArgBinding((ref stmt, ref idx) {
|
||||||
|
stmt.bind(idx++, timeRange.fromTime.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (timeRange.toTime) {
|
||||||
|
qb.where("t.timestamp <= ?")
|
||||||
|
.withArgBinding((ref stmt, ref idx) {
|
||||||
|
stmt.bind(idx++, timeRange.fromTime.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
string query = qb.build() ~ " GROUP BY c.id ORDER BY c.id";
|
||||||
|
Statement stmt = db.prepare(query);
|
||||||
|
qb.applyArgBindings(stmt);
|
||||||
|
ResultRange result = stmt.execute();
|
||||||
|
Appender!(CategorySpendData[]) app;
|
||||||
|
foreach (row; result) {
|
||||||
|
import std.typecons : Nullable;
|
||||||
|
app ~= CategorySpendData(
|
||||||
|
row.peek!ulong(0),
|
||||||
|
row.peek!string(1),
|
||||||
|
row.peek!string(2),
|
||||||
|
toOptional(row.peek!(Nullable!ulong)(3)),
|
||||||
|
row.peek!long(4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return app[];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
module analytics.balances;
|
module analytics.modules.balances;
|
||||||
|
|
||||||
import handy_http_primitives : Optional, mapIfPresent;
|
import handy_http_primitives : Optional, mapIfPresent;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
|
@ -24,13 +24,6 @@ import util.money;
|
||||||
import util.data;
|
import util.data;
|
||||||
import analytics.data;
|
import analytics.data;
|
||||||
|
|
||||||
struct TimeSeriesPoint {
|
|
||||||
/// The millisecond UTC timestamp.
|
|
||||||
ulong x;
|
|
||||||
/// The value at this timestamp.
|
|
||||||
long y;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AccountBalanceData {
|
struct AccountBalanceData {
|
||||||
ulong accountId;
|
ulong accountId;
|
||||||
TimeSeriesPoint[] data;
|
TimeSeriesPoint[] data;
|
||||||
|
|
@ -51,7 +44,7 @@ struct BalanceTimeSeriesAnalytics {
|
||||||
* Returns: An analytics response containing a "totals" time series, as well
|
* Returns: An analytics response containing a "totals" time series, as well
|
||||||
* as a time series for each known account in the given time range.
|
* as a time series for each known account in the given time range.
|
||||||
*/
|
*/
|
||||||
BalanceTimeSeriesAnalytics computeBalanceTimeSeriesV2(
|
BalanceTimeSeriesAnalytics computeBalanceTimeSeries(
|
||||||
ProfileDataSource ds,
|
ProfileDataSource ds,
|
||||||
in Currency currency,
|
in Currency currency,
|
||||||
in TimeRange timeRange
|
in TimeRange timeRange
|
||||||
|
|
@ -151,75 +144,3 @@ private Optional!long deriveBalance(
|
||||||
}
|
}
|
||||||
return Optional!(long).of(balance);
|
return Optional!(long).of(balance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
alias CurrencyGroupedTimeSeries = TimeSeriesPoint[][string];
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
module analytics.modules.category_spend;
|
||||||
|
|
||||||
|
import handy_http_primitives : Optional;
|
||||||
|
import std.algorithm;
|
||||||
|
import std.array;
|
||||||
|
|
||||||
|
import analytics.data;
|
||||||
|
import profile.data;
|
||||||
|
import util.money : Currency;
|
||||||
|
import util.data : TimeRange;
|
||||||
|
|
||||||
|
CategorySpendData[] computeCategorySpend(
|
||||||
|
ProfileDataSource ds,
|
||||||
|
in Currency currency,
|
||||||
|
in TimeRange timeRange,
|
||||||
|
in Optional!ulong parentId
|
||||||
|
) {
|
||||||
|
AnalyticsRepository repo = ds.getAnalyticsRepository();
|
||||||
|
CategorySpendData[] allCategories = repo.getCategorySpendData(currency, timeRange);
|
||||||
|
return allCategories
|
||||||
|
.filter!(d => (
|
||||||
|
parentId
|
||||||
|
? d.parentCategoryId && d.parentCategoryId.value == parentId.value
|
||||||
|
: d.parentCategoryId.isNull
|
||||||
|
))
|
||||||
|
.map!((category) {
|
||||||
|
// For each category that we're reporting on, recursively sum up
|
||||||
|
// the amount of the category and all its children.
|
||||||
|
long totalRecursiveSum = sumAllChildCategoriesRecursive(category, allCategories);
|
||||||
|
return CategorySpendData(
|
||||||
|
category.categoryId,
|
||||||
|
category.categoryName,
|
||||||
|
category.categoryColor,
|
||||||
|
category.parentCategoryId,
|
||||||
|
totalRecursiveSum
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long sumAllChildCategoriesRecursive(in CategorySpendData parentCategory, in CategorySpendData[] data) {
|
||||||
|
long sum = parentCategory.amount;
|
||||||
|
foreach (category; data) {
|
||||||
|
if (category.parentCategoryId && category.parentCategoryId.value == parentCategory.categoryId) {
|
||||||
|
sum += sumAllChildCategoriesRecursive(category, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
module analytics;
|
|
||||||
|
|
||||||
public import analytics.balances;
|
|
||||||
|
|
||||||
import profile.data;
|
|
||||||
import profile.model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to run a function on each available user profile.
|
|
||||||
* Params:
|
|
||||||
* fn = The function to run.
|
|
||||||
*/
|
|
||||||
void doForAllUserProfiles(
|
|
||||||
void function(Profile, ProfileRepository) fn
|
|
||||||
) {
|
|
||||||
import auth.data;
|
|
||||||
import auth.data_impl_fs;
|
|
||||||
import profile.data;
|
|
||||||
import profile.data_impl_sqlite;
|
|
||||||
|
|
||||||
UserRepository userRepo = new FileSystemUserRepository();
|
|
||||||
foreach (user; userRepo.findAll()) {
|
|
||||||
ProfileRepository profileRepo = new FileSystemProfileRepository(user.username);
|
|
||||||
foreach (prof; profileRepo.findAll()) {
|
|
||||||
fn(prof, profileRepo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -34,8 +34,6 @@ SysTime[] generateTimeSeriesTimestamps(Duration intervalSize, in TimeRange timeR
|
||||||
} else {
|
} else {
|
||||||
startOfRange = timeRange.fromTime.value;
|
startOfRange = timeRange.fromTime.value;
|
||||||
}
|
}
|
||||||
import std.stdio;
|
|
||||||
writefln!"start = %s, end = %s"(startOfRange, endOfRange);
|
|
||||||
|
|
||||||
Appender!(SysTime[]) app;
|
Appender!(SysTime[]) app;
|
||||||
app ~= startOfRange;
|
app ~= startOfRange;
|
||||||
|
|
@ -51,3 +49,7 @@ SysTime[] generateTimeSeriesTimestamps(Duration intervalSize, in TimeRange timeR
|
||||||
ulong toUnixMillis(in SysTime ts) {
|
ulong toUnixMillis(in SysTime ts) {
|
||||||
return (ts - SysTime(unixTimeToStdTime(0))).total!"msecs";
|
return (ts - SysTime(unixTimeToStdTime(0))).total!"msecs";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SysTime toSysTime(in ulong unixMillis) {
|
||||||
|
return SysTime(unixTimeToStdTime(0)) + msecs(unixMillis);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import scheduled;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
|
|
||||||
import analytics;
|
|
||||||
|
|
||||||
void startScheduledJobs() {
|
void startScheduledJobs() {
|
||||||
JobSchedule analyticsSchedule = new FixedIntervalSchedule(
|
JobSchedule analyticsSchedule = new FixedIntervalSchedule(
|
||||||
hours(1),
|
hours(1),
|
||||||
|
|
@ -13,10 +11,9 @@ void startScheduledJobs() {
|
||||||
);
|
);
|
||||||
|
|
||||||
JobScheduler jobScheduler = new TaskPoolScheduler();
|
JobScheduler jobScheduler = new TaskPoolScheduler();
|
||||||
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(&computeCategorySpendTimeSeries);
|
// info("Done computing analytics!");
|
||||||
info("Done computing analytics!");
|
// }, analyticsSchedule);
|
||||||
}, analyticsSchedule);
|
// jobScheduler.start();
|
||||||
jobScheduler.start();
|
|
||||||
}
|
}
|
||||||
|
|
@ -7,28 +7,22 @@ export interface TimeSeriesPoint {
|
||||||
y: number
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CurrencyGroupedTimeSeries = Record<string, TimeSeriesPoint[]>
|
|
||||||
|
|
||||||
export interface AccountBalanceData {
|
export interface AccountBalanceData {
|
||||||
accountId: number
|
accountId: number
|
||||||
currencyCode: string
|
|
||||||
data: TimeSeriesPoint[]
|
data: TimeSeriesPoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BalanceTimeSeriesAnalytics {
|
export interface BalanceTimeSeriesAnalytics {
|
||||||
accounts: AccountBalanceData[]
|
accounts: AccountBalanceData[]
|
||||||
totals: CurrencyGroupedTimeSeries
|
totals: TimeSeriesPoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategorySpendData {
|
export interface CategorySpendData {
|
||||||
categoryId: number
|
categoryId: number | null
|
||||||
categoryName: string
|
categoryName: string | null
|
||||||
categoryColor: string
|
categoryColor: string | null
|
||||||
dataByCurrency: CurrencyGroupedTimeSeries
|
parentCategoryId: number | null
|
||||||
}
|
amount: number
|
||||||
|
|
||||||
export interface CategorySpendTimeSeriesAnalytics {
|
|
||||||
categories: CategorySpendData[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnalyticsApiClient extends ApiClient {
|
export class AnalyticsApiClient extends ApiClient {
|
||||||
|
|
@ -41,11 +35,39 @@ export class AnalyticsApiClient extends ApiClient {
|
||||||
this.path = `/profiles/${this.profileName}/analytics`
|
this.path = `/profiles/${this.profileName}/analytics`
|
||||||
}
|
}
|
||||||
|
|
||||||
getBalanceTimeSeries(): Promise<BalanceTimeSeriesAnalytics> {
|
getBalanceTimeSeries(
|
||||||
return super.getJson(this.path + '/balance-time-series')
|
currencyCode: string,
|
||||||
|
fromTimestampMs: number | null,
|
||||||
|
toTimestampMs: number | null,
|
||||||
|
): Promise<BalanceTimeSeriesAnalytics> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('currency', currencyCode)
|
||||||
|
if (fromTimestampMs !== null) {
|
||||||
|
params.append('from', fromTimestampMs + '')
|
||||||
|
}
|
||||||
|
if (toTimestampMs !== null) {
|
||||||
|
params.append('to', toTimestampMs + '')
|
||||||
|
}
|
||||||
|
return super.getJson(this.path + '/balance-time-series?' + params.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategorySpendTimeSeries(): Promise<CategorySpendTimeSeriesAnalytics> {
|
getCategorySpend(
|
||||||
return super.getJson(this.path + '/category-spend-time-series')
|
currencyCode: string,
|
||||||
|
fromTimestampMs: number | null,
|
||||||
|
toTimestampMs: number | null,
|
||||||
|
parentCategoryId: number | null,
|
||||||
|
): Promise<CategorySpendData[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('currency', currencyCode)
|
||||||
|
if (fromTimestampMs !== null) {
|
||||||
|
params.append('from', fromTimestampMs + '')
|
||||||
|
}
|
||||||
|
if (toTimestampMs !== null) {
|
||||||
|
params.append('to', toTimestampMs + '')
|
||||||
|
}
|
||||||
|
if (parentCategoryId !== null) {
|
||||||
|
params.append('parentId', parentCategoryId + '')
|
||||||
|
}
|
||||||
|
return super.getJson(this.path + '/category-spend?' + params.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,27 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AccountApiClient, type Account } from '@/api/account'
|
import { AccountApiClient, type Account } from '@/api/account'
|
||||||
import HomeModule from '@/components/HomeModule.vue'
|
import HomeModule from '@/components/HomeModule.vue'
|
||||||
import { computed, onMounted, ref, type ComputedRef } from 'vue'
|
import { computed, onMounted, ref } 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 {
|
import {
|
||||||
AnalyticsApiClient,
|
AnalyticsApiClient,
|
||||||
type CategorySpendTimeSeriesAnalytics,
|
|
||||||
type BalanceTimeSeriesAnalytics,
|
|
||||||
} from '@/api/analytics'
|
} from '@/api/analytics'
|
||||||
import BalanceTimeSeriesChart from './analytics/BalanceTimeSeriesChart.vue'
|
import type { TimeFrame } 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 { startOfMonth, startOfYear, sub } from 'date-fns'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import CategorySpendPieChart from './analytics/CategorySpendPieChart.vue'
|
import CategorySpendPieChart from './analytics/CategorySpendPieChart.vue'
|
||||||
|
import BalanceTimeSeriesChart from './analytics/BalanceTimeSeriesChart.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const analyticsApi = computed(() => {
|
||||||
|
return new AnalyticsApiClient(route)
|
||||||
|
})
|
||||||
|
|
||||||
const accounts = ref<Account[]>([])
|
const accounts = ref<Account[]>([])
|
||||||
|
|
||||||
const balanceTimeSeriesData = ref<BalanceTimeSeriesAnalytics>()
|
|
||||||
const categorySpendTimeSeriesData = ref<CategorySpendTimeSeriesAnalytics>()
|
|
||||||
|
|
||||||
const currency = ref<Currency>()
|
const currency = ref<Currency>()
|
||||||
const timeFrame = ref<TimeFrame>({})
|
const timeFrame = ref<TimeFrame>({})
|
||||||
|
|
||||||
|
|
@ -34,7 +32,6 @@ 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 Balance' },
|
|
||||||
{ id: 'category-spend', name: 'Category Spend' },
|
{ id: 'category-spend', name: 'Category Spend' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -50,54 +47,9 @@ const availableCurrencies = computed(() => {
|
||||||
return currencies
|
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.data.filter((s) => {
|
|
||||||
if (timeFrame.value.start && !isAfter(s.x, timeFrame.value.start)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (timeFrame.value.end && !isBefore(s.x, timeFrame.value.end)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
series.push({
|
|
||||||
label: account.name,
|
|
||||||
data: filteredTimeSeries,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return series
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
|
|
||||||
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.x, timeFrame.value.end)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return [{ label: currencyCode, data: filteredTimeSeries }]
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const api = new AccountApiClient(route)
|
const api = new AccountApiClient(route)
|
||||||
const analyticsApi = new AnalyticsApiClient(route)
|
|
||||||
accounts.value = await api.getAccounts()
|
accounts.value = await api.getAccounts()
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -108,61 +60,30 @@ onMounted(async () => {
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormControl label="Chart">
|
<FormControl label="Chart">
|
||||||
<select v-model="selectedChart">
|
<select v-model="selectedChart">
|
||||||
<option
|
<option v-for="ct in AnalyticsChartTypes" :key="ct.id" :value="ct">
|
||||||
v-for="ct in AnalyticsChartTypes"
|
|
||||||
:key="ct.id"
|
|
||||||
:value="ct"
|
|
||||||
>
|
|
||||||
{{ ct.name }}
|
{{ ct.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<BalanceTimeSeriesChart
|
<BalanceTimeSeriesChart v-if="currency && selectedChart.id === 'account-balances'" title="Account Balances"
|
||||||
v-if="currency && balanceTimeSeriesData && selectedChart.id === 'account-balances'"
|
:currency="currency" :time-frame="timeFrame" :api="analyticsApi" />
|
||||||
title="Account Balances"
|
|
||||||
:currency="currency"
|
|
||||||
:time-frame="timeFrame"
|
|
||||||
:data="accountBalancesData"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BalanceTimeSeriesChart
|
<CategorySpendPieChart v-if="currency && selectedChart.id === 'category-spend'" :currency="currency"
|
||||||
v-if="currency && balanceTimeSeriesData && selectedChart.id === 'total-balances'"
|
:api="analyticsApi" :time-frame="timeFrame" />
|
||||||
title="Total Balances"
|
|
||||||
:currency="currency"
|
|
||||||
:time-frame="timeFrame"
|
|
||||||
: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 v-model="currency" :disabled="availableCurrencies.length < 2">
|
||||||
v-model="currency"
|
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency">
|
||||||
:disabled="availableCurrencies.length < 2"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="currency in availableCurrencies"
|
|
||||||
:key="currency.code"
|
|
||||||
:value="currency"
|
|
||||||
>
|
|
||||||
{{ currency.code }}
|
{{ currency.code }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl label="Time Frame">
|
<FormControl label="Time Frame">
|
||||||
<select v-model="timeFrame">
|
<select v-model="timeFrame">
|
||||||
<option
|
<option :value="{}" selected>
|
||||||
:value="{}"
|
|
||||||
selected
|
|
||||||
>
|
|
||||||
All Time
|
All Time
|
||||||
</option>
|
</option>
|
||||||
<option :value="{ start: sub(new Date(), { days: 30 }) }">Last 30 days</option>
|
<option :value="{ start: sub(new Date(), { days: 30 }) }">Last 30 days</option>
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,20 @@ import {
|
||||||
type ChartOptions,
|
type ChartOptions,
|
||||||
registerables,
|
registerables,
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import type { BalanceTimeSeries, TimeFrame } from './util'
|
import type { TimeFrame } from './util'
|
||||||
import { integerMoneyToFloat, type Currency } from '@/api/data'
|
import { integerMoneyToFloat, type Currency } from '@/api/data'
|
||||||
import { Line } from 'vue-chartjs'
|
import { Line } from 'vue-chartjs'
|
||||||
|
import type { AnalyticsApiClient } from '@/api/analytics'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string
|
title: string
|
||||||
timeFrame: TimeFrame
|
timeFrame: TimeFrame
|
||||||
currency: Currency
|
currency: Currency
|
||||||
data: BalanceTimeSeries[]
|
api: AnalyticsApiClient
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const chartData = computed(() => buildChartData())
|
const chartData = ref<ChartData<'line'>>()
|
||||||
const chartOptions = ref<ChartOptions<'line'> | undefined>()
|
const chartOptions = ref<ChartOptions<'line'> | undefined>()
|
||||||
|
|
||||||
Chart.register(...registerables)
|
Chart.register(...registerables)
|
||||||
|
|
@ -55,11 +56,25 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function buildChartData(): ChartData<'line'> {
|
watch(
|
||||||
|
[() => props.currency, () => props.timeFrame],
|
||||||
|
() => {
|
||||||
|
buildChartData()
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function buildChartData() {
|
||||||
|
const balanceAnalytics = await props.api.getBalanceTimeSeries(
|
||||||
|
props.currency.code,
|
||||||
|
props.timeFrame.start ? props.timeFrame.start.getTime() : null,
|
||||||
|
props.timeFrame.end ? props.timeFrame.end.getTime() : null
|
||||||
|
)
|
||||||
|
|
||||||
const datasets: ChartDataset<'line'>[] = []
|
const datasets: ChartDataset<'line'>[] = []
|
||||||
let colorIdx = 0
|
let colorIdx = 0
|
||||||
|
|
||||||
for (const series of props.data) {
|
for (const series of balanceAnalytics.accounts) {
|
||||||
const color = COLORS[colorIdx++]
|
const color = COLORS[colorIdx++]
|
||||||
if (colorIdx >= COLORS.length) colorIdx = 0
|
if (colorIdx >= COLORS.length) colorIdx = 0
|
||||||
|
|
||||||
|
|
@ -70,7 +85,7 @@ function buildChartData(): ChartData<'line'> {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
datasets.push({
|
datasets.push({
|
||||||
label: series.label,
|
label: 'Account ' + series.accountId,
|
||||||
data: points,
|
data: points,
|
||||||
cubicInterpolationMode: 'monotone',
|
cubicInterpolationMode: 'monotone',
|
||||||
borderColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
|
borderColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
|
||||||
|
|
@ -81,13 +96,11 @@ function buildChartData(): ChartData<'line'> {
|
||||||
pointHitRadius: 5,
|
pointHitRadius: 5,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { datasets: datasets }
|
chartData.value = { datasets: datasets }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Line
|
<div>
|
||||||
v-if="chartData && chartOptions"
|
<Line v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" />
|
||||||
:data="chartData"
|
</div>
|
||||||
:options="chartOptions"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { integerMoneyToFloat, type Currency } from '@/api/data'
|
import { integerMoneyToFloat, type Currency } from '@/api/data'
|
||||||
import type { TimeFrame } from './util'
|
import type { TimeFrame } from './util'
|
||||||
import type { CategorySpendTimeSeriesAnalytics } from '@/api/analytics'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { type ChartOptions, type ChartData, Chart, registerables } from 'chart.js'
|
import { type ChartOptions, type ChartData, Chart, registerables } from 'chart.js'
|
||||||
import { Pie } from 'vue-chartjs'
|
import { Pie } from 'vue-chartjs'
|
||||||
import { isAfter, isBefore } from 'date-fns'
|
import type { AnalyticsApiClient } from '@/api/analytics'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
timeFrame: TimeFrame
|
timeFrame: TimeFrame
|
||||||
currency: Currency
|
currency: Currency
|
||||||
data: CategorySpendTimeSeriesAnalytics
|
api: AnalyticsApiClient
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const chartData = computed(() => buildChartData())
|
const chartData = ref<ChartData<'pie'>>()
|
||||||
const chartOptions = ref<ChartOptions<'pie'>>()
|
const chartOptions = ref<ChartOptions<'pie'>>()
|
||||||
|
|
||||||
Chart.register(...registerables)
|
Chart.register(...registerables)
|
||||||
|
|
@ -29,33 +28,36 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function buildChartData(): ChartData<'pie'> {
|
watch(
|
||||||
|
[() => props.currency, () => props.timeFrame],
|
||||||
|
() => {
|
||||||
|
buildChartData()
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function buildChartData() {
|
||||||
|
const categorySpendData = await props.api.getCategorySpend(
|
||||||
|
props.currency.code,
|
||||||
|
props.timeFrame.start ? props.timeFrame.start.getTime() : null,
|
||||||
|
props.timeFrame.end ? props.timeFrame.end.getTime() : null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
if (categorySpendData.length === 0) {
|
||||||
|
chartData.value = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const labels: string[] = []
|
const labels: string[] = []
|
||||||
const data: number[] = []
|
const data: number[] = []
|
||||||
const colors: string[] = []
|
const colors: string[] = []
|
||||||
|
|
||||||
for (const categoryData of props.data.categories) {
|
for (const categoryData of categorySpendData) {
|
||||||
labels.push(categoryData.categoryName)
|
labels.push(categoryData.categoryName ?? 'Uncategorized')
|
||||||
colors.push('#' + categoryData.categoryColor)
|
colors.push(categoryData.categoryColor ? '#' + categoryData.categoryColor : 'white')
|
||||||
if (!(props.currency.code in categoryData.dataByCurrency)) {
|
data.push(integerMoneyToFloat(categoryData.amount, props.currency))
|
||||||
data.push(0)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
chartData.value = {
|
||||||
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,
|
labels: labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
|
|
@ -67,10 +69,11 @@ function buildChartData(): ChartData<'pie'> {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Pie
|
|
||||||
id="pie"
|
<div>
|
||||||
v-if="chartData && chartOptions"
|
<Pie id="pie" v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" />
|
||||||
:data="chartData"
|
<p v-if="!chartData">
|
||||||
:options="chartOptions"
|
No category spending data is available for this selection of filters.
|
||||||
/>
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue