diff --git a/src/lib/components/charts/Columns.svelte b/src/lib/components/charts/Columns.svelte index ca7e484..bf321a3 100644 --- a/src/lib/components/charts/Columns.svelte +++ b/src/lib/components/charts/Columns.svelte @@ -11,6 +11,7 @@ type StackRow, stack, textWidth, + tones, } from "./chart.js"; import Legend from "./Legend.svelte"; @@ -24,6 +25,9 @@ const TILTED_CHARS = 16; const TILTED_CHAR_WIDTH = 4.9; const LABEL_GAP = 13; + const BARE_BOTTOM = 4; + const DOT_ROOM = 1.5; + const DOT_R = 3.5; let { keys, @@ -33,6 +37,16 @@ axisFormat = (value: number) => compactMoney(value), label = "Bar chart", active = $bindable(null), + selected = null, + reference = null, + partial = null, + dots, + dotColor = tones.income, + gutter, + legend: showLegend = true, + axis = true, + tilt = true, + keyboard = true, onselect, }: { keys: StackKey[]; @@ -42,30 +56,63 @@ axisFormat?: (value: number) => string; label?: string; active?: string | null; - onselect?: (row: StackRow) => void; + selected?: string | null; + reference?: number | null; + partial?: string | null; + dots?: Record; + dotColor?: string; + gutter?: number; + legend?: boolean; + axis?: boolean; + tilt?: boolean; + keyboard?: boolean; + onselect?: (row: StackRow, segment?: string) => void; } = $props(); + const uid = $props.id(); let width = $state(0); const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) }))); const yCount = $derived(Math.max(2, Math.floor((height - 60) / GAP_Y))); + const stackTop = $derived(Math.max(0, ...stacks.map((s) => s.positive))); + const dotValues = $derived( + Object.values(dots ?? {}).filter( + (v) => v > 0 && (stackTop === 0 || v <= stackTop * DOT_ROOM) + ) + ); const yBase = $derived( scaleLinear() .domain([ Math.min(0, ...stacks.map((s) => s.negative)), - Math.max(0, ...stacks.map((s) => s.positive)) || 1, + Math.max(stackTop, reference ?? 0, ...dotValues) || 1, ]) .nice(yCount) ); const yTicks = $derived(yBase.ticks(yCount)); const left = $derived( - Math.ceil(Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10) + gutter ?? + Math.ceil( + Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10 + ) ); const step = $derived((width - left - RIGHT) / Math.max(1, rows.length)); - const tilted = $derived( + const crowded = $derived( rows.some((r) => textWidth(r.label, 6.2) > step - 6) && step < 70 ); - const bottom = $derived(tilted ? TILTED_BOTTOM : FLAT_BOTTOM); + const tilted = $derived(tilt && crowded); + const flatEvery = $derived( + crowded && !tilt + ? Math.ceil( + (Math.max(...rows.map((r) => textWidth(r.label, 6.2))) + 6) / step + ) + : 1 + ); + const bottom = $derived.by(() => { + if (!axis) { + return BARE_BOTTOM; + } + return tilted ? TILTED_BOTTOM : FLAT_BOTTOM; + }); const labelEvery = $derived(Math.max(1, Math.ceil(LABEL_GAP / step))); const y = $derived(yBase.copy().range([height - bottom, TOP])); const x = $derived( @@ -88,7 +135,13 @@ const activeIndex = $derived( active ? rows.findIndex((r) => r.key === active) : -1 ); - const readRow = $derived(rows[activeIndex >= 0 ? activeIndex : 0]); + const selectedIndex = $derived( + selected ? rows.findIndex((r) => r.key === selected) : -1 + ); + const readRow = $derived( + rows[Math.max(0, activeIndex >= 0 ? activeIndex : selectedIndex)] + ); + const yTop = $derived(y.domain()[1]); const fmt = (row: StackRow, value: number) => (row.format ?? format)(value); const legend = $derived( readRow @@ -113,17 +166,31 @@ : text; } - function select(row: StackRow | undefined) { + function select(row: StackRow | undefined, segment?: string) { if (!row) { return; } if (onselect) { - onselect(row); + onselect(row, segment); } else if (row.href) { goto(row.href); } } + function segmentAt(event: MouseEvent, index: number) { + const found = stacks[index]; + if (!found) { + return; + } + const box = (event.currentTarget as HTMLElement).getBoundingClientRect(); + const value = y.invert(event.clientY - box.top); + return found.segments.find( + (seg) => + value >= Math.min(seg.from, seg.to) && + value <= Math.max(seg.from, seg.to) + )?.key; + } + const clickable = $derived(Boolean(onselect) || rows.some((r) => r.href)); function pick(event: PointerEvent) { @@ -136,6 +203,9 @@ } function onkeydown(event: KeyboardEvent) { + if (!keyboard) { + return; + } if (event.key === "Enter") { select(rows[activeIndex]); return; @@ -179,14 +249,16 @@
-
- {#if keys.length > 1 && readRow} - {readRow.label} - {/if} - -
+ {#if showLegend} +
+ {#if keys.length > 1 && readRow} + {readRow.label} + {/if} + +
+ {/if}
(active = null)} - onclick={() => select(rows[activeIndex])} - onfocus={() => (active ??= rows[0]?.key ?? null)} + onblur={() => keyboard && (active = null)} + onclick={(event) => + select(rows[activeIndex], segmentAt(event, activeIndex))} + onfocus={() => keyboard && (active ??= rows[0]?.key ?? null)} {onkeydown} onpointerleave={() => (active = null)} onpointermove={pick} role="slider" - tabindex="0" + tabindex={keyboard ? 0 : -1} style:height="{height}px" bind:clientWidth={width} > {#if width > 0 && rows.length > 0} {/if}
diff --git a/src/lib/components/charts/Lanes.svelte b/src/lib/components/charts/Lanes.svelte new file mode 100644 index 0000000..2589f6c --- /dev/null +++ b/src/lib/components/charts/Lanes.svelte @@ -0,0 +1,182 @@ + + +{#snippet defaultHeader( + lane: Lane, + row: StackRow | undefined +)} + {@const value = totalOf(row)} + {@const sofar = row?.key === partial} + {@const ratio = + row && lane.typical && !sofar ? versus(value, lane.typical) : null} + {@const dot = row ? lane.dots?.[row.key] : undefined} + {lane.label} + {#if row} + {title(row)} + {lane.format(value)} + {#if sofar} + so far + {:else if ratio !== null} + {versusText(ratio)} + {/if} + {/if} + {#if lane.typical} + + + typical + {lane.format(lane.typical)} + + {/if} + {#if dot} + + + {dotLabel} + {lane.format(dot)} + + {/if} +{/snippet} + +
+ {#if legend && sharedKeys.length > 1} + ({ ...k, shape: "rect" as const }))} + /> + {/if} + {#each lanes as lane, i (lane.key)} + {@const last = i === lanes.length - 1} +
+
+ {@render (header ?? defaultHeader)(lane, rowOf(lane))} +
+ onselect(row.key, lane, segment) + : undefined} + {partial} + reference={lane.typical} + rows={lane.rows} + selected={cursor} + tilt={false} + bind:active={hover} + /> +
+ {/each} +
diff --git a/src/lib/components/charts/chart.ts b/src/lib/components/charts/chart.ts index fe12d68..69adf6d 100644 --- a/src/lib/components/charts/chart.ts +++ b/src/lib/components/charts/chart.ts @@ -39,6 +39,18 @@ export interface StackRow { values: Record; } +export interface Lane { + axisFormat?: (value: number) => string; + dots?: Record; + format: (value: number) => string; + height?: number; + key: string; + keys: StackKey[]; + label: string; + rows: StackRow[]; + typical?: number | null; +} + export interface MeterSegment { color?: string; label?: string; diff --git a/src/lib/components/dashboard/InsightBox.svelte b/src/lib/components/dashboard/InsightBox.svelte deleted file mode 100644 index 58a29c6..0000000 --- a/src/lib/components/dashboard/InsightBox.svelte +++ /dev/null @@ -1,146 +0,0 @@ - - -{#snippet chart( - group: (typeof groups)[number], - height: number -)} -
- {#if visible.length > 1} - {verb} - in {group.code} - {/if} - -
-{/snippet} - - -
- {#if insight.isPending} -
- {:else if insight.isError} -

{insight.error.message}

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

- {kind === "expense" - ? "Nothing spent in this period." - : "Nothing earned in this period."} -

- {:else} - {#each [wide, narrow] as set, i (i)} - {#if set.length > 0} -
g.bars.length))} - > - {#each set as group (group.code)} - {@render chart(group, i === 0 ? 240 : 180)} - {/each} -
- {/if} - {/each} - {/if} -
- {#snippet footer()} - See your {kind} accounts - {/snippet} -
diff --git a/src/lib/components/reports/Board.svelte b/src/lib/components/reports/Board.svelte new file mode 100644 index 0000000..9e042a9 --- /dev/null +++ b/src/lib/components/reports/Board.svelte @@ -0,0 +1,455 @@ + + + + {#snippet actions()} + + {/snippet} + + +{#if ledger.error} +

+ Firefly III did not return the transactions: {ledger.error.message} +

+{/if} + +
+ + + + {#snippet tools()} + {#if pcOk} + + {/if} + {/snippet} + openMonth(month, segment)} + partial={board?.partial} + pinned={view === "month"} + title={(row) => longMonth(row.key)} + bind:hover + /> + + +
+ {#each [ + { key: "grew", label: "What grew" }, + { key: "month", label: longMonth(cursor) }, + ] as item (item.key)} + + {/each} +
+ + + drillFor(cursor, "grew", extra)} + partial={cursor === board?.partial} + rows={grewRows} + /> + drillFor(cursor, "month", extra)} + partial={cursor === board?.partial} + swaps={swaps.filter((swap) => monthOf(swap.date) === cursor)} + totals={view === "year"} + /> + + + {#if view === "month"} + + {:else if board} + + + {/if} + + {#if tables} + + {/if} + + +
diff --git a/src/lib/components/reports/BoardTables.svelte b/src/lib/components/reports/BoardTables.svelte new file mode 100644 index 0000000..728ea27 --- /dev/null +++ b/src/lib/components/reports/BoardTables.svelte @@ -0,0 +1,145 @@ + + +{#snippet matrix( + id: string, + title: string, + dim: "budget" | "category" | "tag", + dir: 1 | -1, + list: typeof expenses +)} + + {#snippet tools()} + + {/snippet} + month(f.date))} + side={dir === 1 ? "in" : "out"} + /> + +{/snippet} + +
+ {#if view === "year"} + {@render matrix("budget-periods", "Budgets", "budget", -1, withdrawals)} + {@render matrix( + "category-expenses", + "Categories, spent", + "category", + -1, + withdrawals + )} + {@render matrix( + "category-income", + "Categories, earned", + "category", + 1, + deposits + )} + {@render matrix("tag-periods", "Tags, spent", "tag", -1, expenses)} + {:else} + + + + + + + + + + + + + + + + {/if} + +
diff --git a/src/lib/components/reports/CurrencySwitch.svelte b/src/lib/components/reports/CurrencySwitch.svelte new file mode 100644 index 0000000..dd2132b --- /dev/null +++ b/src/lib/components/reports/CurrencySwitch.svelte @@ -0,0 +1,26 @@ + + +{#if report.available.length > 1} +
+ {#each report.available as mode (mode)} + + {/each} +
+{/if} diff --git a/src/lib/components/reports/CurrencyTable.svelte b/src/lib/components/reports/CurrencyTable.svelte new file mode 100644 index 0000000..bb583a7 --- /dev/null +++ b/src/lib/components/reports/CurrencyTable.svelte @@ -0,0 +1,199 @@ + + + +
+ + + + + {#each board.months as month (month)} + + {/each} + + + + + + {#each rows as row, i (`${row.code}-${row.kind}`)} + {@const first = i === 0 || rows[i - 1].code !== row.code} + + + {#each board.months as month (month)} + {@const value = row.byMonth?.get(month) ?? 0} + {@const active = + report.drill?.box === "currencies" && + report.drill.at === month && + report.drill.currency === row.code && + report.drill.dir === (row.kind === "spent" ? -1 : 1)} + + {/each} + + + + {/each} + +
+ {shortMonth(month)} + + Typical + Total
+ {first ? row.code : ""} + {LABELS[row.kind]} + + {#if value && row.kind !== "moved"} + + {:else if value} + {cell(row.kind, value)} + {/if} + + {row.typical === null ? "" : formatNumber(row.typical, 0)} + + {cell(row.kind, row.total)} +
+
+
diff --git a/src/lib/components/reports/Headline.svelte b/src/lib/components/reports/Headline.svelte new file mode 100644 index 0000000..a659130 --- /dev/null +++ b/src/lib/components/reports/Headline.svelte @@ -0,0 +1,128 @@ + + + + {#if !board} +

Loading

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

No spending or income in {period}.

+ {:else} +
+ + + + + + {#if showPace && !month} + + {/if} + {#if showPace} + + {/if} + + + + + + {#each rows as row (row.code)} + {@const pace = row.pace} + {@const paceRatio = pace ? versus(pace.sofar, pace.typical) : null} + + + + {#if showPace && !month} + + {/if} + {#if showPace} + + {/if} + + + + {/each} + +
Spent in {period} + {shortMonth(board.partial ?? "")} + so far + {byDay}Typical monthEarned in {period}
+ {row.code} + + {money( + row.code, + month && pace ? pace.sofar : row.spent, + false, + 0 + )} + {#if month} + {@const ratio = pace + ? paceRatio + : versus(row.spent, row.typical)} + {versusText(ratio)} + {/if} + + {#if pace} + {money(row.code, pace.sofar, false, 0)} + {versusText(paceRatio)} + {/if} + + {#if pace} + {money(row.code, pace.typical, false, 0)} + {/if} + + {money(row.code, row.typical, false, 0)} + + {#if row.earned} + {money(row.code, row.earned, false, 0)} + {/if} +
+
+ {/if} +
diff --git a/src/lib/components/reports/MoneyFlow.svelte b/src/lib/components/reports/MoneyFlow.svelte index f609ffd..fdd163c 100644 --- a/src/lib/components/reports/MoneyFlow.svelte +++ b/src/lib/components/reports/MoneyFlow.svelte @@ -8,8 +8,11 @@ navigate, useReport, } from "#lib/reports/report.svelte.js"; + import CurrencySwitch from "./CurrencySwitch.svelte"; import ReportBox from "./ReportBox.svelte"; + let { switcher = false }: { switcher?: boolean } = $props(); + const LEFT_MAX = 5; const RIGHT_MAX = 9; const SLOT = 34; @@ -87,6 +90,7 @@ function swaps(dir: 1 | -1): Node[] { return net .filter(([, value]) => Math.sign(value) === dir) + .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) .map(([code, value]) => ({ color: "var(--ff-transfer)", dir, @@ -97,34 +101,56 @@ })); } + function cover(nodes: Node[], gap: number): [Node[], number] { + let rest = gap; + const used: Node[] = []; + for (const node of nodes) { + const value = Math.min(node.value, rest); + if (value >= CENT) { + used.push({ ...node, value }); + rest -= value; + } + } + return [used, rest]; + } + const model = $derived.by(() => { const flows = report.flows ?? []; const left = side(flows, 1, LEFT_MAX); const right = side(flows, -1, RIGHT_MAX); - if (mode !== "pc") { - left.push(...swaps(1)); - right.push(...swaps(-1)); - } const totalIn = left.reduce((sum, n) => sum + n.value, 0); const totalOut = right.reduce((sum, n) => sum + n.value, 0); - if (totalIn - totalOut >= CENT) { - right.push({ - color: "var(--ff-series-1)", - dir: 1, - drillable: false, - key: "kept", - label: "Left over", - value: totalIn - totalOut, - }); - } else if (totalOut - totalIn >= CENT) { - left.push({ - color: "var(--ff-warn)", - dir: -1, - drillable: false, - key: "deficit", - label: "From balance", - value: totalOut - totalIn, - }); + const exchange = mode !== "pc"; + if (totalOut - totalIn >= CENT) { + const [used, rest] = exchange + ? cover(swaps(1), totalOut - totalIn) + : [[], totalOut - totalIn]; + left.push(...used); + if (rest >= CENT) { + left.push({ + color: "var(--ff-warn)", + dir: -1, + drillable: false, + key: "deficit", + label: "From balance", + value: rest, + }); + } + } else if (totalIn - totalOut >= CENT) { + const [used, rest] = exchange + ? cover(swaps(-1), totalIn - totalOut) + : [[], totalIn - totalOut]; + right.push(...used); + if (rest >= CENT) { + right.push({ + color: "var(--ff-series-1)", + dir: 1, + drillable: false, + key: "kept", + label: "Left over", + value: rest, + }); + } } return { left, right, total: Math.max(totalIn, totalOut) }; }); @@ -205,6 +231,11 @@ loading={!report.flows} title="Where the money went ({modeLabel(mode)})" > + {#snippet tools()} + {#if switcher} + + {/if} + {/snippet}
{#if width > 0 && narrow}
diff --git a/src/lib/components/reports/MonthPanel.svelte b/src/lib/components/reports/MonthPanel.svelte new file mode 100644 index 0000000..b371b7e --- /dev/null +++ b/src/lib/components/reports/MonthPanel.svelte @@ -0,0 +1,195 @@ + + + +
+ {#each shown as fact (fact.code)} +
+

+ {fact.code} + {#if totals} + {money(fact.code, fact.spent, false, 0)} + spent{partial ? " so far" : ""} + {#if fact.ratio !== null} + {versusText(fact.ratio)} + {/if} + {#if fact.earned > 0} + {money(fact.code, fact.earned, false, 0)} + earned + {/if} + {/if} +

+
    + {#each fact.places as place (place.id)} +
  • + +
  • + {/each} + {#if fact.day} + {@const day = fact.day} +
  • + +
  • + {/if} +
+
+ {/each} + {#if moved.length > 0} +

+ Exchanged + {#each moved as pair (`${pair.from}${pair.to}`)} + {money(pair.from, pair.a, false, 0)} + into {money(pair.to, pair.b, false, 0)} + {/each} +

+ {/if} +
+
diff --git a/src/lib/components/reports/NetWorthLanes.svelte b/src/lib/components/reports/NetWorthLanes.svelte new file mode 100644 index 0000000..6771b22 --- /dev/null +++ b/src/lib/components/reports/NetWorthLanes.svelte @@ -0,0 +1,150 @@ + + + 0} + title="Net worth" +> + + row.key === monthOf(now) ? "today" : `end of ${longMonth(row.key)}`} + /> + {#each small as m (m.code)} +

+ {m.code} + {money(m.code, m.last, false, 0)} +

+ {/each} +
diff --git a/src/lib/components/reports/WhatGrew.svelte b/src/lib/components/reports/WhatGrew.svelte new file mode 100644 index 0000000..fbe85ae --- /dev/null +++ b/src/lib/components/reports/WhatGrew.svelte @@ -0,0 +1,156 @@ + + + +
+ + + + + + + + + + + + {#each shown as row (row.key)} + {@const active = + report.drill?.box === "grew" && + report.drill.key === row.cat && + report.drill.currency === row.code} + + + + + + + + {/each} + +
Category + This year + Total + {shortMonth(cursor)}{partial ? " so far" : ""} + Against typical
+ + + + + {money(row.code, row.year, false, 0)} + + {money(row.code, row.month, false, 0)} + + {#if row.ratio !== null} + {versusText(row.ratio, "")} + {:else if row.month > 0} + new + {/if} +
+
+ {#if rows.length > SHOWN} + + {/if} +
diff --git a/src/lib/reports/board.ts b/src/lib/reports/board.ts new file mode 100644 index 0000000..058b4c3 --- /dev/null +++ b/src/lib/reports/board.ts @@ -0,0 +1,403 @@ +import { + type Lane, + OTHER_COLOR, + type StackKey, + seriesColor, +} from "#lib/components/charts/chart.js"; +import { addMonths, periodOf, type Range, today } from "#lib/dates.js"; +import { formatDate } from "#lib/format.js"; +import { session } from "#lib/session.svelte.js"; +import { buckets, type Flow, keys, NONE } from "./flows.js"; +import { money } from "./report.svelte.js"; +import { + type Pace, + pace, + series, + typical, + typicalMonths, + typicalToDay, + versus, +} from "./typical.js"; +import { modeAxis } from "./view.js"; + +export const OTHER = "other"; +const NO_CATEGORY_COLOR = "var(--cladd-fg-softer)"; +const MAX_CATEGORIES = 6; +const TOP_PLACES = 3; + +export const monthOf = (date: string) => date.slice(0, 7); + +export const shiftMonth = (month: string, delta: number) => + monthOf(addMonths(`${month}-01`, delta)); + +export const monthRange = (month: string): Range => + periodOf(`${month}-01`, "1M"); + +export function cursorOf(range: Range, now = today()): string { + const start = monthOf(range.start); + if (start === monthOf(range.end)) { + return start; + } + if (range.start <= now && now <= range.end) { + return monthOf(now); + } + return monthOf(range.end) > monthOf(now) ? monthOf(now) : monthOf(range.end); +} + +export const shortMonth = (month: string) => + new Intl.DateTimeFormat(session.locale, { month: "short" }).format( + new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1, 1) + ); + +export const longMonth = (month: string) => formatDate(`${month}-01`, "month"); + +const ordinals = new Intl.PluralRules("en", { type: "ordinal" }); +const SUFFIX: Record = { + few: "rd", + one: "st", + other: "th", + two: "nd", +}; + +export const ordinal = (n: number) => `${n}${SUFFIX[ordinals.select(n)]}`; + +const key = (code: string, part: string) => `${code} ${part}`; + +export interface Board { + categories: Map>; + catKeys: StackKey[]; + catLabel: Map; + codes: string[]; + earned: Map>; + months: string[]; + now: string; + partial: string | null; + primary: string; + spending: Flow[]; + spent: Map>; + stackOf: (flow: Flow) => string; + stacks: Map>; + typical: Map; + typicalMonths: string[]; +} + +function rankCategories(spending: Flow[], months: Set) { + const totals = new Map(); + const byCat = new Map(); + const labels = new Map([ + [NONE, "(no category)"], + [OTHER, "Other"], + ]); + for (const flow of spending) { + if (!months.has(monthOf(flow.date))) { + continue; + } + const cat = keys.category(flow); + labels.set(cat.key, cat.label); + totals.set(flow.currency, (totals.get(flow.currency) ?? 0) + flow.amount); + const k = key(flow.currency, cat.key); + byCat.set(k, (byCat.get(k) ?? 0) + flow.amount); + } + const score = new Map(); + for (const [k, value] of byCat) { + const [code, cat] = [ + k.slice(0, k.indexOf(" ")), + k.slice(k.indexOf(" ") + 1), + ]; + score.set(cat, (score.get(cat) ?? 0) + value / (totals.get(code) || 1)); + } + const ranked = [...score.entries()] + .filter(([cat]) => cat !== NONE) + .sort((a, b) => b[1] - a[1]) + .map(([cat]) => cat); + const top = new Set(ranked.slice(0, MAX_CATEGORIES)); + const catKeys: StackKey[] = [...top].map((cat, i) => ({ + color: seriesColor(i), + key: cat, + label: labels.get(cat) ?? cat, + })); + if (score.has(NONE)) { + catKeys.push({ + color: NO_CATEGORY_COLOR, + key: NONE, + label: labels.get(NONE) ?? "", + }); + } + if (ranked.length > top.size) { + catKeys.push({ color: OTHER_COLOR, key: OTHER, label: "Other" }); + } + const stackOf = (flow: Flow) => { + const cat = flow.category?.id ?? NONE; + return cat === NONE || top.has(cat) ? cat : OTHER; + }; + return { catKeys, labels, stackOf }; +} + +export function buildBoard( + flows: Flow[], + windowMonths: string[], + year: string, + order: string[], + now = today() +): Board { + const months = buckets(`${year}-01-01`, `${year}-12-31`, "month"); + const closed = year < now.slice(0, 4) ? `${Number(year) + 1}-01-01` : now; + const first = flows.reduce( + (min, flow) => (flow.date < min ? flow.date : min), + "9999" + ); + const typMonths = typicalMonths(windowMonths, closed, first); + const spending = flows.filter((flow) => flow.dir === -1); + const spent = series(spending, (flow) => flow.currency); + const earned = series( + flows.filter((flow) => flow.dir === 1), + (flow) => flow.currency + ); + const shown = new Set([...months, ...typMonths]); + const { catKeys, labels, stackOf } = rankCategories( + spending, + new Set(months) + ); + const counts = new Map(); + for (const flow of flows) { + if (shown.has(monthOf(flow.date))) { + counts.set(flow.currency, (counts.get(flow.currency) ?? 0) + 1); + } + } + const primary = order[0] ?? ""; + const rank = (code: string) => (code === primary ? 0 : 1); + const codes = [...counts.keys()].sort( + (a, b) => rank(a) - rank(b) || (counts.get(b) ?? 0) - (counts.get(a) ?? 0) + ); + return { + categories: series(spending, (flow) => + key(flow.currency, keys.category(flow).key) + ), + catKeys, + catLabel: labels, + codes, + earned, + months, + now, + partial: months.includes(monthOf(now)) ? monthOf(now) : null, + primary, + spending, + spent, + stackOf, + stacks: series(spending, (flow) => key(flow.currency, stackOf(flow))), + typical: new Map( + codes.map((code) => [code, typical(spent.get(code), typMonths)]) + ), + typicalMonths: typMonths, + }; +} + +const QUIET_HEIGHT = 56; + +export function spendingLanes(board: Board): Lane[] { + return board.codes.map((code) => ({ + axisFormat: modeAxis(code), + dots: Object.fromEntries( + board.months.map((month) => [ + month, + board.earned.get(code)?.get(month) ?? 0, + ]) + ), + format: (value: number) => money(code, value, false, 0), + height: board.typical.get(code) ? undefined : QUIET_HEIGHT, + key: code, + keys: board.catKeys, + label: code, + rows: board.months.map((month) => ({ + key: month, + label: shortMonth(month), + values: Object.fromEntries( + board.catKeys.map((k) => [ + k.key, + board.stacks.get(key(code, k.key))?.get(month) ?? 0, + ]) + ), + })), + typical: board.typical.get(code) ?? null, + })); +} + +export function mergedLane(board: Board): Lane { + const code = board.primary; + const pc = (flow: Flow) => flow.pc ?? 0; + const stacks = series(board.spending, board.stackOf, pc); + const spent = series(board.spending, () => code, pc).get(code); + return { + axisFormat: modeAxis(code), + format: (value: number) => money(code, value, false, 0), + key: "pc", + keys: board.catKeys, + label: `≈${code}`, + rows: board.months.map((month) => ({ + key: month, + label: shortMonth(month), + values: Object.fromEntries( + board.catKeys.map((k) => [k.key, stacks.get(k.key)?.get(month) ?? 0]) + ), + })), + typical: typical(spent, board.typicalMonths), + }; +} + +const sumOf = (byMonth: Map | undefined, months: string[]) => + months.reduce((sum, month) => sum + (byMonth?.get(month) ?? 0), 0); + +export interface Headline { + code: string; + earned: number; + pace: Pace | null; + spent: number; + typical: number; +} + +export function headlines(board: Board, months = board.months): Headline[] { + return board.codes.map((code) => ({ + code, + earned: sumOf(board.earned.get(code), months), + pace: + board.partial && months.includes(board.partial) + ? pace( + board.spending.filter((flow) => flow.currency === code), + board.typicalMonths, + board.now + ) + : null, + spent: sumOf(board.spent.get(code), months), + typical: board.typical.get(code) ?? 0, + })); +} + +export interface Grew { + cat: string; + code: string; + impact: number; + key: string; + label: string; + month: number; + ratio: number | null; + typical: number; + values: number[]; + year: number; +} + +export function grew(board: Board, month: string): Grew[] { + const partial = month === board.partial; + const day = Number(board.now.slice(8, 10)); + const lastMonth = board.partial ?? board.months.at(-1) ?? month; + const months = board.months.filter((m) => m <= lastMonth); + const byKey = new Map(); + if (partial) { + for (const flow of board.spending) { + const k = key(flow.currency, keys.category(flow).key); + const list = byKey.get(k) ?? []; + list.push(flow); + byKey.set(k, list); + } + } + const rows: Grew[] = []; + for (const [k, byMonth] of board.categories) { + const split = k.indexOf(" "); + const code = k.slice(0, split); + const cat = k.slice(split + 1); + const base = board.typical.get(code) || 1; + const now = partial + ? (byKey.get(k) ?? []) + .filter((f) => monthOf(f.date) === month && f.date <= board.now) + .reduce((sum, f) => sum + f.amount, 0) + : (byMonth.get(month) ?? 0); + const typ = partial + ? typicalToDay(byKey.get(k) ?? [], board.typicalMonths, day) + : typical(byMonth, board.typicalMonths); + const year = sumOf(byMonth, board.months); + if (now === 0 && typ === 0 && year === 0) { + continue; + } + rows.push({ + cat, + code, + impact: (now - typ) / base, + key: k, + label: board.catLabel.get(cat) ?? cat, + month: now, + ratio: versus(now, typ), + typical: typ, + values: months.map((m) => byMonth.get(m) ?? 0), + year, + }); + } + return rows.sort((a, b) => b.impact - a.impact); +} + +export interface Place { + count: number; + id: string; + name: string; + value: number; +} + +export interface MonthFacts { + code: string; + day: { date: string; value: number; where: string; count: number } | null; + earned: number; + places: Place[]; + ratio: number | null; + spent: number; +} + +export function monthFacts(board: Board, month: string): MonthFacts[] { + const partial = month === board.partial; + return board.codes.map((code) => { + const flows = board.spending.filter( + (flow) => flow.currency === code && monthOf(flow.date) === month + ); + const places = new Map(); + const days = new Map(); + for (const flow of flows) { + const place = places.get(flow.other.id) ?? { + count: 0, + id: flow.other.id, + name: flow.other.name, + value: 0, + }; + place.count += 1; + place.value += flow.amount; + places.set(flow.other.id, place); + const list = days.get(flow.date) ?? []; + list.push(flow); + days.set(flow.date, list); + } + let day: MonthFacts["day"] = null; + for (const [date, list] of days) { + const value = list.reduce((sum, f) => sum + f.amount, 0); + if (!day || value > day.value) { + const top = list.reduce((a, b) => (b.amount > a.amount ? b : a)); + day = { count: list.length, date, value, where: top.other.name }; + } + } + const spent = board.spent.get(code)?.get(month) ?? 0; + const p = partial + ? pace( + board.spending.filter((flow) => flow.currency === code), + board.typicalMonths, + board.now + ) + : null; + return { + code, + day, + earned: board.earned.get(code)?.get(month) ?? 0, + places: [...places.values()] + .sort((a, b) => b.value - a.value) + .slice(0, TOP_PLACES), + ratio: p + ? versus(p.sofar, p.typical) + : versus(spent, board.typical.get(code) ?? 0), + spent, + }; + }); +} diff --git a/src/lib/reports/ledger.ts b/src/lib/reports/ledger.ts new file mode 100644 index 0000000..c384597 --- /dev/null +++ b/src/lib/reports/ledger.ts @@ -0,0 +1,50 @@ +import type { Account, Item, TransactionGroup } from "#lib/api/types.js"; +import { addDays, addMonths, today } from "#lib/dates.js"; +import { buckets, type Flow, toFlows } from "./flows.js"; +import { pages } from "./queries.js"; + +export const LEDGER_MONTHS = 13; + +export interface LedgerWindow { + end: string; + months: string[]; + start: string; +} + +export function ledgerWindow(anchor = today()): LedgerWindow { + const month = `${anchor.slice(0, 7)}-01`; + const start = addMonths(month, 1 - LEDGER_MONTHS); + const end = addDays(addMonths(month, 1), -1); + return { end, months: buckets(start, end, "month"), start }; +} + +export function ledgerQuery(anchor = today()) { + const { start, end } = ledgerWindow(anchor); + return { + queryFn: () => + pages("/v1/transactions", { + end, + start, + type: "default", + }), + queryKey: ["ledger", start, end], + }; +} + +export function ownFlows( + groups: Item[], + accounts: Item[] +): Flow[] { + const own = accounts.filter( + (a) => + a.attributes.type === "asset" || + a.attributes.type === "liabilities" || + a.attributes.type === "liability" + ); + const currency = new Map(own.map((a) => [a.id, a.attributes.currency_code])); + return toFlows( + groups, + new Set(own.map((a) => a.id)), + (id) => currency.get(id) ?? undefined + ).filter((flow) => flow.type === "withdrawal" || flow.type === "deposit"); +} diff --git a/src/lib/reports/rates.ts b/src/lib/reports/rates.ts new file mode 100644 index 0000000..e517a5d --- /dev/null +++ b/src/lib/reports/rates.ts @@ -0,0 +1,48 @@ +import type { Item, TransactionGroup } from "#lib/api/types.js"; + +export type Rate = (from: string, to: string) => number | null; + +type Pairs = Map>; + +function pairs(groups: Item[]): Pairs { + const sums: Pairs = new Map(); + const add = (a: string, b: string, x: number, y: number) => { + const row = sums.get(a) ?? new Map(); + const pair = row.get(b) ?? [0, 0]; + row.set(b, [pair[0] + x, pair[1] + y]); + sums.set(a, row); + }; + for (const group of groups) { + for (const split of group.attributes.transactions) { + const from = split.currency_code; + const to = split.foreign_currency_code; + const x = Math.abs(Number(split.amount)); + const y = Math.abs(Number(split.foreign_amount)); + if (from && to && from !== to && x > 0 && y > 0) { + add(from, to, x, y); + add(to, from, y, x); + } + } + } + return sums; +} + +function walk(sums: Pairs, from: string, to: string): number | null { + const rates = new Map([[from, 1]]); + const queue = [from]; + for (const code of queue) { + const rate = rates.get(code) ?? 1; + for (const [other, [x, y]] of sums.get(code) ?? []) { + if (!rates.has(other)) { + rates.set(other, (rate * y) / x); + queue.push(other); + } + } + } + return rates.get(to) ?? null; +} + +export function impliedRates(groups: Item[]): Rate { + const sums = pairs(groups); + return (from, to) => walk(sums, from, to); +} diff --git a/src/lib/reports/report.svelte.ts b/src/lib/reports/report.svelte.ts index 0687234..264a640 100644 --- a/src/lib/reports/report.svelte.ts +++ b/src/lib/reports/report.svelte.ts @@ -1,7 +1,7 @@ import { createQuery } from "@tanstack/svelte-query"; import { getContext, setContext } from "svelte"; import { lists } from "#lib/api/lists.js"; -import type { Account, Item } from "#lib/api/types.js"; +import type { Account, Item, TransactionGroup } from "#lib/api/types.js"; import { formatMoney } from "#lib/format.js"; import { session } from "#lib/session.svelte.js"; import { goto } from "$app/navigation"; @@ -136,6 +136,7 @@ export interface Drill { asset?: string; at?: string; box: string; + currency?: string; dim?: Dimension | "day" | "flow"; dir?: 1 | -1; key?: string; @@ -172,6 +173,7 @@ function readDrill(url: { asset: url.searchParams.get("acc") ?? undefined, at: url.searchParams.get("at") ?? undefined, box, + currency: url.searchParams.get("dc") ?? undefined, dim: DRILL_DIMS.has(dim) ? (dim as Drill["dim"]) : undefined, dir: DIRS[dir ?? ""], key: split > 0 ? raw.slice(split + 1) : undefined, @@ -181,12 +183,21 @@ function readDrill(url: { export function drillQuery(drill: Drill | null): Record { if (!drill) { - return { acc: null, at: null, box: null, dir: null, dl: null, drill: null }; + return { + acc: null, + at: null, + box: null, + dc: null, + dir: null, + dl: null, + drill: null, + }; } return { acc: drill.asset ?? null, at: drill.at ?? null, box: drill.box, + dc: drill.currency ?? null, dir: drill.dir ? DIR_NAMES[drill.dir] : null, dl: drill.label || null, drill: @@ -208,6 +219,9 @@ export function drillFilter( if (drill.asset && flow.asset.id !== drill.asset) { return false; } + if (drill.currency && flow.currency !== drill.currency) { + return false; + } if (!drill.dim || drill.key === undefined) { return true; } @@ -246,7 +260,15 @@ function twins( ); } -export function createReport(params: () => ReportParams) { +const inRange = (group: Item, start: string, end: string) => { + const date = group.attributes.transactions[0]?.date.slice(0, 10) ?? ""; + return date >= start && date <= end; +}; + +export function createReport( + params: () => ReportParams, + source?: () => Item[] | undefined +) { const p = $derived(params()); const kind = $derived(kindOf(p.type)); const assets = createQuery(() => lists.accounts("asset")); @@ -273,8 +295,13 @@ export function createReport(params: () => ReportParams) { })); const tx = createQuery(() => ({ ...reportQueries.transactions(p.start, p.end), - enabled: p.type !== "audit", + enabled: !source && p.type !== "audit", })); + const groups = $derived( + source + ? source()?.filter((group) => inRange(group, p.start, p.end)) + : tx.data + ); const accounts = $derived([ ...(assets.data ?? []), @@ -347,14 +374,14 @@ export function createReport(params: () => ReportParams) { ); const flows = $derived( - tx.data && accountsReady - ? toFlows(tx.data, selectedSet, (id) => byId.get(id)?.currency_code) + groups && accountsReady + ? toFlows(groups, selectedSet, (id) => byId.get(id)?.currency_code) : undefined ); const swaps = $derived( - tx.data && accountsReady - ? exchanges(tx.data, selectedSet, (id) => byId.get(id)?.currency_code) + groups && accountsReady + ? exchanges(groups, selectedSet, (id) => byId.get(id)?.currency_code) : [] ); @@ -451,7 +478,7 @@ export function createReport(params: () => ReportParams) { return grain; }, get groups() { - return tx.data; + return groups; }, itemName: (kindKey: string, id: string) => itemNames.get(`${kindKey}:${id}`), diff --git a/src/lib/reports/typical.ts b/src/lib/reports/typical.ts new file mode 100644 index 0000000..5fbae6d --- /dev/null +++ b/src/lib/reports/typical.ts @@ -0,0 +1,113 @@ +import { today } from "#lib/dates.js"; +import { percent } from "#lib/format.js"; +import type { Flow } from "./flows.js"; + +export const TYPICAL_MONTHS = 12; +const SAME = 0.03; + +export function median(values: number[]): number { + if (values.length === 0) { + return 0; + } + 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; +} + +export function typicalMonths( + months: string[], + now = today(), + since = "" +): string[] { + const current = now.slice(0, 7); + const first = since.slice(0, 7); + return months + .filter((month) => month < current && month >= first) + .slice(-TYPICAL_MONTHS); +} + +export function series( + flows: Iterable, + keyOf: (flow: Flow) => string | null, + measure: (flow: Flow) => number = (flow) => flow.amount +): Map> { + const result = new Map>(); + for (const flow of flows) { + const key = keyOf(flow); + if (key === null) { + continue; + } + let months = result.get(key); + if (!months) { + months = new Map(); + result.set(key, months); + } + const month = flow.date.slice(0, 7); + months.set(month, (months.get(month) ?? 0) + measure(flow)); + } + return result; +} + +export function typical( + byMonth: ReadonlyMap | undefined, + months: string[] +): number { + return median(months.map((month) => byMonth?.get(month) ?? 0)); +} + +export function typicalToDay( + flows: Iterable, + months: string[], + day: number +): number { + const sums = new Map(months.map((month) => [month, 0])); + for (const flow of flows) { + const month = flow.date.slice(0, 7); + const sum = sums.get(month); + if (sum !== undefined && Number(flow.date.slice(8, 10)) <= day) { + sums.set(month, sum + flow.amount); + } + } + return median([...sums.values()]); +} + +export interface Pace { + day: number; + month: string; + sofar: number; + typical: number; +} + +export function pace( + flows: Iterable, + months: string[], + now = today() +): Pace { + const month = now.slice(0, 7); + const day = Number(now.slice(8, 10)); + let sofar = 0; + const past: Flow[] = []; + for (const flow of flows) { + if (flow.date.slice(0, 7) === month) { + sofar += flow.date <= now ? flow.amount : 0; + } else { + past.push(flow); + } + } + return { day, month, sofar, typical: typicalToDay(past, months, day) }; +} + +export function versus(value: number, base: number): number | null { + return base > 0 ? (value - base) / base : null; +} + +export function versusText(ratio: number | null, noun = "typical"): string { + if (ratio === null) { + return ""; + } + if (Math.abs(ratio) < SAME) { + return noun ? `about ${noun}` : "about the same"; + } + const side = ratio > 0 ? "above" : "below"; + return `${percent(Math.abs(ratio))} ${side}${noun ? ` ${noun}` : ""}`; +} diff --git a/src/routes/reports/+page.svelte b/src/routes/reports/+page.svelte index fcb7cff..79cfaf4 100644 --- a/src/routes/reports/+page.svelte +++ b/src/routes/reports/+page.svelte @@ -1,384 +1,5 @@ - - -
-
{ - event.preventDefault(); - submit(); - }} - > - -
- -
- - { - accounts = next; - problem = ""; - }} - {options} - value={params.accounts} - /> -
-
- Date range -
- - to - -
-
- {#each rangePresets() as preset (preset.key)} - - {/each} -
-
-
- Pre-set ranges -
    - {#each years as year (year)} -
  • - - {#each [0, 1, 2, 3] as q (q)} - - {/each} - · - {#each Array.from({ length: 12 }, (_, m) => m) as m (m)} - {#if `${year}-${String(m + 1).padStart(2, "0")}` <= - today().slice(0, 7)} - - {/if} - {/each} -
  • - {/each} -
-
-
-
- - - {#if list} -
- - { - items = { ...items, [list]: next }; - problem = ""; - }} - options={itemOptions} - value={params.items} - /> -
- {:else} -

This report has no extra options

- {/if} -
- - -
- - - {#each prettyKeys("mod+Enter") as part (part)} - {part} - {/each} - - {#if problem} - {problem} - {/if} -
-
-
- - - {#if accountReady && all.length === 0} -

- In order to generate reports, you need to add at least one asset account - to Firefly III. -

- {:else} -
- {#each [ - { label: "Default financial report", type: "default" }, - { label: "Transaction history overview", type: "audit" }, - ] as section (section.type)} -
-

{section.label}

-
    - {#each quick.filter( - (q) => !q.only || q.only === section.type - ) as link (link.label)} -
  • - {link.label} -
  • - {/each} -
-
- {/each} -

- Remember that reports can be bookmarked. -

-
- {/if} -
-
+ diff --git a/src/routes/reports/[type]/[...rest]/+page.svelte b/src/routes/reports/[type]/[...rest]/+page.svelte index 8111c28..550f51f 100644 --- a/src/routes/reports/[type]/[...rest]/+page.svelte +++ b/src/routes/reports/[type]/[...rest]/+page.svelte @@ -28,7 +28,7 @@ Back to the report generatorBack to reports {/if}