Added balance time series, fixed file selector behavior.
This commit is contained in:
parent
dedc7b742f
commit
85c5cee598
|
|
@ -0,0 +1,20 @@
|
||||||
|
meta {
|
||||||
|
name: Get Account Balance Time Series
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/profiles/:profile/accounts/:accountId/balance-time-series
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
profile: {{current_profile}}
|
||||||
|
accountId: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,10 @@ auth:bearer {
|
||||||
token: {{access_token}}
|
token: {{access_token}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vars:pre-request {
|
||||||
|
current_profile: test-profile-0
|
||||||
|
}
|
||||||
|
|
||||||
script:pre-request {
|
script:pre-request {
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const baseUrl = bru.getEnvVar("base_url");
|
const baseUrl = bru.getEnvVar("base_url");
|
||||||
|
|
@ -16,7 +20,6 @@ script:pre-request {
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
net = require("net")
|
|
||||||
console.log("Testing GET /me");
|
console.log("Testing GET /me");
|
||||||
try {
|
try {
|
||||||
const resp = await axios.get(baseUrl + "/me", {headers: {"Authorization": "Bearer " + bru.getEnvVar("access_token")}});
|
const resp = await axios.get(baseUrl + "/me", {headers: {"Authorization": "Bearer " + bru.getEnvVar("access_token")}});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
This is a sample text attachment.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -0,0 +1,9 @@
|
||||||
|
Food,#8ceb34,Anything food-related.,
|
||||||
|
Groceries,#26c92c,Groceries for home-cooked meals.,Food
|
||||||
|
Restaurants,#bfd166,Restaurants and fast food meals.,
|
||||||
|
Travel,#453ddb,"Cars, transit, and airfare.",
|
||||||
|
Health,#db3d6f,Personal healthcare.,
|
||||||
|
Home,#f59e11,Home maintenance and upkeep.,
|
||||||
|
Income,#20ab16,Any source of income.,
|
||||||
|
Salary,#16ab40,Regular income from a salary.,Income
|
||||||
|
Interest,#8b16ab,Income from account interest.,Income
|
||||||
|
|
|
@ -138,6 +138,14 @@ void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpRespons
|
||||||
writeJsonBody(response, balances);
|
writeJsonBody(response, balances);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleGetAccountBalanceTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
auto ds = getProfileDataSource(request);
|
||||||
|
ulong accountId = request.getPathParamOrThrow!ulong("accountId");
|
||||||
|
int timeZoneOffset = request.getParamAs!int("time-zone-offset", 0);
|
||||||
|
auto series = getBalanceTimeSeries(ds, accountId, timeZoneOffset);
|
||||||
|
writeJsonBody(response, series);
|
||||||
|
}
|
||||||
|
|
||||||
// Value records:
|
// Value records:
|
||||||
|
|
||||||
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
|
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,33 @@ CurrencyBalance[] getTotalBalanceForAllAccounts(ProfileDataSource ds, SysTime ti
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return balances;
|
return balances;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BalanceTimeSeriesPoint {
|
||||||
|
long balance;
|
||||||
|
string timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
BalanceTimeSeriesPoint[] getBalanceTimeSeries(ProfileDataSource ds, ulong accountId, int timeZoneOffsetMinutes) {
|
||||||
|
BalanceTimeSeriesPoint[] points;
|
||||||
|
immutable TimeZone tz = new immutable SimpleTimeZone(minutes(timeZoneOffsetMinutes));
|
||||||
|
SysTime now = Clock.currTime(tz);
|
||||||
|
SysTime endOfToday = SysTime(
|
||||||
|
DateTime(now.year, now.month, now.day, 23, 59, 59),
|
||||||
|
tz
|
||||||
|
);
|
||||||
|
SysTime timestamp = endOfToday.toOtherTZ(UTC());
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
auto balance = getBalance(ds, accountId, timestamp);
|
||||||
|
if (!balance.isNull) {
|
||||||
|
points ~= BalanceTimeSeriesPoint(
|
||||||
|
balance.value,
|
||||||
|
timestamp.toISOExtString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timestamp = timestamp - days(1);
|
||||||
|
}
|
||||||
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
||||||
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord);
|
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord);
|
||||||
a.map(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records", &handleCreateValueRecord);
|
a.map(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records", &handleCreateValueRecord);
|
||||||
a.map(HttpMethod.DELETE, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleDeleteValueRecord);
|
a.map(HttpMethod.DELETE, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleDeleteValueRecord);
|
||||||
|
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/balance-time-series", &handleGetAccountBalanceTimeSeries);
|
||||||
|
|
||||||
import transaction.api;
|
import transaction.api;
|
||||||
// Transaction vendor endpoints:
|
// Transaction vendor endpoints:
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,10 @@ class FileSystemProfileRepository : ProfileRepository {
|
||||||
if (!exists(getProfilesDir())) mkdir(getProfilesDir());
|
if (!exists(getProfilesDir())) mkdir(getProfilesDir());
|
||||||
ProfileDataSource ds = new SqliteProfileDataSource(path);
|
ProfileDataSource ds = new SqliteProfileDataSource(path);
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import std.conv;
|
|
||||||
auto propsRepo = ds.getPropertiesRepository();
|
auto propsRepo = ds.getPropertiesRepository();
|
||||||
propsRepo.setProperty("name", name);
|
propsRepo.setProperty("name", name);
|
||||||
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
|
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
|
||||||
propsRepo.setProperty("user", username);
|
propsRepo.setProperty("user", username);
|
||||||
propsRepo.setProperty("database-schema-version", SCHEMA_VERSION.to!string());
|
|
||||||
return new Profile(name);
|
return new Profile(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,6 +135,7 @@ class SqlitePropertiesRepository : PropertiesRepository {
|
||||||
|
|
||||||
private const SCHEMA = import("sql/schema.sql");
|
private const SCHEMA = import("sql/schema.sql");
|
||||||
private const uint SCHEMA_VERSION = 1;
|
private const uint SCHEMA_VERSION = 1;
|
||||||
|
private const SCHEMA_VERSION_PROPERTY = "database-schema-version";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An SQLite implementation of the ProfileDataSource that uses a single
|
* An SQLite implementation of the ProfileDataSource that uses a single
|
||||||
|
|
@ -160,8 +159,12 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
this.db = Database(path);
|
this.db = Database(path);
|
||||||
db.run("PRAGMA foreign_keys = ON");
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
if (needsInit) {
|
if (needsInit) {
|
||||||
infoF!"Initializing database: %s"(dbPath);
|
infoF!"Initializing database: %s with schema version %d."(dbPath, SCHEMA_VERSION);
|
||||||
db.run(SCHEMA);
|
db.run(SCHEMA);
|
||||||
|
// Set the schema version property right away:
|
||||||
|
import std.conv;
|
||||||
|
auto propRepo = new SqlitePropertiesRepository(db);
|
||||||
|
propRepo.setProperty(SCHEMA_VERSION_PROPERTY, SCHEMA_VERSION.to!string);
|
||||||
}
|
}
|
||||||
migrateSchema();
|
migrateSchema();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ interface TransactionVendorRepository {
|
||||||
|
|
||||||
interface TransactionCategoryRepository {
|
interface TransactionCategoryRepository {
|
||||||
Optional!TransactionCategory findById(ulong id);
|
Optional!TransactionCategory findById(ulong id);
|
||||||
|
Optional!TransactionCategory findByName(string name);
|
||||||
bool existsById(ulong id);
|
bool existsById(ulong id);
|
||||||
bool existsByName(string name);
|
bool existsByName(string name);
|
||||||
TransactionCategory[] findAll();
|
TransactionCategory[] findAll();
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,10 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
||||||
return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
|
return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Optional!TransactionCategory findByName(string name) {
|
||||||
|
return util.sqlite.findOne(db, "SELECT * FROM transaction_category WHERE name = ?", &parseCategory, name);
|
||||||
|
}
|
||||||
|
|
||||||
bool existsById(ulong id) {
|
bool existsById(ulong id) {
|
||||||
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
|
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@ struct TransactionVendor {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransactionCategory {
|
struct TransactionCategory {
|
||||||
immutable ulong id;
|
ulong id;
|
||||||
immutable Optional!ulong parentId;
|
Optional!ulong parentId;
|
||||||
immutable string name;
|
string name;
|
||||||
immutable string description;
|
string description;
|
||||||
immutable string color;
|
string color;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Transaction {
|
struct Transaction {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
module util.sample_data;
|
module util.sample_data;
|
||||||
|
|
||||||
import slf4d;
|
import slf4d;
|
||||||
import handy_http_primitives : Optional;
|
import handy_http_primitives : Optional, mapIfPresent;
|
||||||
|
|
||||||
import auth;
|
import auth;
|
||||||
import profile;
|
import profile;
|
||||||
|
|
@ -46,7 +46,7 @@ void generateRandomUser(int idx, UserRepository userRepo) {
|
||||||
infoF!"Generating random user %s, password: %s."(username, password);
|
infoF!"Generating random user %s, password: %s."(username, password);
|
||||||
User user = createNewUser(userRepo, username, password);
|
User user = createNewUser(userRepo, username, password);
|
||||||
ProfileRepository profileRepo = new FileSystemProfileRepository(username);
|
ProfileRepository profileRepo = new FileSystemProfileRepository(username);
|
||||||
const int profileCount = uniform(1, 5);
|
const int profileCount = uniform(1, 3);
|
||||||
for (int i = 0; i < profileCount; i++) {
|
for (int i = 0; i < profileCount; i++) {
|
||||||
generateRandomProfile(i, profileRepo);
|
generateRandomProfile(i, profileRepo);
|
||||||
}
|
}
|
||||||
|
|
@ -59,16 +59,16 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
|
||||||
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
||||||
|
|
||||||
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
|
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
|
||||||
Currency preferredCurrency = choice(ALL_CURRENCIES);
|
Currency preferredCurrency = Currencies.USD;
|
||||||
|
|
||||||
const int accountCount = uniform(3, 10);
|
const int accountCount = uniform(3, 8);
|
||||||
for (int i = 0; i < accountCount; i++) {
|
for (int i = 0; i < accountCount; i++) {
|
||||||
generateRandomAccount(i, ds, preferredCurrency);
|
generateRandomAccount(i, ds, preferredCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
ds.doTransaction(() {
|
ds.doTransaction(() {
|
||||||
generateVendors(ds);
|
generateVendors(ds);
|
||||||
generateCategories(ds.getTransactionCategoryRepository(), Optional!ulong.empty);
|
generateCategories(ds);
|
||||||
});
|
});
|
||||||
generateRandomTransactions(ds);
|
generateRandomTransactions(ds);
|
||||||
}
|
}
|
||||||
|
|
@ -81,31 +81,27 @@ void generateVendors(ProfileDataSource ds) {
|
||||||
vendorRepo.insert(record[0], record[1]);
|
vendorRepo.insert(record[0], record[1]);
|
||||||
vendorCount++;
|
vendorCount++;
|
||||||
}
|
}
|
||||||
infoF!" Generated %d random vendors."(vendorCount);
|
infoF!" Generated %d vendors."(vendorCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
void generateCategories(TransactionCategoryRepository repo, Optional!ulong parentId, size_t depth = 0) {
|
void generateCategories(ProfileDataSource ds) {
|
||||||
const int categoryCount = uniform(5, 10);
|
auto categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
for (int i = 0; i < categoryCount; i++) {
|
string categoriesCsv = readText("sample-data/categories.csv");
|
||||||
string name = "Test Category " ~ to!string(i);
|
uint categoryCount = 0;
|
||||||
if (parentId) {
|
foreach (record; csvReader!(Tuple!(string, string, string, string))(categoriesCsv)) {
|
||||||
name ~= " (child of " ~ parentId.value.to!string ~ ")";
|
string parentName = record[3];
|
||||||
}
|
Optional!ulong parentId = Optional!ulong.empty;
|
||||||
TransactionCategory category = repo.insert(
|
if (parentName !is null && parentName.length > 0) {
|
||||||
parentId,
|
parentId = categoryRepo.findByName(parentName).mapIfPresent!(c => c.id);
|
||||||
name,
|
|
||||||
"Testing category.",
|
|
||||||
"FFFFFF"
|
|
||||||
);
|
|
||||||
infoF!" Generating child categories for %d, depth = %d"(i, depth);
|
|
||||||
if (depth < 2) {
|
|
||||||
generateCategories(repo, Optional!ulong.of(category.id), depth + 1);
|
|
||||||
}
|
}
|
||||||
|
categoryRepo.insert(parentId, record[0], record[2], record[1][1..$]);
|
||||||
}
|
}
|
||||||
|
infoF!" Generated %d categories."(categoryCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
||||||
AccountRepository accountRepo = ds.getAccountRepository();
|
AccountRepository accountRepo = ds.getAccountRepository();
|
||||||
|
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
|
||||||
string idxStr = idx.to!string;
|
string idxStr = idx.to!string;
|
||||||
string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr;
|
string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr;
|
||||||
string name = "Test Account " ~ idxStr;
|
string name = "Test Account " ~ idxStr;
|
||||||
|
|
@ -122,6 +118,16 @@ void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurr
|
||||||
currency,
|
currency,
|
||||||
description
|
description
|
||||||
);
|
);
|
||||||
|
long balance = uniform(-1_000_000, 1_000_000);
|
||||||
|
ulong accountAge = uniform(5, 365*10);
|
||||||
|
SysTime accountOpenedAt = Clock.currTime(UTC()) - days(accountAge);
|
||||||
|
valueRecordRepo.insert(
|
||||||
|
accountOpenedAt,
|
||||||
|
account.id,
|
||||||
|
AccountValueRecordType.BALANCE,
|
||||||
|
balance,
|
||||||
|
account.currency
|
||||||
|
);
|
||||||
infoF!" Generated random account: %s, #%s, %s"(name, numberSuffix, currency.code);
|
infoF!" Generated random account: %s, #%s, %s"(name, numberSuffix, currency.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,7 +208,33 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto txn = addTransaction(ds, data, []);
|
// Add attachments:
|
||||||
|
MultipartFile[] attachments;
|
||||||
|
if (uniform01 < 0.5) {
|
||||||
|
for (int k = 0; k < uniform(1, 3); k++) {
|
||||||
|
if (k == 0) {
|
||||||
|
attachments ~= MultipartFile(
|
||||||
|
"1.txt",
|
||||||
|
"text/plain",
|
||||||
|
cast(ubyte[]) std.file.read("sample-data/attachments/1.txt")
|
||||||
|
);
|
||||||
|
} else if (k == 1) {
|
||||||
|
attachments ~= MultipartFile(
|
||||||
|
"2.jpg",
|
||||||
|
"image/jpg",
|
||||||
|
cast(ubyte[]) std.file.read("sample-data/attachments/2.jpg")
|
||||||
|
);
|
||||||
|
} else if (k == 2) {
|
||||||
|
attachments ~= MultipartFile(
|
||||||
|
"3.png",
|
||||||
|
"image/png",
|
||||||
|
cast(ubyte[]) std.file.read("sample-data/attachments/3.png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto txn = addTransaction(ds, data, attachments);
|
||||||
infoF!" Generated transaction %d"(txn.id);
|
infoF!" Generated transaction %d"(txn.id);
|
||||||
timestamp -= seconds(uniform(10, 1_000_000));
|
timestamp -= seconds(uniform(10, 1_000_000));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@
|
||||||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
"@fortawesome/vue-fontawesome": "^3.1.2",
|
||||||
"@idle-observer/vue3": "^0.2.0",
|
"@idle-observer/vue3": "^0.2.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-chartjs": "^5.3.2",
|
"vue-chartjs": "^5.3.2",
|
||||||
|
|
@ -2618,6 +2621,16 @@
|
||||||
"pnpm": ">=8"
|
"pnpm": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chartjs-adapter-date-fns": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": ">=2.8.0",
|
||||||
|
"date-fns": ">=2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -2701,6 +2714,25 @@
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/date-fns-tz": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"date-fns": "^3.0.0 || ^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@
|
||||||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
"@fortawesome/vue-fontawesome": "^3.1.2",
|
||||||
"@idle-observer/vue3": "^0.2.0",
|
"@idle-observer/vue3": "^0.2.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-chartjs": "^5.3.2",
|
"vue-chartjs": "^5.3.2",
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,11 @@ export interface CurrencyBalance {
|
||||||
balance: number
|
balance: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BalanceTimeSeriesPoint {
|
||||||
|
balance: number
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
export class AccountApiClient extends ApiClient {
|
export class AccountApiClient extends ApiClient {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
readonly profileName: string
|
readonly profileName: string
|
||||||
|
|
@ -189,6 +194,15 @@ export class AccountApiClient extends ApiClient {
|
||||||
return super.getJson(`/profiles/${this.profileName}/account-balances`)
|
return super.getJson(`/profiles/${this.profileName}/account-balances`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBalanceTimeSeries(
|
||||||
|
accountId: number,
|
||||||
|
timeZoneOffsetMinutes: number,
|
||||||
|
): Promise<BalanceTimeSeriesPoint[]> {
|
||||||
|
return super.getJson(
|
||||||
|
`/profiles/${this.profileName}/accounts/${accountId}/balance-time-series?time-zone-offset=${timeZoneOffsetMinutes}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
getValueRecords(accountId: number, pageRequest: PageRequest): Promise<Page<AccountValueRecord>> {
|
getValueRecords(accountId: number, pageRequest: PageRequest): Promise<Page<AccountValueRecord>> {
|
||||||
return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest)
|
return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,7 @@ export function formatMoney(amount: number, currency: Currency) {
|
||||||
export function floatMoneyToInteger(amount: number, currency: Currency) {
|
export function floatMoneyToInteger(amount: number, currency: Currency) {
|
||||||
return Math.round(amount * Math.pow(10, currency.fractionalDigits ?? 0))
|
return Math.round(amount * Math.pow(10, currency.fractionalDigits ?? 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function integerMoneyToFloat(amount: number, currency: Currency) {
|
||||||
|
return amount / Math.pow(10, currency.fractionalDigits ?? 0)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'
|
import { onMounted, ref, shallowRef, useTemplateRef, watch, type Ref } from 'vue'
|
||||||
import AppButton from '@/components/common/AppButton.vue'
|
import AppButton from '@/components/common/AppButton.vue'
|
||||||
import AttachmentRow from './AttachmentRow.vue'
|
import AttachmentRow from './AttachmentRow.vue'
|
||||||
|
|
||||||
|
|
@ -43,32 +43,33 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
initialFiles: () => [],
|
initialFiles: () => [],
|
||||||
})
|
})
|
||||||
const previousInitialFiles: Ref<ExistingFile[]> = ref([])
|
const previousInitialFiles: Ref<string> = ref('{}')
|
||||||
const fileInput = useTemplateRef('fileInput')
|
const fileInput = useTemplateRef('fileInput')
|
||||||
|
|
||||||
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
|
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
|
||||||
const removedFiles = defineModel<number[]>('removed-files', { default: [] })
|
const removedFiles = defineModel<number[]>('removed-files', { default: [] })
|
||||||
|
|
||||||
// Internal file list model:
|
// Internal file list model:
|
||||||
const files: Ref<FileListItem[]> = ref([])
|
const files = shallowRef<FileListItem[]>([])
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
|
files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
|
||||||
previousInitialFiles.value = [...props.initialFiles]
|
previousInitialFiles.value = JSON.stringify(props.initialFiles)
|
||||||
// If input initial files change, reset the file selector to just those.
|
// If input initial files change, reset the file selector to just those.
|
||||||
watch(
|
watch(
|
||||||
() => props.initialFiles,
|
() => props.initialFiles,
|
||||||
() => {
|
() => {
|
||||||
if (previousInitialFiles.value !== props.initialFiles) {
|
const prevJson = previousInitialFiles.value
|
||||||
|
const newJson = JSON.stringify(props.initialFiles)
|
||||||
|
if (prevJson !== newJson) {
|
||||||
files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
|
files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
|
||||||
previousInitialFiles.value = [...props.initialFiles]
|
previousInitialFiles.value = newJson
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// When our internal model changes, update the defined uploaded/removed files models.
|
})
|
||||||
watch(
|
|
||||||
() => files,
|
function syncModelsWithInternalFileList() {
|
||||||
() => {
|
|
||||||
// Compute the set of uploaded files as just any newly uploaded file list item.
|
// Compute the set of uploaded files as just any newly uploaded file list item.
|
||||||
uploadedFiles.value = files.value
|
uploadedFiles.value = files.value
|
||||||
.filter((f) => f instanceof NewFileListItem)
|
.filter((f) => f instanceof NewFileListItem)
|
||||||
|
|
@ -80,16 +81,15 @@ onMounted(() => {
|
||||||
removedFiles.value = props.initialFiles
|
removedFiles.value = props.initialFiles
|
||||||
.filter((f) => !retainedExistingFileIds.includes(f.id))
|
.filter((f) => !retainedExistingFileIds.includes(f.id))
|
||||||
.map((f) => f.id)
|
.map((f) => f.id)
|
||||||
},
|
}
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function onFileInputChanged(e: Event) {
|
function onFileInputChanged(e: Event) {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
const inputElement = e.target as HTMLInputElement
|
const inputElement = e.target as HTMLInputElement
|
||||||
const fileList = inputElement.files
|
const fileList = inputElement.files
|
||||||
if (fileList === null) {
|
if (fileList === null) {
|
||||||
|
files.value = []
|
||||||
|
syncModelsWithInternalFileList()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (let i = 0; i < fileList?.length; i++) {
|
for (let i = 0; i < fileList?.length; i++) {
|
||||||
|
|
@ -100,47 +100,30 @@ function onFileInputChanged(e: Event) {
|
||||||
}
|
}
|
||||||
// Reset the input element after we've consumed the selected files.
|
// Reset the input element after we've consumed the selected files.
|
||||||
inputElement.value = ''
|
inputElement.value = ''
|
||||||
|
syncModelsWithInternalFileList()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFileDeleteClicked(idx: number) {
|
function onFileDeleteClicked(idx: number) {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
if (idx === 0 && files.value.length === 1) {
|
if (idx === 0 && files.value.length === 1) {
|
||||||
files.value = []
|
files.value = []
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
files.value.splice(idx, 1)
|
files.value.splice(idx, 1)
|
||||||
}
|
}
|
||||||
|
syncModelsWithInternalFileList()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="file-selector">
|
<div class="file-selector">
|
||||||
<div @click.prevent="">
|
<div @click.prevent="">
|
||||||
<AttachmentRow
|
<AttachmentRow v-for="(file, idx) in files" :key="idx" :attachment="file" @deleted="onFileDeleteClicked(idx)" />
|
||||||
v-for="(file, idx) in files"
|
|
||||||
:key="idx"
|
|
||||||
:attachment="file"
|
|
||||||
@deleted="onFileDeleteClicked(idx)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input id="fileInput" type="file" capture="environment" accept="image/*,text/*,.pdf,.doc,.odt,.docx,.xlsx"
|
||||||
id="fileInput"
|
multiple @change="onFileInputChanged" style="display: none" ref="fileInput" :disabled="disabled" />
|
||||||
type="file"
|
|
||||||
capture="environment"
|
|
||||||
accept="image/*,text/*,.pdf,.doc,.odt,.docx,.xlsx"
|
|
||||||
multiple
|
|
||||||
@change="onFileInputChanged"
|
|
||||||
style="display: none"
|
|
||||||
ref="fileInput"
|
|
||||||
:disabled="disabled"
|
|
||||||
/>
|
|
||||||
<label for="fileInput">
|
<label for="fileInput">
|
||||||
<AppButton
|
<AppButton icon="upload" type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
|
||||||
icon="upload"
|
|
||||||
type="button"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
:disabled="disabled"
|
|
||||||
>Select a File
|
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@
|
||||||
import ProfileModule from './home/ProfileModule.vue'
|
import ProfileModule from './home/ProfileModule.vue'
|
||||||
import AccountsModule from './home/AccountsModule.vue'
|
import AccountsModule from './home/AccountsModule.vue'
|
||||||
import TransactionsModule from './home/TransactionsModule.vue'
|
import TransactionsModule from './home/TransactionsModule.vue'
|
||||||
|
import AnalyticsModule from './home/AnalyticsModule.vue';
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="app-module-container">
|
<div class="app-module-container">
|
||||||
<ProfileModule />
|
<ProfileModule />
|
||||||
<AccountsModule />
|
<AccountsModule />
|
||||||
<TransactionsModule />
|
<TransactionsModule />
|
||||||
|
<AnalyticsModule />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AccountApiClient } from '@/api/account';
|
||||||
|
import HomeModule from '@/components/HomeModule.vue';
|
||||||
|
import { CategoryScale, Chart, Filler, Legend, LinearScale, LineElement, PointElement, TimeScale, Title, Tooltip, type ChartData, type ChartDataset, type ChartOptions } from 'chart.js';
|
||||||
|
import { getTime } from 'date-fns';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { Line } from 'vue-chartjs';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import 'chartjs-adapter-date-fns';
|
||||||
|
import { integerMoneyToFloat } from '@/api/data';
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const chartData = ref<ChartData<"line"> | undefined>()
|
||||||
|
const chartOptions = ref<ChartOptions<"line"> | undefined>()
|
||||||
|
|
||||||
|
Chart.register(Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement, TimeScale, Filler)
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
[255, 69, 69],
|
||||||
|
[255, 160, 69],
|
||||||
|
[255, 223, 69],
|
||||||
|
[118, 255, 69],
|
||||||
|
[69, 255, 188],
|
||||||
|
[69, 237, 255],
|
||||||
|
[69, 125, 255],
|
||||||
|
[139, 69, 255],
|
||||||
|
[251, 69, 255],
|
||||||
|
[255, 69, 167]
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const api = new AccountApiClient(route)
|
||||||
|
const accounts = await api.getAccounts()
|
||||||
|
const datasets: ChartDataset<"line">[] = []
|
||||||
|
const timeZoneOffset = -(new Date().getTimezoneOffset())
|
||||||
|
let colorIdx = 0
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (account.currency.code !== 'USD') continue
|
||||||
|
const points = await api.getBalanceTimeSeries(account.id, timeZoneOffset)
|
||||||
|
const color = COLORS[colorIdx++]
|
||||||
|
datasets.push({
|
||||||
|
label: "Account #" + account.numberSuffix,
|
||||||
|
data: points.map(p => {
|
||||||
|
return { x: getTime(p.timestamp), y: integerMoneyToFloat(p.balance, account.currency) }
|
||||||
|
}),
|
||||||
|
cubicInterpolationMode: "monotone",
|
||||||
|
borderColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
|
||||||
|
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.25)`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chartData.value = {
|
||||||
|
datasets: datasets
|
||||||
|
}
|
||||||
|
chartOptions.value = {
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Account Balances"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: "time",
|
||||||
|
time: {
|
||||||
|
unit: "day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<HomeModule title="Analytics" style="min-width: 500px;">
|
||||||
|
<Line v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" />
|
||||||
|
</HomeModule>
|
||||||
|
</template>
|
||||||
Loading…
Reference in New Issue