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}} 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")}});

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); 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)]);

View File

@ -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;
} }
/** /**

View File

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

View File

@ -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();
} }

View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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>