Added balance time series, fixed file selector behavior.
Build and Deploy Web App / build-and-deploy (push) Successful in 31s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m49s Details

This commit is contained in:
andrewlalis 2025-11-04 17:20:49 -05:00
parent dedc7b742f
commit 85c5cee598
21 changed files with 306 additions and 82 deletions

View File

@ -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
}

View File

@ -6,6 +6,10 @@ auth:bearer {
token: {{access_token}}
}
vars:pre-request {
current_profile: test-profile-0
}
script:pre-request {
const axios = require("axios");
const baseUrl = bru.getEnvVar("base_url");
@ -16,7 +20,6 @@ script:pre-request {
await checkAuth();
}
net = require("net")
console.log("Testing GET /me");
try {
const resp = await axios.get(baseUrl + "/me", {headers: {"Authorization": "Bearer " + bru.getEnvVar("access_token")}});

View File

@ -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

View File

@ -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
1 Food #8ceb34 Anything food-related.
2 Groceries #26c92c Groceries for home-cooked meals. Food
3 Restaurants #bfd166 Restaurants and fast food meals.
4 Travel #453ddb Cars, transit, and airfare.
5 Health #db3d6f Personal healthcare.
6 Home #f59e11 Home maintenance and upkeep.
7 Income #20ab16 Any source of income.
8 Salary #16ab40 Regular income from a salary. Income
9 Interest #8b16ab Income from account interest. Income

View File

@ -138,6 +138,14 @@ void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpRespons
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:
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);

View File

@ -77,7 +77,33 @@ CurrencyBalance[] getTotalBalanceForAllAccounts(ProfileDataSource ds, SysTime ti
}
}
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;
}
/**

View File

@ -66,6 +66,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord);
a.map(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records", &handleCreateValueRecord);
a.map(HttpMethod.DELETE, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleDeleteValueRecord);
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/balance-time-series", &handleGetAccountBalanceTimeSeries);
import transaction.api;
// Transaction vendor endpoints:

View File

@ -39,12 +39,10 @@ class FileSystemProfileRepository : ProfileRepository {
if (!exists(getProfilesDir())) mkdir(getProfilesDir());
ProfileDataSource ds = new SqliteProfileDataSource(path);
import std.datetime;
import std.conv;
auto propsRepo = ds.getPropertiesRepository();
propsRepo.setProperty("name", name);
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
propsRepo.setProperty("user", username);
propsRepo.setProperty("database-schema-version", SCHEMA_VERSION.to!string());
return new Profile(name);
}
@ -137,6 +135,7 @@ class SqlitePropertiesRepository : PropertiesRepository {
private const SCHEMA = import("sql/schema.sql");
private const uint SCHEMA_VERSION = 1;
private const SCHEMA_VERSION_PROPERTY = "database-schema-version";
/**
* An SQLite implementation of the ProfileDataSource that uses a single
@ -160,8 +159,12 @@ class SqliteProfileDataSource : ProfileDataSource {
this.db = Database(path);
db.run("PRAGMA foreign_keys = ON");
if (needsInit) {
infoF!"Initializing database: %s"(dbPath);
infoF!"Initializing database: %s with schema version %d."(dbPath, SCHEMA_VERSION);
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();
}

View File

@ -20,6 +20,7 @@ interface TransactionVendorRepository {
interface TransactionCategoryRepository {
Optional!TransactionCategory findById(ulong id);
Optional!TransactionCategory findByName(string name);
bool existsById(ulong id);
bool existsByName(string name);
TransactionCategory[] findAll();

View File

@ -81,6 +81,10 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
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) {
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
}

View File

@ -12,11 +12,11 @@ struct TransactionVendor {
}
struct TransactionCategory {
immutable ulong id;
immutable Optional!ulong parentId;
immutable string name;
immutable string description;
immutable string color;
ulong id;
Optional!ulong parentId;
string name;
string description;
string color;
}
struct Transaction {

View File

@ -1,7 +1,7 @@
module util.sample_data;
import slf4d;
import handy_http_primitives : Optional;
import handy_http_primitives : Optional, mapIfPresent;
import auth;
import profile;
@ -46,7 +46,7 @@ void generateRandomUser(int idx, UserRepository userRepo) {
infoF!"Generating random user %s, password: %s."(username, password);
User user = createNewUser(userRepo, username, password);
ProfileRepository profileRepo = new FileSystemProfileRepository(username);
const int profileCount = uniform(1, 5);
const int profileCount = uniform(1, 3);
for (int i = 0; i < profileCount; i++) {
generateRandomProfile(i, profileRepo);
}
@ -59,16 +59,16 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
ProfileDataSource ds = profileRepo.getDataSource(profile);
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++) {
generateRandomAccount(i, ds, preferredCurrency);
}
ds.doTransaction(() {
generateVendors(ds);
generateCategories(ds.getTransactionCategoryRepository(), Optional!ulong.empty);
generateCategories(ds);
});
generateRandomTransactions(ds);
}
@ -81,31 +81,27 @@ void generateVendors(ProfileDataSource ds) {
vendorRepo.insert(record[0], record[1]);
vendorCount++;
}
infoF!" Generated %d random vendors."(vendorCount);
infoF!" Generated %d vendors."(vendorCount);
}
void generateCategories(TransactionCategoryRepository repo, Optional!ulong parentId, size_t depth = 0) {
const int categoryCount = uniform(5, 10);
for (int i = 0; i < categoryCount; i++) {
string name = "Test Category " ~ to!string(i);
if (parentId) {
name ~= " (child of " ~ parentId.value.to!string ~ ")";
}
TransactionCategory category = repo.insert(
parentId,
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);
void generateCategories(ProfileDataSource ds) {
auto categoryRepo = ds.getTransactionCategoryRepository();
string categoriesCsv = readText("sample-data/categories.csv");
uint categoryCount = 0;
foreach (record; csvReader!(Tuple!(string, string, string, string))(categoriesCsv)) {
string parentName = record[3];
Optional!ulong parentId = Optional!ulong.empty;
if (parentName !is null && parentName.length > 0) {
parentId = categoryRepo.findByName(parentName).mapIfPresent!(c => c.id);
}
categoryRepo.insert(parentId, record[0], record[2], record[1][1..$]);
}
infoF!" Generated %d categories."(categoryCount);
}
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
AccountRepository accountRepo = ds.getAccountRepository();
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
string idxStr = idx.to!string;
string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr;
string name = "Test Account " ~ idxStr;
@ -122,6 +118,16 @@ void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurr
currency,
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);
}
@ -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);
timestamp -= seconds(uniform(10, 1_000_000));
}

View File

@ -14,6 +14,9 @@
"@fortawesome/vue-fontawesome": "^3.1.2",
"@idle-observer/vue3": "^0.2.0",
"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",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
@ -2618,6 +2621,16 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2701,6 +2714,25 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"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": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@ -22,6 +22,9 @@
"@fortawesome/vue-fontawesome": "^3.1.2",
"@idle-observer/vue3": "^0.2.0",
"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",
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",

View File

@ -143,6 +143,11 @@ export interface CurrencyBalance {
balance: number
}
export interface BalanceTimeSeriesPoint {
balance: number
timestamp: string
}
export class AccountApiClient extends ApiClient {
readonly path: string
readonly profileName: string
@ -189,6 +194,15 @@ export class AccountApiClient extends ApiClient {
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>> {
return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest)
}

View File

@ -31,3 +31,7 @@ export function formatMoney(amount: number, currency: Currency) {
export function floatMoneyToInteger(amount: number, currency: Currency) {
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)
}

View File

@ -1,5 +1,5 @@
<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 AttachmentRow from './AttachmentRow.vue'
@ -15,7 +15,7 @@ abstract class FileListItem {
public readonly filename: string,
public readonly contentType: string,
public readonly size: number,
) {}
) { }
}
class ExistingFileListItem extends FileListItem {
@ -43,53 +43,53 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false,
initialFiles: () => [],
})
const previousInitialFiles: Ref<ExistingFile[]> = ref([])
const previousInitialFiles: Ref<string> = ref('{}')
const fileInput = useTemplateRef('fileInput')
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
const removedFiles = defineModel<number[]>('removed-files', { default: [] })
// Internal file list model:
const files: Ref<FileListItem[]> = ref([])
const files = shallowRef<FileListItem[]>([])
onMounted(() => {
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.
watch(
() => 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))
previousInitialFiles.value = [...props.initialFiles]
previousInitialFiles.value = newJson
}
},
)
// When our internal model changes, update the defined uploaded/removed files models.
watch(
() => files,
() => {
// Compute the set of uploaded files as just any newly uploaded file list item.
uploadedFiles.value = files.value
.filter((f) => f instanceof NewFileListItem)
.map((f) => f.file)
// Compute the set of removed files as those from the set of initial files whose ID is no longer present.
const retainedExistingFileIds = files.value
.filter((f) => f instanceof ExistingFileListItem)
.map((f) => f.id)
removedFiles.value = props.initialFiles
.filter((f) => !retainedExistingFileIds.includes(f.id))
.map((f) => f.id)
},
{ deep: true },
)
})
function syncModelsWithInternalFileList() {
// Compute the set of uploaded files as just any newly uploaded file list item.
uploadedFiles.value = files.value
.filter((f) => f instanceof NewFileListItem)
.map((f) => f.file)
// Compute the set of removed files as those from the set of initial files whose ID is no longer present.
const retainedExistingFileIds = files.value
.filter((f) => f instanceof ExistingFileListItem)
.map((f) => f.id)
removedFiles.value = props.initialFiles
.filter((f) => !retainedExistingFileIds.includes(f.id))
.map((f) => f.id)
}
function onFileInputChanged(e: Event) {
if (props.disabled) return
const inputElement = e.target as HTMLInputElement
const fileList = inputElement.files
if (fileList === null) {
files.value = []
syncModelsWithInternalFileList()
return
}
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.
inputElement.value = ''
syncModelsWithInternalFileList()
}
function onFileDeleteClicked(idx: number) {
if (props.disabled) return
if (idx === 0 && files.value.length === 1) {
files.value = []
return
} else {
files.value.splice(idx, 1)
}
files.value.splice(idx, 1)
syncModelsWithInternalFileList()
}
</script>
<template>
<div class="file-selector">
<div @click.prevent="">
<AttachmentRow
v-for="(file, idx) in files"
:key="idx"
:attachment="file"
@deleted="onFileDeleteClicked(idx)"
/>
<AttachmentRow v-for="(file, idx) in files" :key="idx" :attachment="file" @deleted="onFileDeleteClicked(idx)" />
</div>
<div>
<input
id="fileInput"
type="file"
capture="environment"
accept="image/*,text/*,.pdf,.doc,.odt,.docx,.xlsx"
multiple
@change="onFileInputChanged"
style="display: none"
ref="fileInput"
:disabled="disabled"
/>
<input id="fileInput" 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">
<AppButton
icon="upload"
type="button"
@click="fileInput?.click()"
:disabled="disabled"
>Select a File
<AppButton icon="upload" type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
</AppButton>
</label>
</div>

View File

@ -2,11 +2,13 @@
import ProfileModule from './home/ProfileModule.vue'
import AccountsModule from './home/AccountsModule.vue'
import TransactionsModule from './home/TransactionsModule.vue'
import AnalyticsModule from './home/AnalyticsModule.vue';
</script>
<template>
<div class="app-module-container">
<ProfileModule />
<AccountsModule />
<TransactionsModule />
<AnalyticsModule />
</div>
</template>

View File

@ -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>