diff --git a/src/lib/components/accounts/AccountShow.svelte b/src/lib/components/accounts/AccountShow.svelte index 7f101e5..9944a2e 100644 --- a/src/lib/components/accounts/AccountShow.svelte +++ b/src/lib/components/accounts/AccountShow.svelte @@ -19,6 +19,7 @@ setPrimary, span, } from "#lib/components/dashboard/data.js"; + import Merchant from "#lib/components/dimension/Merchant.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte"; import Pager from "#lib/components/transactions/Pager.svelte"; import TransactionTable from "#lib/components/transactions/TransactionTable.svelte"; @@ -75,9 +76,10 @@ const statement = $derived( !everything && (type === "asset" || type === "liabilities") ); + const merchant = $derived(type === "expense" || type === "revenue"); const first = createQuery(() => ({ - enabled: everything, + enabled: everything && !merchant, queryFn: async () => { const head = await api>( `/v1/accounts/${id}/transactions`, @@ -116,7 +118,7 @@ return days <= 3000 ? "1M" : "1Y"; }); const chart = createQuery(() => ({ - enabled: !statement && (!everything || first.isSuccess), + enabled: !(statement || merchant) && (!everything || first.isSuccess), placeholderData: keepPreviousData, queryFn: () => api("/v1/chart/account/overview", { @@ -147,7 +149,7 @@ Number(page.url.searchParams.get("page") ?? "1") || 1 ); const dated = createQuery(() => ({ - enabled: !(everything || statement), + enabled: !(everything || statement || merchant), placeholderData: keepPreviousData, queryFn: () => all( @@ -161,7 +163,7 @@ queryKey: ["account-tx-all", id, period.start, period.end], })); const paged = createQuery(() => ({ - enabled: everything, + enabled: everything && !merchant, placeholderData: keepPreviousData, queryFn: () => api>(`/v1/accounts/${id}/transactions`, { @@ -310,6 +312,8 @@ {#if item} {/if} +{:else if merchant} + {:else}
import { createQuery } from "@tanstack/svelte-query"; + import { untrack } from "svelte"; import { api } from "#lib/api/client.js"; import { all, invalidate } from "#lib/api/lists.js"; - import type { - Budget, - BudgetLimit, - One, - TransactionGroup, - } from "#lib/api/types.js"; - import Box from "#lib/components/Box.svelte"; + import type { Budget, BudgetLimit, One } from "#lib/api/types.js"; import ConfirmButton from "#lib/components/ConfirmButton.svelte"; - import BreakdownBox from "#lib/components/categories/BreakdownBox.svelte"; - import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte"; - import GroupsBox from "#lib/components/categories/GroupsBox.svelte"; - import { - breakdown, - curOfSplit, - dayOf, - firstDay, - fromEntries, - inRange, - periodRanges, - rows as splitRows, - stepLabel, - } from "#lib/components/categories/sums.js"; - import { tones, wash } from "#lib/components/charts/chart.js"; - import LineChart from "#lib/components/charts/LineChart.svelte"; - import Meter from "#lib/components/charts/Meter.svelte"; - import StackedBars from "#lib/components/charts/StackedBars.svelte"; + import type { Lane, StackRow } from "#lib/components/charts/chart.js"; + import Dimension from "#lib/components/dimension/Dimension.svelte"; + import LimitLine from "#lib/components/dimension/LimitLine.svelte"; + import LimitStrip from "#lib/components/dimension/LimitStrip.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte"; import { Button } from "#lib/components/ui/button/index.js"; - import { addDays, contains, today } from "#lib/dates.js"; - import { formatMoney, formatRange, toNumber } from "#lib/format.js"; + import { DimensionModel } from "#lib/dimension/model.svelte.js"; + import { budgetSpec } from "#lib/dimension/spec.js"; + import { limitFacts, stripsOf, withLimits } from "#lib/dimension/stats.js"; import { useKeys } from "#lib/keys.svelte.js"; import { period } from "#lib/period.svelte.js"; + import { longMonth } from "#lib/reports/board.js"; import { goto } from "$app/navigation"; - import { page } from "$app/state"; import { limitRange } from "./data.js"; let { id, limitId }: { id: string; limitId?: string } = $props(); @@ -48,166 +30,42 @@ queryFn: () => all(`/v1/budgets/${id}/limits`), queryKey: ["budget-limits", id], })); - const groups = createQuery(() => ({ - queryFn: () => all(`/v1/budgets/${id}/transactions`), - queryKey: ["budget-transactions", id], - })); + const model = new DimensionModel(() => ({ spec: budgetSpec(id) })); const name = $derived(budget.data?.data.attributes.name ?? ""); - const sortedLimits = $derived( - [...(limits.data ?? [])].sort((a, b) => - (b.attributes.start ?? "").localeCompare(a.attributes.start ?? "") - ) - ); - const limit = $derived(sortedLimits.find((l) => l.id === limitId)); - const range = $derived(limit ? limitRange(limit) : null); - - const allRows = $derived( - splitRows(groups.data ?? [], (s) => s.budget_id === id) - ); - const scoped = $derived( - limit && range - ? inRange(allRows, range).filter( - (r) => r.split.currency_code === limit.attributes.currency_code + const list = $derived(limits.data ?? []); + const limit = $derived(list.find((l) => l.id === limitId)); + const facts = $derived( + model.range + ? limitFacts(list, model.main, model.range, model.now).sort( + (a, b) => + Number(b.code === limit?.attributes.currency_code) - + Number(a.code === limit?.attributes.currency_code) ) - : allRows + : [] ); - const spentRows = $derived( - scoped.filter((r) => r.split.type === "withdrawal") - ); - const codes = $derived( - [...new Set(spentRows.map((r) => r.split.currency_code ?? ""))].filter( - Boolean - ) - ); - const code = $derived( - limit?.attributes.currency_code ?? - (codes.includes(page.url.searchParams.get("currency") ?? "") - ? page.url.searchParams.get("currency") - : codes[0]) ?? - "" - ); - const inCur = $derived( - spentRows.filter((r) => r.split.currency_code === code) - ); - const cur = $derived( - inCur[0] - ? curOfSplit(inCur[0].split) - : { - code, - decimals: limit?.attributes.currency_decimal_places ?? 2, - symbol: limit?.attributes.currency_symbol ?? code, - } - ); - const fmt = (value: number) => - formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol }); + const strips = $derived(stripsOf(list, model.main, model.months)); - const visibleGroups = $derived.by(() => { - if (!range) { - return groups.data ?? []; + $effect(() => { + if (limit) { + const range = limitRange(limit); + untrack(() => period.set(range)); } - const ids = new Set(scoped.map((r) => r.group.id)); - return (groups.data ?? []).filter((g) => ids.has(g.id)); }); - - const view = $derived(period.view); - const history = $derived.by(() => { - const first = firstDay(inCur) ?? period.start; - const end = period.end > first ? period.end : today(); - const ranges = periodRanges(end, view, 400) - .filter((r) => r.end >= first) - .reverse(); - return ranges.map((r) => { - const spent = inRange(inCur, r).reduce( - (sum, row) => sum + toNumber(row.split.amount), - 0 - ); - const own = sortedLimits.find( - (l) => - l.attributes.currency_code === code && - contains(r, l.attributes.start ?? "") - ); - const amount = own ? toNumber(own.attributes.amount) : 0; - return { - format: fmt, - href: own ? `/budgets/show/${id}/${own.id}` : undefined, - key: r.start, - label: stepLabel(r, view), - values: amount - ? { - left: Math.max(0, amount - spent), - over: Math.max(0, spent - amount), - spent: Math.min(spent, amount), - } - : { left: 0, over: 0, spent }, - }; + $effect(() => { + const { start, end } = period; + untrack(() => { + const range = limit && limitRange(limit); + if (range && (range.start !== start || range.end !== end)) { + setTimeout(() => + goto(`/budgets/show/${id}?start=${start}&end=${end}`, { + replace: true, + reset: false, + }) + ); + } }); }); - const historyKeys = [ - { color: "var(--ff-brand)", key: "spent", label: "Spent" }, - { color: wash("var(--ff-brand)", 28), key: "left", label: "Left" }, - { color: tones.expense, key: "over", label: "Overspent" }, - ]; - - const burn = $derived.by(() => { - if (!(limit && range)) { - return []; - } - const amount = toNumber(limit.attributes.amount); - const last = range.end < today() ? range.end : today(); - const byDay = new Map(); - for (const row of inCur) { - byDay.set( - dayOf(row), - (byDay.get(dayOf(row)) ?? 0) + toNumber(row.split.amount) - ); - } - const points: { x: string; y: number }[] = []; - let left = amount; - for (let d = range.start; d <= last; d = addDays(d, 1)) { - left -= byDay.get(d) ?? 0; - points.push({ x: d, y: left }); - } - return [ - { - area: true, - format: fmt, - key: "left", - label: "Left", - points, - strong: true, - }, - { - color: "var(--cladd-fg-softer)", - format: fmt, - key: "pace", - label: "Even pace", - points: [ - { x: range.start, y: amount }, - { x: range.end, y: 0 }, - ], - }, - ]; - }); - - const byCategory = $derived( - breakdown(inCur, (s) => ({ - href: s.category_id ? `/categories/show/${s.category_id}` : undefined, - label: s.category_name ?? "(no category)", - })) - ); - const bySource = $derived( - breakdown(inCur, (s) => ({ - href: s.source_id ? `/accounts/show/${s.source_id}` : undefined, - label: s.source_name ?? "", - })) - ); - const byDestination = $derived( - breakdown(inCur, (s) => ({ - href: s.destination_id ? `/accounts/show/${s.destination_id}` : undefined, - label: s.destination_name ?? "", - })) - ); async function remove() { await api(`/v1/budgets/${id}`, { method: "DELETE" }); @@ -227,16 +85,14 @@ {#snippet actions()} @@ -244,111 +100,50 @@ {/snippet} -
- {#if limit && range} - {lane.label} + {#if row} + {longMonth(row.key)} + {lane.format((row.values.spent ?? 0) + (row.values.over ?? 0))} - {#if burn[0]?.points.length} - - {:else} -

Nothing spent yet.

- {/if} -
- {:else} - - {#snippet tools()} - - {/snippet} - {#if history.length > 0} - - {:else} -

No expenses yet.

- {/if} -
- {/if} - -
- - - -
- -
- - {#snippet links()} - {#if limit} - Show everything - {/if} - {/snippet} - - -
-
+ limit + {/if} + {/if} + {#if lane.typical} + + + typical + {lane.format(lane.typical)} + + {/if} +{/snippet} + + withLimits(lanes, list)} +> + {#snippet extra()} + {#if !limits.isPending} + + {/if} + {/snippet} + {#snippet below()} + + {/snippet} + diff --git a/src/lib/components/categories/BreakdownBox.svelte b/src/lib/components/categories/BreakdownBox.svelte deleted file mode 100644 index af8f39f..0000000 --- a/src/lib/components/categories/BreakdownBox.svelte +++ /dev/null @@ -1,64 +0,0 @@ - - - - {#if items.length === 0} -

Nothing in this range.

- {/if} -
- {#each items as item (item.cur.code)} - {#if items.length > 1} -

- {item.cur.code} -

- {/if} - - {/each} -
-
diff --git a/src/lib/components/categories/CurrencyTabs.svelte b/src/lib/components/categories/CurrencyTabs.svelte deleted file mode 100644 index 1d07c00..0000000 --- a/src/lib/components/categories/CurrencyTabs.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -{#if codes.length > 1} - -{/if} diff --git a/src/lib/components/categories/ShowCategory.svelte b/src/lib/components/categories/ShowCategory.svelte index 6931f66..49040c9 100644 --- a/src/lib/components/categories/ShowCategory.svelte +++ b/src/lib/components/categories/ShowCategory.svelte @@ -1,32 +1,21 @@ @@ -103,16 +61,17 @@ { href: "/categories", label: "Categories" }, { label: name || "Category" }, ]} - subtitle={everything - ? "All transactions" - : `Between ${formatDate(period.start)} and ${formatDate(period.end)}`} + subtitle={everything ? "All time" : period.label} title={name || "Category"} > {#snippet actions()} + @@ -120,66 +79,11 @@ {/snippet} -
-
- {#if !everything} - - {#snippet tools()} - c.code)} - current={cur?.code ?? ""} - /> - {/snippet} - - - {/if} - - {#snippet tools()} - {#if everything} - c.code)} - current={cur?.code ?? ""} - /> - {/if} - {/snippet} - - -
- - periodPath(base, r.start, r.end)} - keep={(s) => s.category_id === id} - loading={groups.isPending} - sidebar={!everything} - title="Transactions" - > - {#snippet links()} - {#if everything} - Show the current period and overview - {:else} - Show all transactions without grouping them by date - {/if} - {/snippet} - -
+ + everything + ? goto(periodPath(base, range.start, range.end)) + : period.set(range)} +/> diff --git a/src/lib/components/categories/flow.ts b/src/lib/components/categories/flow.ts deleted file mode 100644 index f31485a..0000000 --- a/src/lib/components/categories/flow.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - type StackKey, - type StackRow, - tones, -} from "#lib/components/charts/chart.js"; -import type { Range } from "#lib/dates.js"; -import { formatMoney, toNumber } from "#lib/format.js"; -import { type Cur, curOfSplit, inRange, type Row } from "./sums.js"; - -export const FLOW_KEYS: StackKey[] = [ - { color: tones.income, key: "earned", label: "Earned" }, - { color: tones.expense, key: "spent", label: "Spent" }, -]; - -export function currencies(items: Row[]): Cur[] { - const found = new Map(); - const counts = new Map(); - for (const { split } of items) { - const cur = curOfSplit(split); - found.set(cur.code, cur); - counts.set(cur.code, (counts.get(cur.code) ?? 0) + 1); - } - return [...found.values()].sort( - (a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0) - ); -} - -export function flowRows( - items: Row[], - ranges: Range[], - cur: Cur, - label: (range: Range) => string, - href?: (range: Range) => string -): StackRow[] { - const own = items.filter((row) => row.split.currency_code === cur.code); - const format = (value: number) => - formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol }); - return ranges.map((range) => { - let spent = 0; - let earned = 0; - for (const { split } of inRange(own, range)) { - if (split.type === "withdrawal") { - spent -= toNumber(split.amount); - } else if (split.type === "deposit") { - earned += toNumber(split.amount); - } - } - return { - format, - href: href?.(range), - key: range.start, - label: label(range), - values: { earned, spent }, - }; - }); -} diff --git a/src/lib/components/charts/Lanes.svelte b/src/lib/components/charts/Lanes.svelte index 2589f6c..3a871b6 100644 --- a/src/lib/components/charts/Lanes.svelte +++ b/src/lib/components/charts/Lanes.svelte @@ -29,6 +29,7 @@ title = (row: StackRow) => row.label, label = "Months by currency", legend = true, + moreIsGood = false, header, onselect, }: { @@ -38,6 +39,7 @@ partial?: string | null; pinned?: boolean; dotLabel?: string; + moreIsGood?: boolean; dotColor?: string; title?: (row: StackRow) => string; label?: string; @@ -94,7 +96,7 @@ if (ratio === null || Math.abs(ratio) < 0.03) { return "text-cladd-fg-soft"; } - return ratio > 0 ? "text-expense" : "text-income"; + return ratio > 0 === moreIsGood ? "text-income" : "text-expense"; } diff --git a/src/lib/components/dimension/Dimension.svelte b/src/lib/components/dimension/Dimension.svelte new file mode 100644 index 0000000..e4a3c80 --- /dev/null +++ b/src/lib/components/dimension/Dimension.svelte @@ -0,0 +1,161 @@ + + + +{@render extra?.()} + +
+ + {#if lanes.length > 0} + onmonth(monthRange(month))} + partial={model.partial} + title={(row) => longMonth(row.key)} + /> + {:else if !model.loading} +

+ No spending or income here in these months. +

+ {/if} + {@render below?.()} +
+ + setParam( + "only", + only?.facet.id === f.id && only.key === row.key + ? "" + : `${f.id}:${row.key}` + )} + /> +
+ +
+ +
diff --git a/src/lib/components/dimension/FacetRows.svelte b/src/lib/components/dimension/FacetRows.svelte new file mode 100644 index 0000000..f9bafe4 --- /dev/null +++ b/src/lib/components/dimension/FacetRows.svelte @@ -0,0 +1,144 @@ + + +{#if rows.length === 0 && !loading} +

+ Nothing in this period. +

+{:else if rows.length > 0} +
    + {#each rows as row, i (row.key)} +
  • + + + + + + {row.sums.map((sum) => fmt(sum.code, sum.amount, 0)).join(" + ")} + + {percent(row.share)} + + + + {#if row.href} + + + + {:else} + + {/if} +
  • + {/each} +
+{/if} diff --git a/src/lib/components/dimension/Headline.svelte b/src/lib/components/dimension/Headline.svelte new file mode 100644 index 0000000..6d6882d --- /dev/null +++ b/src/lib/components/dimension/Headline.svelte @@ -0,0 +1,147 @@ + + +{#snippet amounts( + sums: Money[], + big: boolean +)} + {#each sums as sum, i (sum.code)} + {#if i > 0} + + + {/if} + {fmt(sum.code, sum.amount, 0)} + {/each} +{/snippet} + +
+ {#if lead} + {@render lead()} + {:else if !model.loading} +

+ {#if facts.count > 0} + {@render amounts(facts.sums, true)} + {when} + · {counted(facts.count, noun)} + {#if facts.median} + · median + {fmt(facts.median.code, facts.median.amount, 0)} + {/if} + {#if perWeek !== null} + · + {formatNumber(perWeek, perWeek < 10 ? 1 : 0)} + per week + {/if} + {:else} + Nothing {when} + {#if model.history.last} + · last {formatDate(model.history.last)} + {/if} + {/if} +

+ {/if} + {#if !model.loading && main} +

+ {#if lead} + {#if facts.count > 0} + {@render amounts(facts.sums, false)} + {when} · + {:else} + nothing {when} · + {/if} + {/if} + {#if typicals.length > 0} + typical month + {@render amounts( + typicals.map((n) => ({ amount: n.typical, code: n.code })), + false + )} + {#if main.projected !== null} + · on pace for ≈ + {fmt(main.code, main.projected, 0)}, + {:else if main.ratio !== null} + · + {/if} + {#if main.ratio !== null} + {versusText(main.ratio)} + {/if} + {:else if model.typicalMonths.length > 0} + in {active} of the last {model.typicalMonths.length} + {model.typicalMonths.length === 1 ? "month" : "months"} + {/if} +

+ {/if} +
diff --git a/src/lib/components/dimension/Inside.svelte b/src/lib/components/dimension/Inside.svelte new file mode 100644 index 0000000..9c0961f --- /dev/null +++ b/src/lib/components/dimension/Inside.svelte @@ -0,0 +1,104 @@ + + + { + if (focused && !section?.contains(event.target as Node)) { + focused = false; + } + }} +/> + +
+
+ {#each model.spec.facets as f, i (f.id)} + + {/each} +
+ {#key facet.id} + (focused = false)} + onnarrow={(row) => { + focused = false; + onnarrow(facet, row); + }} + {rows} + /> + {/key} +
diff --git a/src/lib/components/dimension/Ledger.svelte b/src/lib/components/dimension/Ledger.svelte new file mode 100644 index 0000000..050e989 --- /dev/null +++ b/src/lib/components/dimension/Ledger.svelte @@ -0,0 +1,70 @@ + + + + {#snippet tools()} + {#if narrowed} + {narrowed.label} + {narrowed.groups.length} + of {groups.length} + + {:else} + {groups.length} + {/if} + + {/snippet} + + diff --git a/src/lib/components/dimension/LimitLine.svelte b/src/lib/components/dimension/LimitLine.svelte new file mode 100644 index 0000000..def5984 --- /dev/null +++ b/src/lib/components/dimension/LimitLine.svelte @@ -0,0 +1,56 @@ + + +
+ {#each facts as fact (fact.id)} + {@const expected = + fact.day === null ? null : (fact.amount * fact.day) / fact.days} + {@const left = fact.amount - fact.spent} +

+ limit + {fmt(fact.code, fact.amount, 0)} + · spent + {percent(fact.amount > 0 ? fact.spent / fact.amount : 0)} + {#if fact.day !== null && expected !== null} + · day {fact.day} of {fact.days} → + {#if fact.spent > expected} + {fmt(fact.code, fact.spent - expected, 0)} + over pace + {:else} + under pace + {/if} + {/if} + {#if left >= 0} + · + {fmt(fact.code, left, 0)} + left + {:else} + · + {fmt(fact.code, -left, 0)} + overspent + {/if} +

+ {:else} +

no limit for this period

+ {/each} +
diff --git a/src/lib/components/dimension/LimitStrip.svelte b/src/lib/components/dimension/LimitStrip.svelte new file mode 100644 index 0000000..2891ab4 --- /dev/null +++ b/src/lib/components/dimension/LimitStrip.svelte @@ -0,0 +1,76 @@ + + +{#snippet meter( + cell: StripCell, + code: string +)} + {@const over = cell.spent > cell.amount} + {shortMonth(cell.month)} + + {#if cell.id} + + {/if} + + {cell.id + ? `${fmt(code, cell.spent, 0)} of ${fmt(code, cell.amount, 0)}` + : "no limit"} +{/snippet} + +{#each [...strips] as [code, cells] (code)} + +{/each} diff --git a/src/lib/components/dimension/Merchant.svelte b/src/lib/components/dimension/Merchant.svelte new file mode 100644 index 0000000..e221d08 --- /dev/null +++ b/src/lib/components/dimension/Merchant.svelte @@ -0,0 +1,90 @@ + + +{#snippet lead()} + {#if !model.loading} +

+ {#if history.count > 0} + {#each history.sums as sum, i (sum.code)} + {#if i > 0} + + + {/if} + {fmt(sum.code, sum.amount, 0)} + {/each} + all time · {counted(history.count, noun)} + {#if history.median} + · median + {fmt(history.median.code, history.median.amount, 0)} + {/if} + {#if every !== null} + · every + {formatNumber(every, every < 10 ? 1 : 0)} + days + {/if} + · last + {formatDate(history.last, "short")} + {#if bill} + · subscription + {bill.name} + {/if} + {:else} + No {noun} yet + {/if} +

+ {/if} +{/snippet} + + diff --git a/src/lib/components/tags/ShowTag.svelte b/src/lib/components/tags/ShowTag.svelte index 4f0536a..00816bb 100644 --- a/src/lib/components/tags/ShowTag.svelte +++ b/src/lib/components/tags/ShowTag.svelte @@ -1,39 +1,20 @@ @@ -136,145 +80,41 @@ ({ + href: `/tags/show/${encodeURIComponent(path)}`, + label: path.slice(path.lastIndexOf("/") + 1), + })), + { label: name.slice(name.lastIndexOf("/") + 1) || "Tag" }, ]} - subtitle={everything - ? "All transactions" - : `Between ${formatDate(period.start)} and ${formatDate(period.end)}`} - title={name} + {subtitle} + title={name || "Tag"} > {#snippet actions()} - {everything ? "This period" : "All time"} - Delete + {#if tag} + + Delete + {/if} {/snippet} -{#if tag.isError} -

{tag.error.message}

+{#if tags.isError} +

{tags.error.message}

{/if} -
-
- - - - {#if attributes?.description} - - - - - {/if} - {#if attributes?.date} - - - - - {/if} - {#each [ - { label: "Sum (excluding transfers)", sums: net }, - { label: "Sum of withdrawals", sums: list(sums.spent) }, - { label: "Sum of deposits", sums: list(sums.earned) }, - { label: "Sum of transfers", sums: list(sums.transferred) }, - ] as row (row.label)} - {#if row.sums.length > 0} - - - - - {/if} - {/each} - {#if children.length > 0} - - - - - {/if} - {#if scoped.length === 0 && !groups.isPending} - - - - {/if} - -
- Description - {attributes.description}
- Date - {formatDate(attributes.date)}
- {row.label} - - -
- Sub-tags - - {#each children as child, i (child.id)} - {child.attributes.tag.slice(name.length + 1)}{i < children.length - 1 ? ", " : ""} - {/each} -
- No transactions in this range. -
-
- - {#snippet tools()} - c.code)} - current={cur?.code ?? ""} - /> - {/snippet} - {#if history.length > 0} - - {:else} -

No transactions yet.

- {/if} -
-
- - periodPath(base, r.start, r.end)} - {keep} - loading={groups.isPending} - sidebar={!everything} - title="Transactions" - > - {#snippet links()} - {#if everything} - Show the current period and overview - {:else} - Show all transactions without grouping them by date - {/if} - {/snippet} - -
+ + everything + ? goto(periodPath(base, range.start, range.end)) + : period.set(range)} +/> diff --git a/src/lib/dimension/model.svelte.ts b/src/lib/dimension/model.svelte.ts new file mode 100644 index 0000000..3d7cfdd --- /dev/null +++ b/src/lib/dimension/model.svelte.ts @@ -0,0 +1,163 @@ +import { type CreateQueryResult, createQuery } from "@tanstack/svelte-query"; +import { lists } from "#lib/api/lists.js"; +import type { Account, Item, Split, TransactionGroup } from "#lib/api/types.js"; +import { contains, type Range, today } from "#lib/dates.js"; +import { period } from "#lib/period.svelte.js"; +import { monthOf } from "#lib/reports/board.js"; +import { buckets, type Flow } from "#lib/reports/flows.js"; +import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js"; +import { pages } from "#lib/reports/queries.js"; +import { impliedRates } from "#lib/reports/rates.js"; +import { typicalMonths } from "#lib/reports/typical.js"; +import { session } from "#lib/session.svelte.js"; +import type { Group } from "#lib/transactions/split.js"; +import type { Spec } from "./spec.js"; +import { codesOf, dirOf, factsOf, normsOf, weigh } from "./stats.js"; + +export interface Args { + allTime?: boolean; + everything?: boolean; + spec: Spec; +} + +interface Kept { + groups: Group[]; + journals: Set; +} + +function keptOf( + groups: Group[], + keep: (split: Split) => boolean, + range: Range | null +): Kept { + const journals = new Set(); + const kept: Group[] = []; + for (const group of groups) { + let hit = false; + for (const split of group.attributes.transactions) { + if (keep(split) && (!range || contains(range, split.date.slice(0, 10)))) { + journals.add(String(split.transaction_journal_id)); + hit = true; + } + } + if (hit) { + kept.push(group); + } + } + return { groups: kept, journals }; +} + +const flowsOf = (kept: Kept, accounts: Item[]): Flow[] => + ownFlows(kept.groups, accounts).filter((flow) => + kept.journals.has(flow.journal) + ); + +export class DimensionModel { + readonly now = today(); + readonly args: () => Args; + readonly ledger: CreateQueryResult; + readonly accounts: CreateQueryResult[]>; + readonly source: CreateQueryResult; + + spec = $derived.by(() => this.args().spec); + everything = $derived.by(() => Boolean(this.args().everything)); + allTime = $derived.by(() => this.everything || Boolean(this.args().allTime)); + range = $derived(this.everything ? null : period.range); + anchor = $derived( + this.range && this.range.end < ledgerWindow(this.now).start + ? this.range.end + : this.now + ); + window = $derived(ledgerWindow(this.anchor)); + inside = $derived( + this.range !== null && + this.range.start >= this.window.start && + this.range.end <= this.window.end + ); + sourceRange = $derived(this.allTime ? null : this.range); + + raw = $derived.by( + () => + (this.allTime ? this.source.data : this.ledger.data) ?? ([] as Group[]) + ); + loading = $derived.by( + () => + this.accounts.isPending || + (this.allTime ? this.source.isPending : this.ledger.isPending) || + (!(this.allTime || this.inside) && this.source.isPending) + ); + base = $derived(keptOf(this.raw, this.spec.keep, null)); + inPeriod = $derived.by(() => { + if (this.everything) { + return this.base; + } + const from = + this.allTime || this.inside ? this.raw : (this.source.data ?? []); + return keptOf(from, this.spec.keep, this.range); + }); + groups = $derived(this.inPeriod.groups); + flows = $derived.by(() => flowsOf(this.base, this.accounts.data ?? [])); + periodFlows = $derived.by(() => + flowsOf(this.inPeriod, this.accounts.data ?? []) + ); + dir = $derived(dirOf(this.flows.length > 0 ? this.flows : this.periodFlows)); + main = $derived(this.flows.filter((flow) => flow.dir === this.dir)); + periodMain = $derived( + this.periodFlows.filter((flow) => flow.dir === this.dir) + ); + codes = $derived(codesOf([...this.main, ...this.periodMain])); + history = $derived(factsOf(this.main, this.codes)); + months = $derived( + this.everything && this.history.first + ? buckets(this.history.first, this.now, "month") + : this.window.months + ); + typicalMonths = $derived( + typicalMonths(this.months, this.now, period.first ?? this.history.first) + ); + focus = $derived( + this.range && monthOf(this.range.start) === monthOf(this.range.end) + ? monthOf(this.range.start) + : null + ); + partial = $derived( + this.months.includes(monthOf(this.now)) ? monthOf(this.now) : null + ); + norms = $derived( + normsOf(this.main, this.codes, this.typicalMonths, this.focus, this.now) + ); + facts = $derived(factsOf(this.periodMain, this.codes)); + weight = $derived( + weigh( + [...this.main, ...this.periodMain], + session.primary?.code ?? "", + impliedRates(this.raw) + ) + ); + + constructor(args: () => Args) { + this.args = args; + this.ledger = createQuery(() => ({ + ...ledgerQuery(this.anchor), + enabled: !this.allTime, + })); + this.accounts = createQuery(() => lists.accounts("all")); + this.source = createQuery(() => { + const range = this.sourceRange; + return { + enabled: this.allTime || !this.inside, + queryFn: () => + pages(this.spec.source, { + ...(range ?? {}), + type: "default", + }), + queryKey: [ + "dimension", + this.spec.source, + range?.start ?? "all", + range?.end ?? "all", + ], + }; + }); + } +} diff --git a/src/lib/dimension/spec.ts b/src/lib/dimension/spec.ts new file mode 100644 index 0000000..6577e79 --- /dev/null +++ b/src/lib/dimension/spec.ts @@ -0,0 +1,109 @@ +import type { Item, Split, Tag } from "#lib/api/types.js"; +import { buildTree, flatten } from "#lib/components/tags/tree.js"; +import { type Flow, type Key, keys, NONE } from "#lib/reports/flows.js"; + +export interface Facet { + id: string; + incomeLabel?: string; + label: string; + of: (flow: Flow) => Key[]; +} + +export interface Spec { + facets: Facet[]; + keep: (split: Split) => boolean; + source: string; +} + +const listOf = (pick: (flow: Flow) => Key | null) => (flow: Flow) => { + const key = pick(flow); + return key ? [key] : []; +}; + +const facets = { + budgets: { id: "budgets", label: "Budgets", of: listOf(keys.budget) }, + cards: { id: "cards", label: "Cards", of: listOf(keys.asset) }, + categories: { + id: "categories", + label: "Categories", + of: listOf(keys.category), + }, + merchants: { + id: "merchants", + incomeLabel: "Payers", + label: "Merchants", + of: listOf(keys.other), + }, + tags: { id: "tags", label: "Tags", of: keys.tag }, +} satisfies Record; + +export const categorySpec = (id: string): Spec => ({ + facets: [facets.merchants, facets.cards, facets.budgets, facets.tags], + keep: (split) => String(split.category_id ?? "") === id, + source: `/v1/categories/${id}/transactions`, +}); + +export const budgetSpec = (id: string): Spec => ({ + facets: [facets.merchants, facets.cards, facets.categories, facets.tags], + keep: (split) => String(split.budget_id ?? "") === id, + source: `/v1/budgets/${id}/transactions`, +}); + +export const merchantSpec = (id: string): Spec => ({ + facets: [facets.categories, facets.cards, facets.budgets, facets.tags], + keep: (split) => + String(split.source_id) === id || String(split.destination_id) === id, + source: `/v1/accounts/${id}/transactions`, +}); + +export const underTag = (name: string, tag: string) => + tag === name || tag.startsWith(`${name}/`); + +function subtagFacet(name: string, tags: Item[]): Facet | null { + const node = flatten(buildTree(tags)).find((n) => n.path === name); + const children = node?.children ?? []; + if (children.length === 0) { + return null; + } + const childOf = (tag: string): Key | null => { + if (tag === name) { + return { key: NONE, label: "(no subtag)" }; + } + const child = children.find((c) => underTag(c.path, tag)); + return child + ? { + href: `/tags/show/${encodeURIComponent(child.path)}`, + key: child.path, + label: child.name, + } + : null; + }; + return { + id: "subtags", + label: "Subtags", + of: (flow) => { + const found = new Map(); + for (const tag of flow.tags) { + const key = childOf(tag); + if (key) { + found.set(key.key, key); + } + } + return [...found.values()]; + }, + }; +} + +export function tagSpec(name: string, tags: Item[]): Spec { + const subtags = subtagFacet(name, tags); + return { + facets: [ + ...(subtags ? [subtags] : []), + facets.merchants, + facets.cards, + facets.categories, + ], + keep: (split) => (split.tags ?? []).some((tag) => underTag(name, tag)), + source: "/v1/transactions", + }; +} diff --git a/src/lib/dimension/stats.ts b/src/lib/dimension/stats.ts new file mode 100644 index 0000000..4795085 --- /dev/null +++ b/src/lib/dimension/stats.ts @@ -0,0 +1,356 @@ +import type { BudgetLimit, Item } from "#lib/api/types.js"; +import { + type Lane, + type StackKey, + tones, + wash, +} from "#lib/components/charts/chart.js"; +import { contains, daysBetween, type Range } from "#lib/dates.js"; +import { monthOf, shortMonth } from "#lib/reports/board.js"; +import { type Flow, group, type Totals } from "#lib/reports/flows.js"; +import type { Rate } from "#lib/reports/rates.js"; +import { money } from "#lib/reports/report.svelte.js"; +import { median, pace, series, typical, versus } from "#lib/reports/typical.js"; +import { modeAxis } from "#lib/reports/view.js"; +import type { Facet } from "./spec.js"; + +export type Dir = 1 | -1; + +export interface Money { + amount: number; + code: string; +} + +export interface Facts { + count: number; + first: string; + last: string; + median: Money | null; + sums: Money[]; +} + +export interface Norm { + code: string; + projected: number | null; + ratio: number | null; + typical: number; +} + +export interface FacetRow { + href?: string; + key: string; + label: string; + share: number; + spark: number[]; + sums: Money[]; +} + +export interface LimitFact { + amount: number; + code: string; + day: number | null; + days: number; + id: string; + spent: number; +} + +export interface StripCell { + amount: number; + id: string | null; + month: string; + spent: number; +} + +const BRAND = "var(--ff-brand)"; +const PLURAL = /s$/; +const LEFT_WASH = 28; + +export const counted = (count: number, noun: string) => + `${count} ${count === 1 ? noun.replace(PLURAL, "") : noun}`; + +export const fmt = (code: string, amount: number, decimals?: number) => + money(code, amount, false, decimals); + +export function dirOf(flows: Flow[]): Dir { + const out = flows.filter((flow) => flow.dir === -1).length; + return out * 2 >= flows.length ? -1 : 1; +} + +function tally(flows: Flow[]) { + const by = new Map(); + for (const flow of flows) { + const t = by.get(flow.currency) ?? { count: 0, sum: 0 }; + t.count += 1; + t.sum += flow.amount; + by.set(flow.currency, t); + } + return by; +} + +export function codesOf(flows: Flow[]): string[] { + const by = tally(flows); + return [...by.keys()].sort( + (a, b) => (by.get(b)?.count ?? 0) - (by.get(a)?.count ?? 0) + ); +} + +export function sumsOf(flows: Flow[], codes: string[]): Money[] { + const by = tally(flows); + return codes + .filter((code) => by.has(code)) + .map((code) => ({ amount: by.get(code)?.sum ?? 0, code })); +} + +export function factsOf(flows: Flow[], codes: string[]): Facts { + const main = codes.find((code) => flows.some((f) => f.currency === code)); + let first = ""; + let last = ""; + for (const flow of flows) { + first = !first || flow.date < first ? flow.date : first; + last = flow.date > last ? flow.date : last; + } + const amounts = flows.filter((f) => f.currency === main).map((f) => f.amount); + return { + count: new Set(flows.map((flow) => flow.group)).size, + first, + last, + median: main ? { amount: median(amounts), code: main } : null, + sums: sumsOf(flows, codes), + }; +} + +export function normsOf( + flows: Flow[], + codes: string[], + months: string[], + focus: string | null, + now: string +): Norm[] { + const by = series(flows, (flow) => flow.currency); + return codes.map((code) => { + const typ = typical(by.get(code), months); + if (focus === monthOf(now)) { + const p = pace( + flows.filter((flow) => flow.currency === code), + months, + now + ); + const projected = p.sofar + typ - p.typical; + return { code, projected, ratio: versus(projected, typ), typical: typ }; + } + const value = focus ? (by.get(code)?.get(focus) ?? 0) : 0; + return { + code, + projected: null, + ratio: focus ? versus(value, typ) : null, + typical: typ, + }; + }); +} + +export function lanesOf( + flows: Flow[], + months: string[], + codes: string[], + dir: Dir, + norms: Norm[], + height: number +): Lane[] { + const key: StackKey = { + color: BRAND, + key: "value", + label: dir === -1 ? "Spent" : "Earned", + }; + const main = series( + flows.filter((flow) => flow.dir === dir), + (flow) => flow.currency + ); + const back = series( + flows.filter((flow) => flow.dir !== dir), + (flow) => flow.currency + ); + return codes.map((code) => ({ + axisFormat: modeAxis(code), + dots: back.has(code) + ? Object.fromEntries( + months.map((month) => [month, back.get(code)?.get(month) ?? 0]) + ) + : undefined, + format: (value: number) => fmt(code, value, 0), + height, + key: code, + keys: [key], + label: code, + rows: months.map((month) => ({ + key: month, + label: shortMonth(month), + values: { value: main.get(code)?.get(month) ?? 0 }, + })), + typical: norms.find((n) => n.code === code)?.typical || null, + })); +} + +const LIMIT_KEYS: StackKey[] = [ + { color: BRAND, key: "spent", label: "Spent" }, + { color: wash(BRAND, LEFT_WASH), key: "left", label: "Left" }, + { color: tones.expense, key: "over", label: "Overspent" }, +]; + +const limitKey = (code: string, month: string) => `${code} ${month}`; + +function limitsByMonth(limits: Item[]) { + const by = new Map(); + for (const limit of limits) { + const k = limitKey( + limit.attributes.currency_code ?? "", + monthOf(limit.attributes.start ?? "") + ); + by.set(k, (by.get(k) ?? 0) + Number(limit.attributes.amount)); + } + return by; +} + +export function withLimits(lanes: Lane[], limits: Item[]): Lane[] { + const by = limitsByMonth(limits); + return lanes.map((lane) => ({ + ...lane, + keys: LIMIT_KEYS, + rows: lane.rows.map((row) => { + const spent = row.values.value ?? 0; + const limit = by.get(limitKey(lane.key, row.key)); + const values: Record = limit + ? { + left: Math.max(0, limit - spent), + over: Math.max(0, spent - limit), + spent: Math.min(spent, limit), + } + : { spent }; + return { ...row, values }; + }), + })); +} + +const limitRangeOf = (limit: Item): Range => ({ + end: (limit.attributes.end ?? "").slice(0, 10), + start: (limit.attributes.start ?? "").slice(0, 10), +}); + +const spentIn = (flows: Flow[], code: string, range: Range) => + flows + .filter((flow) => flow.currency === code && contains(range, flow.date)) + .reduce((sum, flow) => sum + flow.amount, 0); + +export function limitFacts( + limits: Item[], + flows: Flow[], + range: Range, + now: string +): LimitFact[] { + return limits + .filter((limit) => { + const r = limitRangeOf(limit); + return r.start <= range.end && r.end >= range.start; + }) + .map((limit) => { + const r = limitRangeOf(limit); + const code = limit.attributes.currency_code ?? ""; + return { + amount: Number(limit.attributes.amount), + code, + day: contains(r, now) ? daysBetween(r.start, now) + 1 : null, + days: daysBetween(r.start, r.end) + 1, + id: limit.id, + spent: spentIn(flows, code, r), + }; + }); +} + +export function stripsOf( + limits: Item[], + flows: Flow[], + months: string[] +): Map { + const spent = series(flows, (flow) => flow.currency); + const strips = new Map(); + const codes = new Set(limits.map((l) => l.attributes.currency_code ?? "")); + for (const code of codes) { + const own = limits.filter((l) => l.attributes.currency_code === code); + const cells = months.map((month) => { + const limit = own.find( + (l) => monthOf(l.attributes.start ?? "") === month + ); + return { + amount: limit ? Number(limit.attributes.amount) : 0, + id: limit?.id ?? null, + month, + spent: spent.get(code)?.get(month) ?? 0, + }; + }); + if (cells.some((cell) => cell.id)) { + strips.set(code, cells); + } + } + return strips; +} + +export function weigh( + flows: Flow[], + primary: string, + rate: Rate +): (code: string) => number { + const codes = [...new Set(flows.map((flow) => flow.currency))]; + const rates = new Map( + codes.map((code) => [code, code === primary ? 1 : rate(code, primary)]) + ); + if ([...rates.values()].every((r) => r !== null)) { + return (code) => rates.get(code) ?? 0; + } + const by = tally(flows); + return (code) => { + const t = by.get(code); + return t && t.sum > 0 ? t.count / t.sum : 0; + }; +} + +export function facetRows( + facet: Facet, + period: Flow[], + window: Flow[], + months: string[], + codes: string[], + weight: (code: string) => number +): FacetRow[] { + const worth = (totals: Totals | undefined) => { + let sum = 0; + for (const [code, m] of totals?.cur ?? []) { + sum += (m.in + m.out) * weight(code); + } + return sum; + }; + const sparks = new Map( + group(window, facet.of, (flow) => monthOf(flow.date)).map((row) => [ + row.key, + months.map((month) => worth(row.cells.get(month))), + ]) + ); + const total = + period.reduce( + (sum, flow) => sum + flow.amount * weight(flow.currency), + 0 + ) || 1; + return group(period, facet.of) + .map((row) => ({ + href: row.href, + key: row.key, + label: row.label, + share: worth(row.totals) / total, + spark: sparks.get(row.key) ?? [], + sums: codes + .filter((code) => row.totals.cur.has(code)) + .map((code) => { + const m = row.totals.cur.get(code); + return { amount: (m?.in ?? 0) + (m?.out ?? 0), code }; + }), + })) + .sort((a, b) => b.share - a.share || a.label.localeCompare(b.label)); +}