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