From 8c4e72666b11ace3979d8c4df91c86f8151c2724 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 15:14:03 +0000 Subject: [PATCH] feat(dashboard): morning page, compact accounts, what the bot entered, next 7 days The right column drops the box per account for one table per currency with sparklines and the last five transactions on l, a list of what was entered since the last look (x marks reviewed), bills and piggy bank dates in the next seven days, and debts with friends net per currency. The left column opens with spending so far against a typical month, and $ shows totals in UAH. --- .../components/dashboard/AccountBox.svelte | 188 --------- .../components/dashboard/AccountsBlock.svelte | 381 ++++++++++++++++++ src/lib/components/dashboard/DebtsBox.svelte | 183 ++++++--- src/lib/components/dashboard/DueBox.svelte | 187 +++++++++ src/lib/components/dashboard/InboxBox.svelte | 183 +++++++++ src/lib/components/dashboard/PaceBox.svelte | 203 ++++++++++ .../dashboard/SubscriptionsBox.svelte | 5 +- src/lib/dashboard/column.svelte.ts | 108 +++++ src/lib/dashboard/primary.svelte.ts | 137 +++++++ src/lib/dashboard/reviewed.svelte.ts | 70 ++++ src/routes/+page.svelte | 119 +++--- 11 files changed, 1452 insertions(+), 312 deletions(-) delete mode 100644 src/lib/components/dashboard/AccountBox.svelte create mode 100644 src/lib/components/dashboard/AccountsBlock.svelte create mode 100644 src/lib/components/dashboard/DueBox.svelte create mode 100644 src/lib/components/dashboard/InboxBox.svelte create mode 100644 src/lib/components/dashboard/PaceBox.svelte create mode 100644 src/lib/dashboard/column.svelte.ts create mode 100644 src/lib/dashboard/primary.svelte.ts create mode 100644 src/lib/dashboard/reviewed.svelte.ts diff --git a/src/lib/components/dashboard/AccountBox.svelte b/src/lib/components/dashboard/AccountBox.svelte deleted file mode 100644 index 3b89b04..0000000 --- a/src/lib/components/dashboard/AccountBox.svelte +++ /dev/null @@ -1,188 +0,0 @@ - - - - {#snippet tools()} - {#if trend.length > 1} - - {/if} - {#if change !== 0} - - {/if} - {/snippet} -
- {#if list.isPending} -
- {:else if list.isError} -

{list.error.message}

- {:else if rows.length === 0} -

- No transactions in this period. -

- {:else} - - {/if} -
- {#snippet footer()} - - - Create - - - go(createUrl("withdrawal", account.id))} - >New withdrawal - go(createUrl("deposit", account.id))} - >New deposit - go(createUrl("transfer", account.id))} - >New transfer - - - - - {formatMoney({ amount: a.current_balance, ...cur })} - - - - go(showUrl(account.id))} - >Show - {#if canReconcile(account)} - go(reconcileUrl(account.id))} - >Reconcile - {/if} - go(editUrl(account.id))} - >Edit - openOld(showUrl(account.id))} - >Open in old UI - - - {/snippet} -
diff --git a/src/lib/components/dashboard/AccountsBlock.svelte b/src/lib/components/dashboard/AccountsBlock.svelte new file mode 100644 index 0000000..fd5150f --- /dev/null +++ b/src/lib/components/dashboard/AccountsBlock.svelte @@ -0,0 +1,381 @@ + + +{#snippet spark( + values: number[] +)} + {#if values.length > 1} + + {/if} +{/snippet} + +{#snippet balance( + amount: number, + cur: Cur +)} + {formatMoney({ amount, ...cur })} +{/snippet} + + + {#snippet tools()} + {#if converted.balance !== null} + {formatPrimary(converted.balance)} + {#if converted.change} + {formatPrimary(converted.change, true)} + {/if} + {/if} + {/snippet} + {#if shown.length === 0} +

+ {assets.isPending ? "" : "No accounts on the home screen."} +

+ {:else} + + + + + + + + + + {#each groups as group (group.code)} + + + + + + + + {#each group.accounts as account (account.id)} + {@const a = account.attributes} + {@const key = accountKey(account.id)} + {@const open = expanded.includes(account.id)} + column.point(key)} + > + + + + + + {#if open} + {#each recentRows.get(account.id) ?? [] as tx (tx.key)} + column.point(tx.key)} + > + + + + + {:else} + + + + {/each} + {/if} + {/each} + + {/each} +
AccountBalanceThis period
+ {group.code} + + {@render balance(group.balance, group.cur)} + + {#if group.change !== 0} + + {/if} +
+
+ + {a.name} +
+
+ {@render balance(toNumber(a.current_balance), group.cur)} + + {#if toNumber(a.balance_difference) !== 0} + + {/if} +
+ + {formatDate(tx.date, "short")} + {tx.title} + + + +
+ {recent[expanded.indexOf(account.id)]?.isPending + ? "" + : "Nothing in the last year."} +
+ {/if} +
diff --git a/src/lib/components/dashboard/DebtsBox.svelte b/src/lib/components/dashboard/DebtsBox.svelte index e9cce1f..120e674 100644 --- a/src/lib/components/dashboard/DebtsBox.svelte +++ b/src/lib/components/dashboard/DebtsBox.svelte @@ -1,18 +1,25 @@ +{#snippet more()} + See your liabilities +{/snippet} + {#if groups.length > 0} - +
- {#each groups as group (group.code)} -
-
- {group.code}{g.text} - {#if group.owed > 0} - Owed to me - {group.format(group.owed)} - {/if} - {#if group.owe > 0} - I owe - {group.format(group.owe)} - {/if} -
- -
+ {g.net > 0 ? "owed to me" : "I owe"} + {/if} {/each}
- {#snippet footer()} - See your liabilities - {/snippet} + + {#each groups as g (g.code)} + + {#each g.rows as r, i (r.key)} + column.point(r.key)} + > + + + + + {/each} + + {/each} +
+ {r.name} + +
+ +
+
+ {formatMoney({ amount: r.value, ...g.cur }, true)} +
{/if} diff --git a/src/lib/components/dashboard/DueBox.svelte b/src/lib/components/dashboard/DueBox.svelte new file mode 100644 index 0000000..a24c347 --- /dev/null +++ b/src/lib/components/dashboard/DueBox.svelte @@ -0,0 +1,187 @@ + + +{#snippet more()} + Go to your subscriptions +{/snippet} + + + {#if items.length === 0} +

+ {bills.isPending ? "" : `Nothing due in the next ${DAYS} days.`} +

+ {:else} +
+ {#if converted === null} + {#each totals as t (t.code)} + {money(t.amount, t.cur, t.approx)} + {/each} + {:else} + {formatPrimary(converted)} + {/if} +
+ + + {#each items as item (item.key)} + column.point(item.key)} + > + + + + + {/each} + +
+ {dayLabel(item.date)} + + {item.name}{item.piggy ? " · piggy bank" : ""} + + {money(item.amount, item.cur, item.approx)} +
+ {/if} +
diff --git a/src/lib/components/dashboard/InboxBox.svelte b/src/lib/components/dashboard/InboxBox.svelte new file mode 100644 index 0000000..67b5fe1 --- /dev/null +++ b/src/lib/components/dashboard/InboxBox.svelte @@ -0,0 +1,183 @@ + + +{#snippet actions()} + {#if fresh > 0} + + {/if} + {#if entries.length > SHOW} + and {entries.length - SHOW} more + {/if} +{/snippet} + + 0 || entries.length > SHOW ? actions : undefined} + href={searchHref} + title="Entered since you last looked" +> + {#snippet tools()} + {#if fresh > 0} + {fresh} new + {/if} + {/snippet} + {#if entries.length === 0} +

+ {inbox.isPending ? "" : `Nothing new since ${formatDateTime(since)}.`} +

+ {:else} + + + {#each shown as e, i (e.id)} + {@const key = `inbox-${e.id}`} + {@const isNew = reviewed.isNew(e)} + column.point(key)} + > + + + + + + {/each} + +
+ + + {formatDate(e.date, "short")} + + {e.title} + {#if e.note} +

+ {e.note} +

+ {/if} +
+ {#if e.money} + + {/if} +
+ {/if} +
diff --git a/src/lib/components/dashboard/PaceBox.svelte b/src/lib/components/dashboard/PaceBox.svelte new file mode 100644 index 0000000..04ac955 --- /dev/null +++ b/src/lib/components/dashboard/PaceBox.svelte @@ -0,0 +1,203 @@ + + +{#if visible && rows.length > 0} + + {#snippet tools()} + Typical: median of {MONTHS} months + {/snippet} + + + + + + + + + + + + {#each lines as r (r.code)} + {@const diff = r.typical === null ? null : r.spent - r.typical} + {@const scale = Math.max(r.spent, r.typical ?? 0) || 1} + + + + + + + + {/each} + +
CurrencySpentTypicalDifference
+ {r.code} + + {r.format(r.spent)} + + {r.typical === null ? "n/a" : r.format(r.typical)} + + {#if diff !== null} + {r.format(Math.abs(diff))} + {diff > 0 ? "over" : "under"} + {/if} +
+
+{/if} diff --git a/src/lib/components/dashboard/SubscriptionsBox.svelte b/src/lib/components/dashboard/SubscriptionsBox.svelte index c32fb57..18354c9 100644 --- a/src/lib/components/dashboard/SubscriptionsBox.svelte +++ b/src/lib/components/dashboard/SubscriptionsBox.svelte @@ -4,6 +4,7 @@ import type { Bill } from "#lib/api/types.js"; import Box from "#lib/components/Box.svelte"; import Meter from "#lib/components/charts/Meter.svelte"; + import { today } from "#lib/dates.js"; import { formatDate, formatMoney, toNumber } from "#lib/format.js"; import { period } from "#lib/period.svelte.js"; import { parseSummary, span, summaryQuery } from "./data.js"; @@ -50,7 +51,7 @@ (b.attributes.paid_dates ?? []).map((p) => p.date) ).length; const next = expected[paid]; - if (!next) { + if (!next || next >= today()) { return []; } const amount = @@ -111,7 +112,7 @@ {#if due.length > 0}
Still to pay this periodNot paid yet
    {#each due.slice(0, DUE_LIMIT) as d (d.bill.id)} diff --git a/src/lib/dashboard/column.svelte.ts b/src/lib/dashboard/column.svelte.ts new file mode 100644 index 0000000..9a63b21 --- /dev/null +++ b/src/lib/dashboard/column.svelte.ts @@ -0,0 +1,108 @@ +import { getContext, onMount, setContext } from "svelte"; +import { ListNav } from "#lib/list-nav.svelte.js"; + +export interface Row { + edit?: () => void; + expand?: () => void; + fold?: () => void; + key: string; + open: () => void; + review?: () => void; +} + +interface Section { + order: number; + rows: () => Row[]; +} + +const CONTEXT = Symbol("dashboard-column"); +const GROUP = "Dashboard"; + +class Column { + sections = $state.raw([]); + rows = $derived(this.sections.flatMap((section) => section.rows())); + index = $derived(new Map(this.rows.map((row, i) => [row.key, i]))); + + nav = new ListNav({ + edit: (row) => row.edit?.(), + extra: () => [ + { + group: GROUP, + keys: "Enter", + label: "Expand account or open row", + run: () => this.run((row) => (row.expand ?? row.open)()), + }, + { + group: GROUP, + keys: "l", + label: "Show last transactions of the account", + run: () => this.run((row) => (row.expand ?? row.open)()), + }, + { + group: GROUP, + keys: "h", + label: "Fold account", + run: () => this.run((row) => row.fold?.()), + }, + { + group: GROUP, + keys: "o", + label: "Open row", + run: () => this.run((row) => row.open()), + }, + { + group: GROUP, + keys: "x", + label: "Mark entry reviewed", + run: () => this.run((row) => row.review?.()), + }, + ], + id: "dashboard", + items: () => this.rows, + key: (row) => row.key, + }); + + private run(action: (row: Row) => void) { + if (this.nav.current) { + action(this.nav.current); + } + } + + add(section: Section) { + this.sections = [...this.sections, section].sort( + (a, b) => a.order - b.order + ); + return () => { + this.sections = this.sections.filter((s) => s !== section); + }; + } + + id(key: string) { + return this.nav.rowId(this.index.get(key) ?? -1); + } + + point(key: string) { + this.nav.cursor = this.index.get(key) ?? -1; + } + + rowClass(key: string) { + return this.nav.current?.key === key + ? "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]" + : "hover:bg-row-hover"; + } +} + +export type { Column }; + +export function provideColumn() { + const column = new Column(); + setContext(CONTEXT, column); + column.nav.use(); + return column; +} + +export function useColumn(order: number, rows: () => Row[]) { + const column = getContext(CONTEXT); + onMount(() => column.add({ order, rows })); + return column; +} diff --git a/src/lib/dashboard/primary.svelte.ts b/src/lib/dashboard/primary.svelte.ts new file mode 100644 index 0000000..16d970d --- /dev/null +++ b/src/lib/dashboard/primary.svelte.ts @@ -0,0 +1,137 @@ +import { api } from "#lib/api/client.js"; +import { all } from "#lib/api/lists.js"; +import type { ExchangeRate, Page, TransactionGroup } from "#lib/api/types.js"; +import { formatMoney, toNumber } from "#lib/format.js"; +import { session } from "#lib/session.svelte.js"; + +const KEY = "ffui.dashboard.in-primary"; +const OWN_SAMPLE = 100; +const HOUR_MS = 3_600_000; + +export interface Rate { + own: boolean; + rate: number; +} + +export interface Part { + amount: number; + code: string; +} + +export function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +class InPrimary { + on = $state( + typeof localStorage !== "undefined" && localStorage.getItem(KEY) === "1" + ); + + toggle() { + this.on = !this.on; + localStorage.setItem(KEY, this.on ? "1" : "0"); + } +} + +export const inPrimary = new InPrimary(); + +function ownRates( + groups: { attributes: TransactionGroup }[], + primary: string +): Record { + const implied = new Map(); + const add = (code: string, rate: number) => + implied.set(code, [...(implied.get(code) ?? []), rate]); + for (const s of groups.flatMap((g) => g.attributes.transactions)) { + const amount = toNumber(s.amount); + const foreign = toNumber(s.foreign_amount); + if (!(amount && foreign && s.foreign_currency_code && s.currency_code)) { + continue; + } + if (s.currency_code === primary) { + add(s.foreign_currency_code, amount / foreign); + } else if (s.foreign_currency_code === primary) { + add(s.currency_code, foreign / amount); + } + } + return Object.fromEntries( + [...implied].map(([code, values]) => [ + code, + { own: true, rate: median(values) }, + ]) + ); +} + +function listedRates( + listed: { attributes: ExchangeRate }[], + primary: string +): Record { + const rates: Record = {}; + const dated = new Map(); + for (const { attributes: r } of listed) { + const direct = r.to_currency_code === primary; + const code = direct ? r.from_currency_code : r.to_currency_code; + const rate = toNumber(r.rate); + const date = r.date ?? ""; + const usable = direct || r.from_currency_code === primary; + if (code && rate && usable && date >= (dated.get(code) ?? "")) { + dated.set(code, date); + rates[code] = { own: false, rate: direct ? rate : 1 / rate }; + } + } + return rates; +} + +async function loadRates(primary: string): Promise> { + const [listed, own] = await Promise.all([ + all("/v1/exchange-rates"), + api>("/v1/search/transactions", { + query: { limit: OWN_SAMPLE, query: "has_foreign_amount:true" }, + }), + ]); + return { + ...ownRates(own.data, primary), + ...listedRates(listed, primary), + [primary]: { own: false, rate: 1 }, + }; +} + +export const ratesQuery = () => ({ + enabled: inPrimary.on, + queryFn: () => loadRates(session.primary?.code ?? ""), + queryKey: ["dashboard-rates", session.primary?.code ?? ""], + staleTime: HOUR_MS, +}); + +export function inPrimaryTotal( + parts: Part[], + rates: Record | undefined +): number | null { + if (!(inPrimary.on && rates)) { + return null; + } + let total = 0; + for (const part of parts) { + const rate = rates[part.code]; + if (!rate) { + return null; + } + total += part.amount * rate.rate; + } + return total; +} + +export function formatPrimary(amount: number, signed = false): string { + const { primary } = session; + return `≈${formatMoney( + { + amount, + code: primary?.code, + decimals: primary?.decimal_places, + symbol: primary?.symbol, + }, + signed + )}`; +} diff --git a/src/lib/dashboard/reviewed.svelte.ts b/src/lib/dashboard/reviewed.svelte.ts new file mode 100644 index 0000000..45972f8 --- /dev/null +++ b/src/lib/dashboard/reviewed.svelte.ts @@ -0,0 +1,70 @@ +const UNTIL_KEY = "ffui.reviewed-until"; +const IDS_KEY = "ffui.reviewed-ids"; +const FIRST_LOOK_MS = 2 * 86_400_000; + +export interface Entry { + at: string; + id: string; +} + +const time = (value: string) => Date.parse(value); + +function read(key: string): string | null { + return typeof localStorage === "undefined" ? null : localStorage.getItem(key); +} + +class Reviewed { + until = $state(read(UNTIL_KEY) ?? ""); + ids = $state.raw(new Set(JSON.parse(read(IDS_KEY) ?? "[]"))); + + constructor() { + if (!this.until && typeof localStorage !== "undefined") { + const first = new Date(Date.now() - FIRST_LOOK_MS).toISOString(); + this.save(first, this.ids); + } + } + + isNew(entry: Entry) { + return time(entry.at) > time(this.until) && !this.ids.has(entry.id); + } + + toggle(entry: Entry) { + const ids = new Set(this.ids); + if (this.isNew(entry)) { + ids.add(entry.id); + } else { + ids.delete(entry.id); + } + this.save(this.until, ids); + } + + all(entries: Entry[]) { + const newest = Math.max( + time(this.until), + ...entries.map((e) => time(e.at)) + ); + this.save(new Date(newest).toISOString(), new Set()); + } + + compact(entries: Entry[]) { + const after = entries + .filter((e) => time(e.at) > time(this.until)) + .sort((a, b) => time(a.at) - time(b.at)); + const open = after.find((e) => !this.ids.has(e.id)); + const limit = open ? time(open.at) : Number.POSITIVE_INFINITY; + const done = after.filter((e) => time(e.at) < limit); + const ids = after + .filter((e) => time(e.at) >= limit && this.ids.has(e.id)) + .map((e) => e.id); + this.save(done.at(-1)?.at ?? this.until, new Set(ids)); + } + + private save(until: string, ids: Set) { + this.until = until; + this.ids = ids; + localStorage.setItem(UNTIL_KEY, until); + localStorage.setItem(IDS_KEY, JSON.stringify([...ids])); + } +} + +export const reviewed = new Reviewed(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fba74e1..2279339 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,93 +1,92 @@ - + + {#snippet actions()} +
    + inPrimary.toggle()} + /> + + + {#if ownRates} + ≈ from your own exchanges + {/if} +
    + {/snippet} +
    -
    -
    +
    + +
    + + + + + + +
    +
    -
    - {#each frontpage as account (account.id)} - {@const trend = trends.get(account.attributes.name)} - - {/each} - - - -