diff --git a/src/lib/components/reports/AccountBalances.svelte b/src/lib/components/reports/AccountBalances.svelte
new file mode 100644
index 0000000..bc11a7e
--- /dev/null
+++ b/src/lib/components/reports/AccountBalances.svelte
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+ Name
+
+ Balance at start of period
+
+
+ Balance at end of period
+
+ Difference
+
+
+
+ {#each rows as row (row.id)}
+ {@const active =
+ report.drill?.box === "balances" && report.drill.key === row.id}
+
+
+ open(row)}
+ title="Show transactions"
+ type="button"
+ >
+ {row.name}
+
+
+ {money(row.code, row.start)}
+ {money(row.code, row.end)}
+
+ {money(row.code, row.end - row.start, true)}
+
+
+ {/each}
+
+
+ {#each sums as [code, sum], i (code)}
+
+ Sum ({code})
+ {money(code, sum.start)}
+ {money(code, sum.end)}
+
+ {money(code, sum.end - sum.start, true)}
+
+
+ {/each}
+
+
+
+
diff --git a/src/lib/components/reports/AuditAccount.svelte b/src/lib/components/reports/AuditAccount.svelte
new file mode 100644
index 0000000..af3a2fa
--- /dev/null
+++ b/src/lib/components/reports/AuditAccount.svelte
@@ -0,0 +1,246 @@
+
+
+
+ {#if !rows}
+ Loading
+ {:else if rows.length === 0}
+
+ No activity was recorded on account
+ {account?.name}
+ between {formatDate(p.start, "long")} and {formatDate(p.end, "long")}.
+
+ {:else}
+
+ Account balance of {account?.name} at the end of
+ {formatDate(addDays(p.start, -1), "long")}
+ was:
+ {money(code, before ?? 0)}
+
+
+
+
+
+ {#if show("icon")}
+ Type
+ {/if}
+ {#if show("description")}
+ Description
+ {/if}
+ {#if show("balance_before")}
+ Balance before
+ {/if}
+ {#if show("amount")}
+ Amount
+ {/if}
+ {#if show("balance_after")}
+ Balance after
+ {/if}
+ {#if show("date")}
+ Date
+ {/if}
+ {#if show("from")}
+ From
+ {/if}
+ {#if show("to")}
+ To
+ {/if}
+ {#if show("budget")}
+ Budget
+ {/if}
+ {#if show("category")}
+ Category
+ {/if}
+ {#if show("bill")}
+ Subscription
+ {/if}
+ {#if show("created_at")}
+ Created at
+ {/if}
+ {#if show("updated_at")}
+ Updated at
+ {/if}
+ {#if show("notes")}
+ Notes
+ {/if}
+ {#each dateColumns as key (key)}
+
+ {key.replace("_date", "").replace("process", "processing")}
+ date
+
+ {/each}
+
+
+
+ {#each rows as row (row.split.transaction_journal_id)}
+ {@const s = row.split}
+ {@const Icon =
+ ICONS[s.type as keyof typeof ICONS] ?? ArrowLeftRight}
+
+ {#if show("icon")}
+
+
+
+ {/if}
+ {#if show("description")}
+
+
+ {#if row.title}
+ {row.title}
+ ({s.description})
+ {:else}
+ {s.description}
+ {/if}
+
+
+ {/if}
+ {#if show("balance_before")}
+
+ {money(code, row.before)}
+
+ {/if}
+ {#if show("amount")}
+
+ {money(code, row.amount)}
+
+ {/if}
+ {#if show("balance_after")}
+
+ {money(code, row.after)}
+
+ {/if}
+ {#if show("date")}
+
+ {formatDate(s.date, "medium")}
+
+ {/if}
+ {#if show("from")}
+
+ {s.source_name}
+
+ {/if}
+ {#if show("to")}
+
+ {s.destination_name}
+
+ {/if}
+ {#if show("budget")}
+ {s.budget_name ?? ""}
+ {/if}
+ {#if show("category")}
+ {s.category_name ?? ""}
+ {/if}
+ {#if show("bill")}
+ {s.bill_name ?? ""}
+ {/if}
+ {#if show("created_at")}
+
+ {formatDateTime(row.created)}
+
+ {/if}
+ {#if show("updated_at")}
+
+ {formatDateTime(row.updated)}
+
+ {/if}
+ {#if show("notes")}
+
+ {s.notes ?? ""}
+
+ {/if}
+ {#each dateColumns as key (key)}
+
+ {formatDate(s[key] ?? null, "medium")}
+
+ {/each}
+
+ {/each}
+
+
+
+
+ Account balance of {account?.name} at the end of
+ {formatDate(p.end, "long")}
+ was:
+ {money(code, after ?? 0)}
+
+ {/if}
+
diff --git a/src/lib/components/reports/AuditReport.svelte b/src/lib/components/reports/AuditReport.svelte
new file mode 100644
index 0000000..30e3f9f
--- /dev/null
+++ b/src/lib/components/reports/AuditReport.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+
+ Visible columns
+ {#each AUDIT_COLUMNS as column (column.key)}
+
+
+ toggle(column.key, event.currentTarget.checked)}
+ type="checkbox"
+ >
+ {column.label}
+
+ {/each}
+
+
+ {#if !report.accountsReady}
+
Loading
+ {/if}
+ {#each accounts as item (item.id)}
+
+ {/each}
+
diff --git a/src/lib/components/reports/BalanceLines.svelte b/src/lib/components/reports/BalanceLines.svelte
new file mode 100644
index 0000000..7feb092
--- /dev/null
+++ b/src/lib/components/reports/BalanceLines.svelte
@@ -0,0 +1,96 @@
+
+
+ 0}
+ title={net ? "Net worth" : "Account balances"}
+>
+
+ {#each charts as [code, series] (code)}
+
+ {#if charts.length > 1 || !net}
+
+ {code}
+
+ {/if}
+ 1 ? 200 : 240}
+ label="{net ? "Net worth" : "Account balances"} in {code}"
+ {marker}
+ {series}
+ {xFormat}
+ />
+
+ {/each}
+
+
diff --git a/src/lib/components/reports/BudgetLimits.svelte b/src/lib/components/reports/BudgetLimits.svelte
new file mode 100644
index 0000000..a06ae83
--- /dev/null
+++ b/src/lib/components/reports/BudgetLimits.svelte
@@ -0,0 +1,226 @@
+
+
+
+
+
+
+
+ Budget
+ Date
+ Budgeted
+ pct.
+ Spent
+ pct.
+ Left
+ Overspent
+
+
+
+ {#each lines as line (line.key)}
+ {@const sum = sumOf(line.code)}
+ {@const left = (line.budgeted ?? 0) - line.spent}
+ {@const active =
+ report.drill?.box === "budgets" &&
+ report.drill.key === line.budgetId}
+
+
+ open(line)}
+ title="Show transactions"
+ type="button"
+ >
+ {line.name}
+
+
+
+ {#if line.budgeted !== null}
+ {formatRange(line.start, line.end)}
+ {/if}
+
+
+ {#if line.budgeted !== null}
+ {money(line.code, line.budgeted)}
+ {/if}
+
+
+ {#if line.budgeted && sum?.budgeted}
+ {percent(line.budgeted / sum.budgeted)}
+ {/if}
+
+
+ {line.spent ? money(line.code, -line.spent) : ""}
+
+
+ {#if line.spent && sum?.spent}
+ {percent(line.spent / sum.spent)}
+ {/if}
+
+
+ {#if line.budgeted !== null && left > 0}
+ {money(line.code, left)}
+ {/if}
+
+
+ {#if line.budgeted !== null && left < 0}
+ {money(line.code, -left)}
+ {/if}
+
+
+ {/each}
+
+
+ {#each sums as [code, sum], i (code)}
+
+ Sum ({code})
+
+ {money(code, sum.budgeted)}
+
+
+ {money(code, -sum.spent)}
+
+
+
+
+ {/each}
+
+
+
+
diff --git a/src/lib/components/reports/DefaultReport.svelte b/src/lib/components/reports/DefaultReport.svelte
new file mode 100644
index 0000000..37cc344
--- /dev/null
+++ b/src/lib/components/reports/DefaultReport.svelte
@@ -0,0 +1,264 @@
+
+
+
+ {#if month}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if month}
+
+
+
+
+
+
+
+
+ f.asset.id)}
+ side="out"
+ />
+
+
+
+
+
+
+
+
+
+
+ {:else}
+
+
+
+ {#snippet tools()}
+
+ {/snippet}
+ bucket(f.date))}
+ side="out"
+ />
+
+
+
+ {#snippet tools()}
+
+ {/snippet}
+ bucket(f.date))}
+ side="out"
+ />
+
+
+
+ {#snippet tools()}
+
+ {/snippet}
+ bucket(f.date))}
+ side="in"
+ />
+
+
+
+ {#snippet tools()}
+
+ {/snippet}
+ bucket(f.date))}
+ side="out"
+ />
+
+
+
+ {/if}
+
diff --git a/src/lib/components/reports/Delta.svelte b/src/lib/components/reports/Delta.svelte
new file mode 100644
index 0000000..ee8117f
--- /dev/null
+++ b/src/lib/components/reports/Delta.svelte
@@ -0,0 +1,34 @@
+
+
+{#if ratio !== null && Math.abs(ratio) >= 0.005}
+
+ {#if up}
+
+ {:else}
+
+ {/if}
+ {percent(Math.abs(ratio))}
+
+{/if}
diff --git a/src/lib/components/reports/DeltaToggle.svelte b/src/lib/components/reports/DeltaToggle.svelte
new file mode 100644
index 0000000..d5240db
--- /dev/null
+++ b/src/lib/components/reports/DeltaToggle.svelte
@@ -0,0 +1,20 @@
+
+
+ navigate({ delta: on ? null : box })}
+ title={on ? "Show amounts" : "Show change against the previous period"}
+ type="button"
+>
+ {on ? "Amounts" : "Change"}
+
diff --git a/src/lib/components/reports/DimensionReport.svelte b/src/lib/components/reports/DimensionReport.svelte
new file mode 100644
index 0000000..bd8d196
--- /dev/null
+++ b/src/lib/components/reports/DimensionReport.svelte
@@ -0,0 +1,495 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if type !== "double"}
+
+
+
+ {/if}
+
+
+ {#snippet tools()}
+
+ {/snippet}
+ bucketOf(f.date, report.grain))}
+ side="out"
+ />
+
+
+
+ {#if type === "tag"}
+
+
+
+
+
+
+
+ {/if}
+ {#if type === "budget"}
+
+
+
+ {/if}
+
+
+
+ {#if hasIncome}
+
+
+
+
+
+
+ {/if}
+
+
+ {#if type === "double"}
+
+
+
+
+
+
+
+
+ {:else}
+
+
+
+
+ {#if hasIncome}
+
+
+
+ {/if}
+
+
+
+ {#if hasIncome}
+
+
+
+ {/if}
+
+ {/if}
+
+ {#each items as item (item.key)}
+ {@const subset = flows?.filter((f) => {
+ const found = itemKey(f);
+ return Array.isArray(found)
+ ? found.some((k) => k.key === item.key)
+ : found?.key === item.key;
+ })}
+ {#if !subset || subset.length > 0}
+
+ {/if}
+ {/each}
+
+
+
+
+
+ {#if hasIncome}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
+ {#if hasIncome}
+
+
+
+
+
+
+
+
+ {/if}
+
diff --git a/src/lib/components/reports/DrillPanel.svelte b/src/lib/components/reports/DrillPanel.svelte
new file mode 100644
index 0000000..091f062
--- /dev/null
+++ b/src/lib/components/reports/DrillPanel.svelte
@@ -0,0 +1,135 @@
+
+
+
+
+
+
{title}
+
+ {subset.length}
+ {subset.length === 1 ? "transaction" : "transactions"}
+
+
+
+ {#each [
+ { label: "In", side: "in" as const },
+ { label: "Out", side: "out" as const },
+ ] as column (column.side)}
+ {#if [...totals.cur.values()].some((m) => m[column.side] > 0)}
+
+ {/if}
+ {/each}
+
+
+
+
+
+ {#if showChart && keys.length > 0}
+
+ navigate(drillQuery({ ...drill, at: row.key }))}
+ {rows}
+ />
+
+ {/if}
+ {#if subset.length > 0}
+
+ {:else}
+ No transactions match.
+ {/if}
+
diff --git a/src/lib/components/reports/FlowTable.svelte b/src/lib/components/reports/FlowTable.svelte
new file mode 100644
index 0000000..c9e0916
--- /dev/null
+++ b/src/lib/components/reports/FlowTable.svelte
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+ {#if has("date")}
+ Date
+ {/if}
+ Description
+ {#if has("account")}
+ Account
+ {/if}
+ {#if has("other")}
+ Counterparty
+ {/if}
+ {#if has("category")}
+ Category
+ {/if}
+ {#if has("budget")}
+ Budget
+ {/if}
+ Amount
+
+
+
+ {#each flows as flow (`${flow.journal}:${flow.dir}`)}
+
+ {#if has("date")}
+
+ {formatDate(flow.date, "short")}
+
+ {/if}
+
+ {flow.description}
+
+ {#if has("account")}
+
+ {flow.asset.name}
+
+ {/if}
+ {#if has("other")}
+
+ {flow.other.name}
+
+ {/if}
+ {#if has("category")}
+
+ {flow.category?.name ?? ""}
+
+ {/if}
+ {#if has("budget")}
+
+ {flow.budget?.name ?? ""}
+
+ {/if}
+
+ {money(flow.currency, flow.dir * flow.amount)}
+
+
+ {/each}
+
+
+
diff --git a/src/lib/components/reports/IncomeVsExpenses.svelte b/src/lib/components/reports/IncomeVsExpenses.svelte
new file mode 100644
index 0000000..083ac50
--- /dev/null
+++ b/src/lib/components/reports/IncomeVsExpenses.svelte
@@ -0,0 +1,154 @@
+
+
+
+ {#snippet tools()}
+ {#if before && before.n > 0}
+ Change against {formatRange(previous.start, previous.end)}
+ {/if}
+ {/snippet}
+
+
+
+
+ Currency
+ In
+ Out
+ Difference
+
+
+
+ {#each rows as row (row.code)}
+
+ {row.label}
+
+ open(1)}
+ title="Show money in"
+ type="button"
+ >
+
+ {money(row.code, row.in)}
+
+
+
+ open(-1)}
+ title="Show money out"
+ type="button"
+ >
+
+ {money(row.code, -row.out)}
+
+
+
+ {money(row.code, row.in - row.out, true)}
+
+
+ {/each}
+
+
+
+
diff --git a/src/lib/components/reports/Matrix.svelte b/src/lib/components/reports/Matrix.svelte
new file mode 100644
index 0000000..61bb469
--- /dev/null
+++ b/src/lib/components/reports/Matrix.svelte
@@ -0,0 +1,312 @@
+
+
+
+
+
+
+
+ {nameLabel}
+
+ {#each cols as col (col.key)}
+
+ {col.label}
+
+ {/each}
+ {#if average}
+
+ Average
+
+ {/if}
+ Sum
+
+
+
+ {#each sorted as row (row.key)}
+ {@const rowTotal = amountOf(row.totals, mode, side)}
+
+
+ open(row, null)}
+ title="Show transactions for {row.label}"
+ type="button"
+ >
+ {row.label}
+
+
+ {#each cols as col, i (col.key)}
+ {@const value = amountOf(row.cells.get(col.key), mode, side)}
+ {@const diff = delta ? change(row, i) : null}
+ {@const cell = row.cells.get(col.key)}
+
+ {#if multi && cell}
+ open(row, col)}
+ tabindex="-1"
+ title="{row.label}, {col.title ?? col.label}"
+ type="button"
+ >
+
+
+ {:else if !multi && (value || diff)}
+ open(row, col)}
+ tabindex="-1"
+ title="{row.label}, {col.title ?? col.label}: {money(
+ mode,
+ sign * value
+ )}"
+ type="button"
+ >
+ {#if diff}
+
+ {#if diff.up}
+
+ {:else}
+
+ {/if}
+ {diff.ratio === null
+ ? "new"
+ : percent(Math.abs(diff.ratio))}
+
+ {:else}
+ {short(sign * value)}
+ {/if}
+
+ {/if}
+
+ {/each}
+ {#if average}
+
+ {short((sign * rowTotal) / Math.max(1, cols.length))}
+
+ {/if}
+
+ {#if multi}
+
+ {:else}
+ {short(sign * rowTotal)}
+ {/if}
+
+
+ {/each}
+
+
+
+
+ Sum
+
+ {#each cols as col, i (col.key)}
+
+ {#if multi}
+
+
+
+ {:else if colTotals[i]}
+ open(null, col)}
+ tabindex="-1"
+ type="button"
+ >
+ {short(sign * colTotals[i])}
+
+ {/if}
+
+ {/each}
+ {#if average}
+
+ {short((sign * grand) / Math.max(1, cols.length))}
+
+ {/if}
+
+ {#if multi}
+
+ {:else}
+ {short(sign * grand)}
+ {/if}
+
+
+
+
+
diff --git a/src/lib/components/reports/Money.svelte b/src/lib/components/reports/Money.svelte
new file mode 100644
index 0000000..ca775e0
--- /dev/null
+++ b/src/lib/components/reports/Money.svelte
@@ -0,0 +1,47 @@
+
+
+{#each lines as line (line.code)}
+
+ {money(line.code, side === "out" ? -line.value : line.value)}
+
+{/each}
diff --git a/src/lib/components/reports/MoneyFlow.svelte b/src/lib/components/reports/MoneyFlow.svelte
new file mode 100644
index 0000000..f609ffd
--- /dev/null
+++ b/src/lib/components/reports/MoneyFlow.svelte
@@ -0,0 +1,323 @@
+
+
+
+
+ {#if width > 0 && narrow}
+
+ {#each [
+ { list: model.left, title: "In" },
+ { list: model.right, title: "Out" },
+ ] as column (column.title)}
+
+
+ {column.title}
+
+ {#each column.list as node (node.key)}
+ open(node)}
+ type="button"
+ >
+
+ {node.label}
+ {money(mode, node.value)}
+ {share(node)}
+
+ {/each}
+
+ {/each}
+
+ {:else if width > 0}
+
+
+ {#each left as item (item.node.key)}
+
+
+ {/each}
+ {#each right as item (item.node.key)}
+
+
+ {/each}
+
+
+
{money(mode, model.total)}
+ {#each [
+ ...left.map((item) => ({ ...item, isLeft: true })),
+ ...right.map((item) => ({ ...item, isLeft: false })),
+ ] as item (`${item.isLeft}-${item.node.key}`)}
+ {@const active =
+ report.drill?.box === "flow" &&
+ report.drill.key === item.node.key &&
+ report.drill.dir === item.node.dir}
+
open(item.node)}
+ title={item.node.drillable
+ ? `Show transactions for ${item.node.label}`
+ : undefined}
+ type="button"
+ style:left="{item.isLeft ? 0 : rightX + BAR + 4}px"
+ style:top="{item.center - 16}px"
+ style:width="{labelWidth - 6}px"
+ >
+ {item.node.label}
+ {money(mode, item.node.value)}
+ · {share(item.node)}
+
+ {/each}
+
+ {/if}
+
+
diff --git a/src/lib/components/reports/MultiSelect.svelte b/src/lib/components/reports/MultiSelect.svelte
new file mode 100644
index 0000000..c369731
--- /dev/null
+++ b/src/lib/components/reports/MultiSelect.svelte
@@ -0,0 +1,205 @@
+
+
+ {
+ if (!next) {
+ query = "";
+ active = 0;
+ }
+ }}
+ bind:open
+>
+
+ {summary}
+
+
+
+
+
+
+
+
+ toggle(
+ visible.map((o) => o.id),
+ true
+ )}
+ type="button"
+ >
+ Select all
+
+
+ toggle(
+ visible.map((o) => o.id),
+ false
+ )}
+ type="button"
+ >
+ None
+
+ {chosen.size}
+ of {options.length}
+
+
+
+ {#each groups as [group, items] (group)}
+ {#if group}
+ {@const all = items.every((o) => chosen.has(o.id))}
+
+ toggle(
+ items.map((o) => o.id),
+ !all
+ )}
+ title={all ? `Deselect ${group}` : `Select ${group}`}
+ type="button"
+ >
+ {group}
+
+ {/if}
+ {#each items as option (option.id)}
+ {@const index = visible.indexOf(option)}
+
+
+ toggle([option.id], event.currentTarget.checked)}
+ type="checkbox"
+ >
+ {option.label}
+ {#if option.note}
+ {option.note}
+ {/if}
+
+ {/each}
+ {:else}
+
+ {#if query}
+ Nothing matches
+ {:else}
+ {loading ? "Loading" : "Nothing to choose from"}
+ {/if}
+
+ {/each}
+
+
+
+
diff --git a/src/lib/components/reports/ParamBar.svelte b/src/lib/components/reports/ParamBar.svelte
new file mode 100644
index 0000000..082ec39
--- /dev/null
+++ b/src/lib/components/reports/ParamBar.svelte
@@ -0,0 +1,292 @@
+
+
+
+
+
+ Report type
+ setType(event.currentTarget.value as ReportType)}
+ value={p.type}
+ bind:this={typeSelect}
+ >
+ {#each REPORT_KINDS as kind (kind.type)}
+ {kind.label}
+ {/each}
+
+
+
+ Accounts
+ go({ accounts })}
+ {options}
+ value={accountValue}
+ bind:open={accountsOpen}
+ />
+
+ {#if list}
+
+ {LIST_LABELS[list].many}
+ go({ items })}
+ options={itemOptions}
+ value={report.items}
+ bind:open={itemsOpen}
+ />
+
+ {/if}
+
+
+ {#if report.available.length > 1}
+
+
+ Charts in
+
+
+ {#each report.available as mode (mode)}
+ navigate({ cur: mode })}
+ title="Show charts in {modeLabel(mode)} c"
+ type="button"
+ >
+ {modeLabel(mode)}
+
+ {/each}
+
+
+ {/if}
+
diff --git a/src/lib/components/reports/PeriodChart.svelte b/src/lib/components/reports/PeriodChart.svelte
new file mode 100644
index 0000000..b1dcb92
--- /dev/null
+++ b/src/lib/components/reports/PeriodChart.svelte
@@ -0,0 +1,88 @@
+
+
+
+
+ navigate(
+ drillQuery(
+ active === row.key
+ ? null
+ : {
+ at: row.key,
+ box: id,
+ dim: itemKey === undefined ? undefined : dim,
+ key: itemKey,
+ label: [
+ itemKey === undefined ? "" : title,
+ bucketLabel(row.key, report.grain),
+ ]
+ .filter(Boolean)
+ .join(", "),
+ }
+ )
+ )}
+ {rows}
+ />
+
diff --git a/src/lib/components/reports/ReportBox.svelte b/src/lib/components/reports/ReportBox.svelte
new file mode 100644
index 0000000..8e64586
--- /dev/null
+++ b/src/lib/components/reports/ReportBox.svelte
@@ -0,0 +1,54 @@
+
+
+
+
+
diff --git a/src/lib/components/reports/ReportPage.svelte b/src/lib/components/reports/ReportPage.svelte
new file mode 100644
index 0000000..f9f4a23
--- /dev/null
+++ b/src/lib/components/reports/ReportPage.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+{#if report.error}
+
+ Firefly III did not return the data for this report: {report.error.message}
+
+{/if}
+{#if params.type === "default"}
+
+{:else if params.type === "audit"}
+
+{:else}
+ {#key params.type}
+
+ {/key}
+{/if}
diff --git a/src/lib/components/reports/ReportRow.svelte b/src/lib/components/reports/ReportRow.svelte
new file mode 100644
index 0000000..3e7189a
--- /dev/null
+++ b/src/lib/components/reports/ReportRow.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
+ {@render children()}
+
+{#if drill}
+
+
+
+{/if}
diff --git a/src/lib/components/reports/ShareBars.svelte b/src/lib/components/reports/ShareBars.svelte
new file mode 100644
index 0000000..49be060
--- /dev/null
+++ b/src/lib/components/reports/ShareBars.svelte
@@ -0,0 +1,116 @@
+
+
+{#snippet line(
+ label: string,
+ value: number,
+ fill: string
+)}
+
+ {label}
+ {money(mode, value)}
+ {percent(value / sum)}
+
+
+
+
+{/snippet}
+
+{#if ranked.length === 0}
+
+ Nothing in {mode === "pc" ? "this period" : mode}.
+
+{:else}
+
+ {#each head as item (item.row.key)}
+ {@const active =
+ report.drill?.box === box && report.drill.key === item.row.key}
+
+ open(item.row)}
+ title="Show transactions for {item.row.label}"
+ type="button"
+ >
+ {@render line(item.row.label, item.value, color)}
+
+
+ {/each}
+ {#if rest.length > 0}
+
+ {@render line(
+ `Other (${rest.length})`,
+ restValue,
+ "var(--cladd-fg-softest)"
+ )}
+
+ {/if}
+
+{/if}
diff --git a/src/lib/components/reports/SpendingCalendar.svelte b/src/lib/components/reports/SpendingCalendar.svelte
new file mode 100644
index 0000000..db4cd8a
--- /dev/null
+++ b/src/lib/components/reports/SpendingCalendar.svelte
@@ -0,0 +1,336 @@
+
+
+
+
+ {#if big}
+
+ {#each months as month (month.key)}
+
+ {#if months.length > 1}
+
+ {month.label}
+
+ {/if}
+
+ {#each WEEKDAYS as name (name)}
+
{name}
+ {/each}
+ {#each month.cells as day, i (day ?? `pad-${i}`)}
+ {#if day}
+ {@const value = spending?.get(day)?.value ?? 0}
+ {@const step = value ? level(value) : -1}
+
open(day)}
+ onfocus={() => (cursor = day)}
+ {onkeydown}
+ tabindex={day === focusDay ? 0 : -1}
+ type="button"
+ style:background={step >= 0
+ ? fill(step)
+ : "var(--cladd-surface-next)"}
+ style:color={step >= 3 ? "white" : undefined}
+ >
+ {Number(day.slice(8, 10))}
+ {#if value}
+ {amount(value, width < 520)}
+ {/if}
+
+ {:else}
+
+ {/if}
+ {/each}
+
+
+ {/each}
+
+ {:else}
+
+
+
+
+ {#each WEEKDAYS as name, i (name)}
+
+ {i % 2 === 0 ? name : ""}
+
+ {/each}
+
+ {#each weeks as week, w (w)}
+ {@const firstDay = week.find((d) => d !== null)}
+
+
+ {#if firstDay &&
+ (w === 0 || Number(firstDay.slice(8, 10)) <= 7)}
+ {monthShort.format(parse(firstDay))}
+ {/if}
+
+ {#each week as day, d (d)}
+ {#if day}
+ {@const value = spending?.get(day)?.value ?? 0}
+ {@const step = value ? level(value) : -1}
+
open(day)}
+ onfocus={() => (cursor = day)}
+ {onkeydown}
+ tabindex={day === focusDay ? 0 : -1}
+ title={label(day)}
+ type="button"
+ style:background={step >= 0
+ ? fill(step)
+ : "var(--cladd-surface-next)"}
+ style:height="{cellSize}px"
+ style:width="{cellSize}px"
+ >
+ {:else}
+
+ {/if}
+ {/each}
+
+ {/each}
+
+
+
+
+ Biggest days
+
+
+ {#each top as [day, info] (day)}
+
+ open(day)}
+ type="button"
+ >
+ {formatDate(day, "medium")}
+ {info.n}
+ {info.n === 1 ? "transaction" : "transactions"}
+ {money(mode, -info.value)}
+
+
+ {/each}
+
+
+ {quiet}
+ of {days.length} days without spending
+
+
+
+ {/if}
+
+ Less
+ {#each STEPS as _, i (i)}
+
+ {/each}
+ More
+ {#if thresholds.length > 0}
+ over {amount(thresholds.at(-1) ?? 0, false)} is the darkest
+ {/if}
+
+
+
diff --git a/src/lib/components/reports/Subscriptions.svelte b/src/lib/components/reports/Subscriptions.svelte
new file mode 100644
index 0000000..c6e346e
--- /dev/null
+++ b/src/lib/components/reports/Subscriptions.svelte
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+ Name
+ Minimum amount
+ Maximum amount
+ Expected on
+ Paid
+
+
+
+ {#each rows as row (row.id)}
+ {@const code = row.a.currency_code ?? ""}
+
+
+ {row.a.name}
+
+
+ {money(code, Number(row.a.amount_min ?? 0))}
+
+
+ {money(code, Number(row.a.amount_max ?? 0))}
+
+
+ {row.expected.map((d) => formatDate(d, "short")).join(", ")}
+
+
+ {#each row.paid as paid (paid.transaction_journal_id)}
+
+ {:else}
+ Not charged (yet)
+ {/each}
+
+
+ {/each}
+
+
+
+
diff --git a/src/lib/components/reports/SumTable.svelte b/src/lib/components/reports/SumTable.svelte
new file mode 100644
index 0000000..c013bf1
--- /dev/null
+++ b/src/lib/components/reports/SumTable.svelte
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+ {nameLabel}
+ {#each columns as column (column.kind)}
+
+ {column.label}
+
+ {/each}
+
+
+
+ {#each shown as row (row.key)}
+ {@const active =
+ report.drill?.box === box && report.drill.key === row.key}
+
+
+ drill(row)}
+ title="Show transactions"
+ type="button"
+ >
+ {row.label}
+
+ {#if counts && row.totals.n > 1}
+ {row.totals.n}
+ transactions
+ {/if}
+
+ {#each columns as column (column.kind)}
+
+ {#each lines(row.totals, column.kind) as line (line.code)}
+ {line.text}
+ {/each}
+
+ {/each}
+
+ {/each}
+
+ {#if footer && footer.n > 0}
+
+
+ Sum
+ {#each columns as column (column.kind)}
+
+ {#if !(
+ column.kind.startsWith("pct") || column.kind.startsWith("avg")
+ )}
+ {#each lines(footer, column.kind) as line (line.code)}
+ {line.text}
+ {/each}
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+
+{#if limit && sorted.length > limit}
+ (expanded = !expanded)}
+ type="button"
+ >
+ {expanded ? "Show top" : `Show entire list (${sorted.length})`}
+
+{/if}
diff --git a/src/lib/reports/audit.ts b/src/lib/reports/audit.ts
new file mode 100644
index 0000000..ccf2584
--- /dev/null
+++ b/src/lib/reports/audit.ts
@@ -0,0 +1,96 @@
+import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
+
+export interface LedgerRow {
+ after: number;
+ amount: number;
+ before: number;
+ created: string | null;
+ group: string;
+ split: Split;
+ title: string | null;
+ updated: string | null;
+}
+
+export const AUDIT_COLUMNS = [
+ { key: "icon", label: "Icon" },
+ { key: "description", label: "Description" },
+ { key: "balance_before", label: "Balance before" },
+ { key: "amount", label: "Amount" },
+ { key: "balance_after", label: "Balance after" },
+ { key: "date", label: "Date" },
+ { key: "from", label: "From" },
+ { key: "to", label: "To" },
+ { key: "budget", label: "Budget" },
+ { key: "category", label: "Category" },
+ { key: "bill", label: "Subscription" },
+ { key: "created_at", label: "Created at" },
+ { key: "updated_at", label: "Updated at" },
+ { key: "notes", label: "Notes" },
+ { key: "interest_date", label: "Interest date" },
+ { key: "book_date", label: "Book date" },
+ { key: "process_date", label: "Processing date" },
+ { key: "due_date", label: "Due date" },
+ { key: "payment_date", label: "Payment date" },
+ { key: "invoice_date", label: "Invoice date" },
+] as const;
+
+export type AuditColumn = (typeof AUDIT_COLUMNS)[number]["key"];
+
+export const DEFAULT_AUDIT: AuditColumn[] = [
+ "icon",
+ "description",
+ "balance_before",
+ "amount",
+ "balance_after",
+ "date",
+ "to",
+];
+
+function signed(split: Split, account: string, code: string | undefined) {
+ const foreign =
+ split.foreign_amount !== null &&
+ split.foreign_amount !== undefined &&
+ split.foreign_currency_code === code &&
+ split.currency_code !== code;
+ const value = Number(foreign ? split.foreign_amount : split.amount);
+ return String(split.source_id) === account ? -value : value;
+}
+
+export function ledger(
+ groups: Item[],
+ account: string,
+ code: string | undefined,
+ start: number
+): LedgerRow[] {
+ const rows = groups.flatMap((group) =>
+ group.attributes.transactions
+ .filter(
+ (split) =>
+ String(split.source_id) === account ||
+ String(split.destination_id) === account
+ )
+ .map((split) => ({ group, split }))
+ );
+ rows.sort(
+ (a, b) =>
+ a.split.date.localeCompare(b.split.date) ||
+ Number(a.split.transaction_journal_id) -
+ Number(b.split.transaction_journal_id)
+ );
+ let balance = start;
+ return rows.map(({ group, split }) => {
+ const amount = signed(split, account, code);
+ const before = balance;
+ balance += amount;
+ return {
+ after: balance,
+ amount,
+ before,
+ created: group.attributes.created_at ?? null,
+ group: group.id,
+ split,
+ title: group.attributes.group_title ?? null,
+ updated: group.attributes.updated_at ?? null,
+ };
+ });
+}
diff --git a/src/lib/reports/bills.test.ts b/src/lib/reports/bills.test.ts
new file mode 100644
index 0000000..65e8235
--- /dev/null
+++ b/src/lib/reports/bills.test.ts
@@ -0,0 +1,27 @@
+///
+import { expect, test } from "bun:test";
+import type { Bill } from "#lib/api/types.js";
+import { expectedDates } from "./bills.js";
+
+test("monthly bills land once per month", () => {
+ const bill = {
+ date: "2025-11-03T00:00:00+01:00",
+ repeat_freq: "monthly",
+ skip: 0,
+ } as Bill;
+ expect(
+ expectedDates(bill, { end: "2026-09-30", start: "2026-09-01" })
+ ).toEqual(["2026-09-03"]);
+});
+
+test("skip and end date are honoured", () => {
+ const bill = {
+ date: "2026-01-31",
+ end_date: "2026-06-01",
+ repeat_freq: "monthly",
+ skip: 1,
+ } as Bill;
+ expect(
+ expectedDates(bill, { end: "2026-12-31", start: "2026-01-01" })
+ ).toEqual(["2026-01-31", "2026-03-31", "2026-05-31"]);
+});
diff --git a/src/lib/reports/bills.ts b/src/lib/reports/bills.ts
new file mode 100644
index 0000000..2e32537
--- /dev/null
+++ b/src/lib/reports/bills.ts
@@ -0,0 +1,33 @@
+import type { Bill } from "#lib/api/types.js";
+import { addDays, addMonths, type Range } from "#lib/dates.js";
+
+const STEP: Record string> = {
+ "half-year": (date, n) => addMonths(date, 6 * n),
+ monthly: (date, n) => addMonths(date, n),
+ quarterly: (date, n) => addMonths(date, 3 * n),
+ weekly: (date, n) => addDays(date, 7 * n),
+ yearly: (date, n) => addMonths(date, 12 * n),
+};
+
+const LIMIT = 500;
+
+export function expectedDates(bill: Bill, range: Range): string[] {
+ const first = bill.date?.slice(0, 10);
+ const step = STEP[bill.repeat_freq ?? ""];
+ if (!(first && step)) {
+ return [];
+ }
+ const every = (bill.skip ?? 0) + 1;
+ const stop = bill.end_date?.slice(0, 10);
+ const dates: string[] = [];
+ for (let i = 0; i < LIMIT; i += 1) {
+ const date = step(first, i * every);
+ if (date > range.end || (stop && date > stop)) {
+ break;
+ }
+ if (date >= range.start) {
+ dates.push(date);
+ }
+ }
+ return dates;
+}
diff --git a/src/lib/reports/flows.test.ts b/src/lib/reports/flows.test.ts
new file mode 100644
index 0000000..4a2c480
--- /dev/null
+++ b/src/lib/reports/flows.test.ts
@@ -0,0 +1,186 @@
+///
+import { expect, test } from "bun:test";
+import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
+import { ledger } from "./audit.js";
+import {
+ amountOf,
+ bucketRange,
+ buckets,
+ exchanges,
+ group,
+ keys,
+ matches,
+ rank,
+ toFlows,
+ topFlows,
+ total,
+} from "./flows.js";
+
+let nextId = 1;
+
+function tx(split: Partial): Item {
+ nextId += 1;
+ const id = String(nextId);
+ return {
+ attributes: {
+ transactions: [
+ {
+ amount: "10",
+ currency_code: "UAH",
+ date: "2026-03-05T00:00:00+02:00",
+ description: `tx ${id}`,
+ destination_id: "100",
+ destination_name: "Shop",
+ source_id: "1",
+ source_name: "Mono",
+ tags: [],
+ transaction_journal_id: id,
+ type: "withdrawal",
+ ...split,
+ } as Split,
+ ],
+ } as TransactionGroup,
+ id,
+ type: "transactions",
+ };
+}
+
+const currencyOf = (id: string) => ({ "1": "UAH", "2": "USD", "3": "UAH" })[id];
+
+const groups = [
+ tx({ amount: "100", category_id: "7", category_name: "Food" }),
+ tx({ amount: "50", tags: ["trip", "fun"] }),
+ tx({
+ amount: "4000",
+ destination_id: "2",
+ destination_name: "Wise",
+ foreign_amount: "100",
+ foreign_currency_code: "USD",
+ type: "transfer",
+ }),
+ tx({
+ amount: "300",
+ destination_id: "1",
+ source_id: "200",
+ source_name: "Employer",
+ type: "deposit",
+ }),
+ tx({
+ amount: "20",
+ date: "2026-04-01T00:00:00+02:00",
+ destination_id: "3",
+ type: "transfer",
+ }),
+];
+
+test("only flows that cross the selection count", () => {
+ const flows = toFlows(groups, new Set(["1", "3"]), currencyOf);
+ expect(flows.map((flow) => flow.amount)).toEqual([100, 50, 4000, 300]);
+ const totals = total(flows);
+ expect(amountOf(totals, "UAH", "out")).toBe(4150);
+ expect(amountOf(totals, "UAH", "in")).toBe(300);
+ expect(amountOf(totals, "UAH", "net")).toBe(-3850);
+});
+
+test("transfers into a selected account use the destination currency", () => {
+ const flows = toFlows(groups, new Set(["2"]), currencyOf);
+ expect(flows).toHaveLength(1);
+ expect(flows[0]).toMatchObject({ amount: 100, currency: "USD", dir: 1 });
+});
+
+test("groups by dimension, counting multi-tag journals once per tag", () => {
+ const flows = toFlows(groups, new Set(["1", "3"]), currencyOf);
+ const tags = group(flows, keys.tag);
+ expect(tags.map((row) => [row.label, row.totals.n])).toEqual([
+ ["(no tags)", 3],
+ ["trip", 1],
+ ["fun", 1],
+ ]);
+ const categories = rank(group(flows, keys.category), "UAH", "out");
+ expect(categories[0].label).toBe("(no category)");
+ expect(matches(flows[0], "category", "7")).toBe(true);
+ expect(matches(flows[3], "budget", "0")).toBe(false);
+});
+
+test("pivots into period columns", () => {
+ const flows = toFlows(groups, new Set(["1"]), currencyOf);
+ const rows = group(flows, keys.category, (flow) => flow.date.slice(0, 7));
+ const none = rows.find((row) => row.key === "0");
+ expect([...(none?.cells.keys() ?? [])]).toEqual(["2026-03", "2026-04"]);
+ expect(amountOf(none?.cells.get("2026-04"), "UAH", "out")).toBe(20);
+});
+
+test("buckets cover the range", () => {
+ expect(buckets("2025-11-15", "2026-02-02", "month")).toEqual([
+ "2025-11",
+ "2025-12",
+ "2026-01",
+ "2026-02",
+ ]);
+ expect(buckets("2026-02-27", "2026-03-02", "day")).toEqual([
+ "2026-02-27",
+ "2026-02-28",
+ "2026-03-01",
+ "2026-03-02",
+ ]);
+ expect(buckets("2024-06-01", "2026-01-01", "year")).toEqual([
+ "2024",
+ "2025",
+ "2026",
+ ]);
+ expect(
+ bucketRange("2026-02", "month", { end: "2026-02-20", start: "2026-01-01" })
+ ).toEqual({ end: "2026-02-20", start: "2026-02-01" });
+});
+
+test("top flows stay inside one currency", () => {
+ const flows = toFlows(groups, new Set(["1"]), currencyOf);
+ expect(topFlows(flows, "UAH", -1, 2).map((flow) => flow.amount)).toEqual([
+ 4000, 100,
+ ]);
+ expect(topFlows(flows, "USD", -1, 2)).toHaveLength(0);
+});
+
+test("aggregates a year of transactions quickly", () => {
+ const many = Array.from({ length: 1500 }, (_, i) =>
+ tx({
+ amount: String(i),
+ category_id: String(i % 7),
+ category_name: `c${i % 7}`,
+ date: `2026-${String((i % 12) + 1).padStart(2, "0")}-10T00:00:00+02:00`,
+ tags: [`t${i % 5}`],
+ })
+ );
+ const started = performance.now();
+ const flows = toFlows(many, new Set(["1"]), currencyOf);
+ group(flows, keys.category, (flow) => flow.date.slice(0, 7));
+ group(flows, keys.tag);
+ group(flows, keys.other);
+ expect(performance.now() - started).toBeLessThan(50);
+});
+
+test("currency exchanges between selected accounts are kept apart", () => {
+ const list = exchanges(groups, new Set(["1", "2"]), currencyOf);
+ expect(list).toEqual([
+ {
+ date: "2026-03-05",
+ from: "UAH",
+ fromAmount: 4000,
+ to: "USD",
+ toAmount: 100,
+ },
+ ]);
+});
+
+test("audit ledger runs the balance forward in the account currency", () => {
+ const rows = ledger(groups, "1", "UAH", 1000);
+ expect(rows.map((row) => [row.amount, row.after])).toEqual([
+ [-100, 900],
+ [-50, 850],
+ [-4000, -3150],
+ [300, -2850],
+ [-20, -2870],
+ ]);
+ const usd = ledger(groups, "2", "USD", 0);
+ expect(usd.map((row) => row.amount)).toEqual([100]);
+});
diff --git a/src/lib/reports/flows.ts b/src/lib/reports/flows.ts
new file mode 100644
index 0000000..079cd28
--- /dev/null
+++ b/src/lib/reports/flows.ts
@@ -0,0 +1,486 @@
+import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
+import type { Grain } from "./params.js";
+
+export interface Ref {
+ id: string;
+ name: string;
+}
+
+export interface Party extends Ref {
+ type: string;
+}
+
+export interface Flow {
+ amount: number;
+ asset: Party;
+ bill: Ref | null;
+ budget: Ref | null;
+ category: Ref | null;
+ currency: string;
+ date: string;
+ description: string;
+ dir: 1 | -1;
+ group: string;
+ journal: string;
+ other: Party;
+ pc: number | null;
+ tags: string[];
+ type: string;
+}
+
+export interface Measure {
+ in: number;
+ n: number;
+ out: number;
+}
+
+export interface Totals {
+ cur: Map;
+ n: number;
+ pc: Measure;
+ pcOk: boolean;
+}
+
+export interface Key {
+ href?: string;
+ key: string;
+ label: string;
+}
+
+export interface Row extends Key {
+ cells: Map;
+ totals: Totals;
+}
+
+export type Side = "in" | "out" | "net";
+
+const NUMERIC = (value: string | null | undefined) =>
+ value === null || value === undefined || value === "" ? null : Number(value);
+
+const ref = (id: string | null | undefined, name: string | null | undefined) =>
+ id ? { id: String(id), name: name ?? "" } : null;
+
+function party(split: Split, side: "source" | "destination"): Party {
+ return {
+ id: String(split[`${side}_id`] ?? ""),
+ name: split[`${side}_name`] ?? "",
+ type: split[`${side}_type`] ?? "",
+ };
+}
+
+function inbound(
+ split: Split,
+ currencyOf: (id: string) => string | undefined
+): { amount: number; currency: string } {
+ const foreign = NUMERIC(split.foreign_amount);
+ const code = split.foreign_currency_code;
+ if (
+ foreign !== null &&
+ code &&
+ currencyOf(String(split.destination_id)) === code
+ ) {
+ return { amount: foreign, currency: code };
+ }
+ return { amount: Number(split.amount), currency: split.currency_code ?? "" };
+}
+
+function toFlow(
+ split: Split,
+ groupId: string,
+ outgoing: boolean,
+ currencyOf: (id: string) => string | undefined
+): Flow {
+ const value = outgoing
+ ? { amount: Number(split.amount), currency: split.currency_code ?? "" }
+ : inbound(split, currencyOf);
+ return {
+ ...value,
+ asset: party(split, outgoing ? "source" : "destination"),
+ bill: ref(split.bill_id, split.bill_name),
+ budget: ref(split.budget_id, split.budget_name),
+ category: ref(split.category_id, split.category_name),
+ date: split.date.slice(0, 10),
+ description: split.description,
+ dir: outgoing ? -1 : 1,
+ group: groupId,
+ journal: String(split.transaction_journal_id ?? ""),
+ other: party(split, outgoing ? "destination" : "source"),
+ pc: NUMERIC(split.pc_amount),
+ tags: split.tags ?? [],
+ type: split.type,
+ };
+}
+
+export function toFlows(
+ groups: Item[],
+ selected: ReadonlySet,
+ currencyOf: (id: string) => string | undefined = () => undefined
+): Flow[] {
+ const flows: Flow[] = [];
+ for (const item of groups) {
+ for (const split of item.attributes.transactions) {
+ const outgoing = selected.has(String(split.source_id));
+ if (outgoing !== selected.has(String(split.destination_id))) {
+ flows.push(toFlow(split, item.id, outgoing, currencyOf));
+ }
+ }
+ }
+ return flows;
+}
+
+export function flowValue(flow: Flow, mode: string): number | null {
+ if (mode === "pc") {
+ return flow.pc;
+ }
+ return flow.currency === mode ? flow.amount : null;
+}
+
+export const emptyTotals = (): Totals => ({
+ cur: new Map(),
+ n: 0,
+ pc: { in: 0, n: 0, out: 0 },
+ pcOk: true,
+});
+
+export function add(totals: Totals, flow: Flow) {
+ let measure = totals.cur.get(flow.currency);
+ if (!measure) {
+ measure = { in: 0, n: 0, out: 0 };
+ totals.cur.set(flow.currency, measure);
+ }
+ const side = flow.dir === 1 ? "in" : "out";
+ measure[side] += flow.amount;
+ measure.n += 1;
+ totals.n += 1;
+ if (flow.pc === null) {
+ totals.pcOk = false;
+ } else {
+ totals.pc[side] += flow.pc;
+ totals.pc.n += 1;
+ }
+}
+
+export function total(flows: Iterable): Totals {
+ const totals = emptyTotals();
+ for (const flow of flows) {
+ add(totals, flow);
+ }
+ return totals;
+}
+
+export function measureOf(totals: Totals, mode: string): Measure | undefined {
+ return mode === "pc" ? totals.pc : totals.cur.get(mode);
+}
+
+export function amountOf(
+ totals: Totals | undefined,
+ mode: string,
+ side: Side
+): number {
+ const measure = totals ? measureOf(totals, mode) : undefined;
+ if (!measure) {
+ return 0;
+ }
+ if (side === "net") {
+ return measure.in - measure.out;
+ }
+ return measure[side];
+}
+
+export type KeyFn = (flow: Flow) => Key | Key[] | null;
+export type ColFn = (flow: Flow) => string | null;
+
+export function group(flows: Iterable, keyOf: KeyFn, colOf?: ColFn) {
+ const rows = new Map();
+ for (const flow of flows) {
+ const found = keyOf(flow);
+ if (!found) {
+ continue;
+ }
+ const col = colOf?.(flow) ?? null;
+ for (const key of Array.isArray(found) ? found : [found]) {
+ let row = rows.get(key.key);
+ if (!row) {
+ row = { ...key, cells: new Map(), totals: emptyTotals() };
+ rows.set(key.key, row);
+ }
+ add(row.totals, flow);
+ if (col !== null) {
+ let cell = row.cells.get(col);
+ if (!cell) {
+ cell = emptyTotals();
+ row.cells.set(col, cell);
+ }
+ add(cell, flow);
+ }
+ }
+ }
+ return [...rows.values()];
+}
+
+export function rank(
+ rows: T[],
+ mode: string,
+ side: Side
+): T[] {
+ const score = (row: T) => Math.abs(amountOf(row.totals, mode, side));
+ return [...rows].sort(
+ (a, b) => score(b) - score(a) || a.label.localeCompare(b.label)
+ );
+}
+
+export function currencies(totals: Totals, codes?: string[]): string[] {
+ const present = [...totals.cur.keys()];
+ if (!codes) {
+ return present.sort();
+ }
+ return [
+ ...codes.filter((code) => totals.cur.has(code)),
+ ...present.filter((code) => !codes.includes(code)).sort(),
+ ];
+}
+
+export const NONE = "0";
+
+export const keys = {
+ asset: (flow: Flow): Key => ({
+ href: `/accounts/show/${flow.asset.id}`,
+ key: flow.asset.id,
+ label: flow.asset.name,
+ }),
+ bill: (flow: Flow): Key =>
+ flow.bill
+ ? {
+ href: `/subscriptions/show/${flow.bill.id}`,
+ key: flow.bill.id,
+ label: flow.bill.name,
+ }
+ : { key: NONE, label: "(no subscription)" },
+ budget: (flow: Flow): Key | null => {
+ if (flow.dir === 1 || flow.type !== "withdrawal") {
+ return null;
+ }
+ return flow.budget
+ ? {
+ href: `/budgets/show/${flow.budget.id}`,
+ key: flow.budget.id,
+ label: flow.budget.name,
+ }
+ : { key: NONE, label: "(no budget)" };
+ },
+ category: (flow: Flow): Key =>
+ flow.category
+ ? {
+ href: `/categories/show/${flow.category.id}`,
+ key: flow.category.id,
+ label: flow.category.name,
+ }
+ : { key: NONE, label: "(no category)" },
+ name: (flow: Flow): Key => ({
+ key: flow.other.name.toLowerCase(),
+ label: flow.other.name,
+ }),
+ other: (flow: Flow): Key => ({
+ href: `/accounts/show/${flow.other.id}`,
+ key: flow.other.id,
+ label: flow.other.name,
+ }),
+ tag: (flow: Flow): Key[] =>
+ flow.tags.length > 0
+ ? flow.tags.map((tag) => ({
+ href: `/tags/show/${encodeURIComponent(tag)}`,
+ key: tag,
+ label: tag,
+ }))
+ : [{ key: NONE, label: "(no tags)" }],
+};
+
+export type Dimension = keyof typeof keys;
+
+export function matches(flow: Flow, dim: Dimension, key: string): boolean {
+ const found = keys[dim](flow);
+ if (!found) {
+ return false;
+ }
+ return Array.isArray(found)
+ ? found.some((item) => item.key === key)
+ : found.key === key;
+}
+
+export function bucketOf(date: string, grain: Grain): string {
+ if (grain === "year") {
+ return date.slice(0, 4);
+ }
+ return grain === "month" ? date.slice(0, 7) : date.slice(0, 10);
+}
+
+const pad = (n: number) => String(n).padStart(2, "0");
+
+export function buckets(start: string, end: string, grain: Grain): string[] {
+ const list: string[] = [];
+ if (grain === "year") {
+ for (
+ let y = Number(start.slice(0, 4));
+ y <= Number(end.slice(0, 4));
+ y += 1
+ ) {
+ list.push(String(y));
+ }
+ return list;
+ }
+ if (grain === "month") {
+ let y = Number(start.slice(0, 4));
+ let m = Number(start.slice(5, 7));
+ const last = end.slice(0, 7);
+ while (`${y}-${pad(m)}` <= last) {
+ list.push(`${y}-${pad(m)}`);
+ m += 1;
+ if (m > 12) {
+ m = 1;
+ y += 1;
+ }
+ }
+ return list;
+ }
+ const d = new Date(
+ Number(start.slice(0, 4)),
+ Number(start.slice(5, 7)) - 1,
+ Number(start.slice(8, 10))
+ );
+ for (;;) {
+ const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+ if (key > end) {
+ return list;
+ }
+ list.push(key);
+ d.setDate(d.getDate() + 1);
+ }
+}
+
+export function bucketRange(
+ key: string,
+ grain: Grain,
+ clip?: { start: string; end: string }
+): { start: string; end: string } {
+ let range: { start: string; end: string };
+ if (grain === "year") {
+ range = { end: `${key}-12-31`, start: `${key}-01-01` };
+ } else if (grain === "month") {
+ const [y, m] = key.split("-").map(Number);
+ range = {
+ end: `${key}-${pad(new Date(y, m, 0).getDate())}`,
+ start: `${key}-01`,
+ };
+ } else {
+ range = { end: key, start: key };
+ }
+ if (!clip) {
+ return range;
+ }
+ return {
+ end: range.end > clip.end ? clip.end : range.end,
+ start: range.start < clip.start ? clip.start : range.start,
+ };
+}
+
+export function topFlows(
+ flows: Iterable,
+ mode: string,
+ dir: 1 | -1,
+ limit: number
+): Flow[] {
+ const list: Flow[] = [];
+ for (const flow of flows) {
+ if (flow.dir !== dir) {
+ continue;
+ }
+ if (mode === "pc" ? flow.pc !== null : flow.currency === mode) {
+ list.push(flow);
+ }
+ }
+ const amount = (flow: Flow) => (mode === "pc" ? (flow.pc ?? 0) : flow.amount);
+ return list.sort((a, b) => amount(b) - amount(a)).slice(0, limit);
+}
+
+export function modes(totals: Totals, codes: string[]): string[] {
+ const list = currencies(totals, codes);
+ return totals.pcOk && list.length > 1 ? ["pc", ...list] : list;
+}
+
+export function daily(flows: Iterable, mode: string, dir: 1 | -1) {
+ const days = new Map();
+ for (const flow of flows) {
+ if (flow.dir !== dir) {
+ continue;
+ }
+ const value = flowValue(flow, mode);
+ if (value === null) {
+ continue;
+ }
+ const day = days.get(flow.date) ?? { n: 0, value: 0 };
+ day.value += value;
+ day.n += 1;
+ days.set(flow.date, day);
+ }
+ return days;
+}
+
+export function merge(list: (Totals | undefined)[]): Totals {
+ const result = emptyTotals();
+ for (const totals of list) {
+ if (!totals) {
+ continue;
+ }
+ for (const [code, m] of totals.cur) {
+ const target = result.cur.get(code) ?? { in: 0, n: 0, out: 0 };
+ target.in += m.in;
+ target.out += m.out;
+ target.n += m.n;
+ result.cur.set(code, target);
+ }
+ result.n += totals.n;
+ result.pc.in += totals.pc.in;
+ result.pc.out += totals.pc.out;
+ result.pc.n += totals.pc.n;
+ result.pcOk &&= totals.pcOk;
+ }
+ return result;
+}
+
+export interface Exchange {
+ date: string;
+ from: string;
+ fromAmount: number;
+ to: string;
+ toAmount: number;
+}
+
+export function exchanges(
+ groups: Item[],
+ selected: ReadonlySet,
+ currencyOf: (id: string) => string | undefined
+): Exchange[] {
+ const list: Exchange[] = [];
+ for (const item of groups) {
+ for (const split of item.attributes.transactions) {
+ const source = String(split.source_id);
+ const destination = String(split.destination_id);
+ if (!(selected.has(source) && selected.has(destination))) {
+ continue;
+ }
+ const from = split.currency_code ?? currencyOf(source) ?? "";
+ const to = split.foreign_currency_code ?? currencyOf(destination) ?? from;
+ const foreign = NUMERIC(split.foreign_amount);
+ if (from !== to && foreign !== null) {
+ list.push({
+ date: split.date.slice(0, 10),
+ from,
+ fromAmount: Number(split.amount),
+ to,
+ toAmount: foreign,
+ });
+ }
+ }
+ }
+ return list;
+}
diff --git a/src/lib/reports/memory.ts b/src/lib/reports/memory.ts
new file mode 100644
index 0000000..05b0155
--- /dev/null
+++ b/src/lib/reports/memory.ts
@@ -0,0 +1,43 @@
+import {
+ kindOf,
+ type ListKind,
+ type ReportParams,
+ type ReportType,
+} from "./params.js";
+
+const KEY = "ffui.reports.form";
+
+export interface Memory {
+ accounts: string[];
+ items: Partial>;
+ type: ReportType;
+}
+
+export function recall(): Memory {
+ try {
+ const value = JSON.parse(localStorage.getItem(KEY) ?? "null");
+ if (value && kindOf(value.type) && Array.isArray(value.accounts)) {
+ return {
+ accounts: value.accounts,
+ items: value.items ?? {},
+ type: value.type,
+ };
+ }
+ } catch {
+ localStorage.removeItem(KEY);
+ }
+ return { accounts: [], items: {}, type: "default" };
+}
+
+export function remember(
+ params: Pick
+) {
+ const memory = recall();
+ const list = kindOf(params.type)?.list;
+ const next: Memory = {
+ accounts: params.accounts,
+ items: list ? { ...memory.items, [list]: params.items } : memory.items,
+ type: params.type,
+ };
+ localStorage.setItem(KEY, JSON.stringify(next));
+}
diff --git a/src/lib/reports/options.ts b/src/lib/reports/options.ts
new file mode 100644
index 0000000..c4629b4
--- /dev/null
+++ b/src/lib/reports/options.ts
@@ -0,0 +1,115 @@
+import type { Account, Budget, Category, Item, Tag } from "#lib/api/types.js";
+import type { ListKind } from "./params.js";
+import type { Option } from "./report.svelte.js";
+
+const byLabel = (a: Option, b: Option) =>
+ (a.group ?? "").localeCompare(b.group ?? "") ||
+ a.label.localeCompare(b.label);
+
+export function budgetOptions(budgets: Item[] = []): Option[] {
+ return budgets
+ .filter((b) => b.attributes.active !== false)
+ .map((b) => ({ id: b.id, label: b.attributes.name }))
+ .sort(byLabel);
+}
+
+export function categoryOptions(categories: Item[] = []): Option[] {
+ return categories
+ .map((c) => ({ id: c.id, label: c.attributes.name }))
+ .sort(byLabel);
+}
+
+export function tagOptions(tags: Item[] = []): Option[] {
+ return tags
+ .map((t) => ({
+ group: t.attributes.date ? t.attributes.date.slice(0, 4) : "(no date)",
+ id: t.id,
+ label: t.attributes.tag,
+ }))
+ .sort(byLabel);
+}
+
+export function doubleOptions(
+ expense: Item[] = [],
+ revenue: Item[] = []
+): Option[] {
+ const revenueNames = new Set(
+ revenue.map((a) => a.attributes.name.toLowerCase())
+ );
+ const revenueIbans = new Set(
+ revenue.map((a) => a.attributes.iban).filter(Boolean)
+ );
+ const paired = (a: Account) =>
+ revenueNames.has(a.name.toLowerCase()) ||
+ (!!a.iban && revenueIbans.has(a.iban));
+ const label = (a: Account) => (a.iban ? `${a.name} (${a.iban})` : a.name);
+ const pairs = expense.filter((a) => paired(a.attributes));
+ const pairedNames = new Set(
+ pairs.map((a) => a.attributes.name.toLowerCase())
+ );
+ return [
+ ...pairs.map((a) => ({
+ group: "Expense and revenue",
+ id: a.id,
+ label: label(a.attributes),
+ })),
+ ...expense
+ .filter((a) => !paired(a.attributes))
+ .map((a) => ({
+ group: "Expense accounts",
+ id: a.id,
+ label: label(a.attributes),
+ })),
+ ...revenue
+ .filter((a) => !pairedNames.has(a.attributes.name.toLowerCase()))
+ .map((a) => ({
+ group: "Revenue accounts",
+ id: a.id,
+ label: label(a.attributes),
+ })),
+ ].sort(
+ (a, b) =>
+ GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group) ||
+ a.label.localeCompare(b.label)
+ );
+}
+
+const GROUP_ORDER = [
+ "Expense and revenue",
+ "Expense accounts",
+ "Revenue accounts",
+];
+
+export interface ListSources {
+ budgets?: Item[];
+ categories?: Item[];
+ expense?: Item[];
+ revenue?: Item[];
+ tags?: Item[];
+}
+
+export function listOptions(kind: ListKind, sources: ListSources): Option[] {
+ switch (kind) {
+ case "budget":
+ return budgetOptions(sources.budgets);
+ case "category":
+ return categoryOptions(sources.categories);
+ case "tag":
+ return tagOptions(sources.tags);
+ default:
+ return doubleOptions(sources.expense, sources.revenue);
+ }
+}
+
+export function listReady(kind: ListKind, sources: ListSources): boolean {
+ switch (kind) {
+ case "budget":
+ return !!sources.budgets;
+ case "category":
+ return !!sources.categories;
+ case "tag":
+ return !!sources.tags;
+ default:
+ return !!(sources.expense && sources.revenue);
+ }
+}
diff --git a/src/lib/reports/params.test.ts b/src/lib/reports/params.test.ts
new file mode 100644
index 0000000..23920b1
--- /dev/null
+++ b/src/lib/reports/params.test.ts
@@ -0,0 +1,82 @@
+///
+import { expect, test } from "bun:test";
+import {
+ fiscalYear,
+ grainOf,
+ parseReport,
+ parseReportDate,
+ reportPath,
+ templateOf,
+} from "./params.js";
+
+const NOW = "2026-10-16";
+
+test("parses Firefly dates and magic words", () => {
+ expect(parseReportDate("20260101", "01-01", NOW)).toBe("2026-01-01");
+ expect(parseReportDate("2026-02-03", "01-01", NOW)).toBe("2026-02-03");
+ expect(parseReportDate("20260231", "01-01", NOW)).toBeNull();
+ expect(parseReportDate("currentMonthStart", "01-01", NOW)).toBe("2026-10-01");
+ expect(parseReportDate("currentMonthEnd", "01-01", NOW)).toBe("2026-10-31");
+ expect(parseReportDate("previousMonthStart", "01-01", NOW)).toBe(
+ "2026-09-01"
+ );
+ expect(parseReportDate("previousMonthEnd", "01-01", NOW)).toBe("2026-09-30");
+ expect(parseReportDate("previousYearEnd", "01-01", NOW)).toBe("2025-12-31");
+ expect(parseReportDate("currentFiscalYearStart", "04-01", NOW)).toBe(
+ "2026-04-01"
+ );
+ expect(parseReportDate("previousFiscalYearEnd", "04-01", NOW)).toBe(
+ "2026-03-31"
+ );
+});
+
+test("fiscal year wraps around the start day", () => {
+ expect(fiscalYear("2026-02-10", "04-01")).toEqual({
+ end: "2026-03-31",
+ start: "2025-04-01",
+ });
+});
+
+test("round-trips a report path", () => {
+ const parsed = parseReport("category", "1,2/5,7/20260101/20261231");
+ expect(parsed.ok).toBe(true);
+ if (parsed.ok) {
+ expect(parsed.params).toEqual({
+ accounts: ["1", "2"],
+ end: "2026-12-31",
+ items: ["5", "7"],
+ start: "2026-01-01",
+ type: "category",
+ });
+ expect(reportPath(parsed.params)).toBe(
+ "/reports/category/1,2/5,7/20260101/20261231"
+ );
+ }
+});
+
+test("rejects bad addresses like Firefly does", () => {
+ expect(parseReport("nope", "1/20260101/20260131").ok).toBe(false);
+ expect(parseReport("default", "1/20260201/20260101").ok).toBe(false);
+ expect(parseReport("budget", "1/20260101/20260131").ok).toBe(false);
+ const swapped = parseReport("double", "1/9/20260201/20260101");
+ expect(swapped.ok && swapped.params.start).toBe("2026-01-01");
+});
+
+test("keeps magic list words for the page to resolve", () => {
+ const parsed = parseReport(
+ "tag",
+ "allAssetAccounts/allTags/20260101/20260131"
+ );
+ expect(parsed.ok && parsed.params.accounts).toEqual(["allAssetAccounts"]);
+ expect(parsed.ok && parsed.params.items).toEqual(["allTags"]);
+});
+
+test("picks grain and template like Firefly", () => {
+ expect(grainOf("2026-10-01", "2026-10-31")).toBe("day");
+ expect(templateOf("2026-10-01", "2026-10-31")).toBe("month");
+ expect(grainOf("2026-01-01", "2026-12-31")).toBe("month");
+ expect(templateOf("2026-01-01", "2026-12-31")).toBe("year");
+ expect(templateOf("2026-01-01", "2026-02-28")).toBe("year");
+ expect(grainOf("2025-01-01", "2026-12-31")).toBe("year");
+ expect(templateOf("2025-01-01", "2026-12-31")).toBe("multi-year");
+});
diff --git a/src/lib/reports/params.ts b/src/lib/reports/params.ts
new file mode 100644
index 0000000..6ddb652
--- /dev/null
+++ b/src/lib/reports/params.ts
@@ -0,0 +1,283 @@
+import {
+ addDays,
+ iso,
+ parse,
+ periodOf,
+ type Range,
+ shiftPeriod,
+ today,
+} from "#lib/dates.js";
+
+export type ReportType =
+ | "default"
+ | "audit"
+ | "budget"
+ | "category"
+ | "tag"
+ | "double";
+
+export type ListKind = "budget" | "category" | "tag" | "double";
+
+export interface ReportKind {
+ label: string;
+ list?: ListKind;
+ type: ReportType;
+}
+
+export const REPORT_KINDS: ReportKind[] = [
+ { label: "Default financial report", type: "default" },
+ { label: "Transaction history overview (audit)", type: "audit" },
+ { label: "Budget report", list: "budget", type: "budget" },
+ { label: "Category report", list: "category", type: "category" },
+ { label: "Tag report", list: "tag", type: "tag" },
+ { label: "Expense/revenue account report", list: "double", type: "double" },
+];
+
+export const LIST_LABELS: Record = {
+ budget: { many: "Budgets", one: "budget" },
+ category: { many: "Categories", one: "category" },
+ double: { many: "Expense/revenue accounts", one: "account" },
+ tag: { many: "Tags", one: "tag" },
+};
+
+export const ALL_ASSETS = "allAssetAccounts";
+
+export interface ReportParams {
+ accounts: string[];
+ end: string;
+ items: string[];
+ start: string;
+ type: ReportType;
+}
+
+export type Parsed =
+ | { ok: true; params: ReportParams }
+ | { ok: false; error: string };
+
+const YMD = /^(\d{4})(\d{2})(\d{2})$/;
+const ISO = /^(\d{4})-(\d{2})-(\d{2})/;
+const LIST_ITEM = /^[^,/]+$/;
+
+export const kindOf = (type: string) =>
+ REPORT_KINDS.find((kind) => kind.type === type);
+
+export const ymd = (value: string) => value.replaceAll("-", "");
+
+function valid(y: number, m: number, d: number): string | null {
+ const date = new Date(y, m - 1, d);
+ if (
+ date.getFullYear() !== y ||
+ date.getMonth() !== m - 1 ||
+ date.getDate() !== d
+ ) {
+ return null;
+ }
+ return iso(date);
+}
+
+export function fiscalYear(value: string, fiscalStart = "01-01"): Range {
+ const [month, day] = fiscalStart.split("-").map(Number);
+ const year = Number(value.slice(0, 4));
+ const thisYear = iso(new Date(year, (month || 1) - 1, day || 1));
+ const startYear = thisYear <= value ? year : year - 1;
+ const start = iso(new Date(startYear, (month || 1) - 1, day || 1));
+ const next = iso(new Date(startYear + 1, (month || 1) - 1, day || 1));
+ return { end: addDays(next, -1), start };
+}
+
+function magic(now: string, fiscalStart: string): Record {
+ const d = parse(now);
+ const y = d.getFullYear();
+ const m = d.getMonth();
+ const fiscal = fiscalYear(now, fiscalStart);
+ const previousFiscal = fiscalYear(addDays(fiscal.start, -1), fiscalStart);
+ return {
+ currentFiscalYearEnd: fiscal.end,
+ currentFiscalYearStart: fiscal.start,
+ currentMonthEnd: iso(new Date(y, m + 1, 0)),
+ currentMonthStart: iso(new Date(y, m, 1)),
+ currentYearEnd: `${y}-12-31`,
+ currentYearStart: `${y}-01-01`,
+ previousFiscalYearEnd: previousFiscal.end,
+ previousFiscalYearStart: previousFiscal.start,
+ previousMonthEnd: iso(new Date(y, m, 0)),
+ previousMonthStart: iso(new Date(y, m - 1, 1)),
+ previousYearEnd: `${y - 1}-12-31`,
+ previousYearStart: `${y - 1}-01-01`,
+ };
+}
+
+export function parseReportDate(
+ value: string,
+ fiscalStart = "01-01",
+ now = today()
+): string | null {
+ const word = magic(now, fiscalStart)[value];
+ if (word) {
+ return word;
+ }
+ const match = YMD.exec(value) ?? ISO.exec(value);
+ if (!match) {
+ return null;
+ }
+ return valid(Number(match[1]), Number(match[2]), Number(match[3]));
+}
+
+function list(segment: string | undefined): string[] | null {
+ if (!segment) {
+ return null;
+ }
+ const items = segment
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+ return items.length > 0 && items.every((item) => LIST_ITEM.test(item))
+ ? [...new Set(items)]
+ : null;
+}
+
+export function parseReport(
+ type: string,
+ rest: string,
+ fiscalStart = "01-01",
+ now = today()
+): Parsed {
+ const kind = kindOf(type);
+ if (!kind) {
+ return { error: `There is no "${type}" report.`, ok: false };
+ }
+ const segments = rest.split("/").filter(Boolean);
+ const expected = kind.list ? 4 : 3;
+ if (segments.length !== expected) {
+ return { error: "This report address is incomplete.", ok: false };
+ }
+ const accounts = list(segments[0]);
+ const items = kind.list ? list(segments[1]) : [];
+ let start = parseReportDate(segments.at(-2) ?? "", fiscalStart, now);
+ let end = parseReportDate(segments.at(-1) ?? "", fiscalStart, now);
+ if (!accounts) {
+ return { error: "Please select at least one asset account", ok: false };
+ }
+ if (!items) {
+ return {
+ error: `Please select at least one ${LIST_LABELS[kind.list as ListKind].one}`,
+ ok: false,
+ };
+ }
+ if (!(start && end)) {
+ return {
+ error: "The dates in this report address are not valid.",
+ ok: false,
+ };
+ }
+ if (end < start) {
+ if (type !== "double") {
+ return {
+ error: "End date of report must be after start date.",
+ ok: false,
+ };
+ }
+ [start, end] = [end, start];
+ }
+ return { ok: true, params: { accounts, end, items, start, type: kind.type } };
+}
+
+export function reportPath(params: ReportParams): string {
+ const parts = [
+ "/reports",
+ params.type,
+ params.accounts.join(","),
+ ...(kindOf(params.type)?.list ? [params.items.join(",")] : []),
+ ymd(params.start),
+ ymd(params.end),
+ ];
+ return parts.join("/");
+}
+
+export function missing(params: ReportParams): string | null {
+ if (params.accounts.length === 0) {
+ return "Please select at least one asset account";
+ }
+ const kind = kindOf(params.type);
+ if (kind?.list && params.items.length === 0) {
+ return kind.list === "double"
+ ? "Please select at least one combination of expense/revenue accounts. If you have none (the list is empty) this report is not available."
+ : `Please select at least one ${LIST_LABELS[kind.list].one}`;
+ }
+ if (!(params.start && params.end)) {
+ return "Please choose a date range";
+ }
+ if (params.end < params.start) {
+ return "End date of report must be after start date.";
+ }
+ return null;
+}
+
+export type Grain = "day" | "month" | "year";
+export type Template = "month" | "year" | "multi-year";
+
+const daysIn = (y: number, m: number) => new Date(y, m + 1, 0).getDate();
+
+export function monthsBetween(start: string, end: string): number {
+ const a = parse(start);
+ const b = parse(end);
+ const whole =
+ (b.getFullYear() - a.getFullYear()) * 12 + b.getMonth() - a.getMonth();
+ const days = b.getDate() - a.getDate();
+ return whole + days / daysIn(a.getFullYear(), a.getMonth());
+}
+
+const GRAIN_EPSILON = 0.001;
+
+export function grainOf(start: string, end: string): Grain {
+ const months = monthsBetween(start, end);
+ if (months >= 12 + GRAIN_EPSILON) {
+ return "year";
+ }
+ return months >= 1 + GRAIN_EPSILON ? "month" : "day";
+}
+
+export function templateOf(start: string, end: string): Template {
+ const months = monthsBetween(start, end);
+ if (months > 12) {
+ return "multi-year";
+ }
+ return months > 1 ? "year" : "month";
+}
+
+const ALIGNED_VIEWS = ["1Y", "6M", "3M", "1M"] as const;
+
+export function shiftRange(range: Range, dir: 1 | -1): Range {
+ for (const view of ALIGNED_VIEWS) {
+ const aligned = periodOf(range.start, view);
+ if (aligned.start === range.start && aligned.end === range.end) {
+ return shiftPeriod(range, view, dir);
+ }
+ }
+ return shiftPeriod(range, "1M", dir);
+}
+
+export interface RangePreset {
+ key: string;
+ label: string;
+ range: Range;
+}
+
+export function rangePresets(now = today()): RangePreset[] {
+ const month = periodOf(now, "1M");
+ const year = periodOf(now, "1Y");
+ return [
+ { key: "this-month", label: "This month", range: month },
+ {
+ key: "last-month",
+ label: "Last month",
+ range: shiftPeriod(month, "1M", -1),
+ },
+ { key: "this-year", label: "This year", range: year },
+ {
+ key: "last-year",
+ label: "Last year",
+ range: shiftPeriod(year, "1Y", -1),
+ },
+ ];
+}
diff --git a/src/lib/reports/queries.ts b/src/lib/reports/queries.ts
new file mode 100644
index 0000000..fe2dda4
--- /dev/null
+++ b/src/lib/reports/queries.ts
@@ -0,0 +1,130 @@
+import { api } from "#lib/api/client.js";
+import { all } from "#lib/api/lists.js";
+import type {
+ Account,
+ Bill,
+ BudgetLimit,
+ ChartDataSet,
+ InsightGroupEntry,
+ Item,
+ Page,
+ TransactionGroup,
+} from "#lib/api/types.js";
+import { addDays } from "#lib/dates.js";
+
+const PAGE = 500;
+
+export async function pages(
+ path: string,
+ query: Record
+): Promise- []> {
+ const first = await api
>(path, {
+ query: { ...query, limit: PAGE, page: 1 },
+ });
+ const count = first.meta.pagination?.total_pages ?? 1;
+ const rest = await Promise.all(
+ Array.from({ length: Math.max(0, count - 1) }, (_, i) =>
+ api>(path, { query: { ...query, limit: PAGE, page: i + 2 } })
+ )
+ );
+ return [first, ...rest].flatMap((page) => page.data);
+}
+
+const balancesAt = async (date: string) => {
+ const [assets, liabilities] = await Promise.all([
+ all("/v1/accounts", { date, type: "asset" }),
+ all("/v1/accounts", { date, type: "liabilities" }),
+ ]);
+ return new Map(
+ [...assets, ...liabilities].map((item) => [
+ item.id,
+ Number(item.attributes.current_balance ?? 0),
+ ])
+ );
+};
+
+export const reportQueries = {
+ balances: (start: string, end: string) => ({
+ queryFn: async () => {
+ const [before, after] = await Promise.all([
+ balancesAt(addDays(start, -1)),
+ balancesAt(end),
+ ]);
+ return { after, before };
+ },
+ queryKey: ["report", "balances", start, end],
+ }),
+ bills: (start: string, end: string) => ({
+ queryFn: () => all("/v1/bills", { end, start }),
+ queryKey: ["report", "bills", start, end],
+ }),
+ chart: (start: string, end: string, period: string, accounts: string[]) => ({
+ enabled: accounts.length > 0,
+ queryFn: () =>
+ api("/v1/chart/account/overview", {
+ query: { "accounts[]": accounts, end, period, start },
+ }),
+ queryKey: ["report", "chart", start, end, period, accounts.join(",")],
+ }),
+ first: () => ({
+ queryFn: async () => {
+ const probe = await api>("/v1/transactions", {
+ query: {
+ end: "2038-01-01",
+ limit: 1,
+ start: "1970-01-02",
+ type: "all",
+ },
+ });
+ const last = probe.meta.pagination?.total_pages ?? 1;
+ const page = await api>("/v1/transactions", {
+ query: {
+ end: "2038-01-01",
+ limit: 1,
+ page: last,
+ start: "1970-01-02",
+ type: "all",
+ },
+ });
+ return (
+ page.data[0]?.attributes.transactions[0]?.date.slice(0, 10) ?? null
+ );
+ },
+ queryKey: ["report", "first"],
+ staleTime: 3_600_000,
+ }),
+ ledger: (account: string, start: string, end: string) => ({
+ queryFn: () =>
+ pages(`/v1/accounts/${account}/transactions`, {
+ end,
+ start,
+ types: "all",
+ }),
+ queryKey: ["report", "ledger", account, start, end],
+ }),
+ limitSpent: (
+ budget: string,
+ start: string,
+ end: string,
+ accounts: string[]
+ ) => ({
+ queryFn: () =>
+ api("/v1/insight/expense/budget", {
+ query: { "accounts[]": accounts, "budgets[]": [budget], end, start },
+ }),
+ queryKey: ["report", "limit-spent", budget, start, end, accounts.join(",")],
+ }),
+ limits: (start: string, end: string) => ({
+ queryFn: () => all("/v1/budget-limits", { end, start }),
+ queryKey: ["report", "limits", start, end],
+ }),
+ transactions: (start: string, end: string) => ({
+ queryFn: () =>
+ pages("/v1/transactions", {
+ end,
+ start,
+ type: "default",
+ }),
+ queryKey: ["report", "transactions", start, end],
+ }),
+};
diff --git a/src/lib/reports/report.svelte.ts b/src/lib/reports/report.svelte.ts
new file mode 100644
index 0000000..0687234
--- /dev/null
+++ b/src/lib/reports/report.svelte.ts
@@ -0,0 +1,497 @@
+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 { formatMoney } from "#lib/format.js";
+import { session } from "#lib/session.svelte.js";
+import { goto } from "$app/navigation";
+import { page } from "$app/state";
+import {
+ type Dimension,
+ exchanges,
+ type Flow,
+ matches,
+ modes,
+ toFlows,
+ total,
+} from "./flows.js";
+import {
+ ALL_ASSETS,
+ grainOf,
+ kindOf,
+ type ReportParams,
+ reportPath,
+ templateOf,
+} from "./params.js";
+import { reportQueries } from "./queries.js";
+
+export interface Option {
+ group?: string;
+ id: string;
+ label: string;
+ note?: string;
+}
+
+const ROLE_GROUPS: Record = {
+ cashWalletAsset: "Cash wallets",
+ ccAsset: "Credit cards",
+ defaultAsset: "Default asset accounts",
+ savingAsset: "Savings accounts",
+ sharedAsset: "Shared asset accounts",
+};
+
+const LIABILITY_GROUP: Record = {
+ debt: "Liability: Debt",
+ loan: "Liability: Loan",
+ mortgage: "Liability: Mortgage",
+};
+
+export const LIST_LENGTH = 10;
+
+export function accountOptions(accounts: Item[]): Option[] {
+ return accounts
+ .map((item) => {
+ const a = item.attributes;
+ const group =
+ a.type === "liabilities" || a.type === "liability"
+ ? (LIABILITY_GROUP[a.liability_type ?? ""] ?? "Liabilities")
+ : (ROLE_GROUPS[a.account_role ?? ""] ?? "Default asset accounts");
+ const shared = a.account_role === "sharedAsset" ? " (shared)" : "";
+ return {
+ group,
+ id: item.id,
+ label: `${a.name}${shared}`,
+ note: [a.currency_code, a.active ? "" : "inactive"]
+ .filter(Boolean)
+ .join(" · "),
+ };
+ })
+ .sort(
+ (a, b) =>
+ (a.group ?? "").localeCompare(b.group ?? "") ||
+ a.label.localeCompare(b.label)
+ );
+}
+
+export const isLiability = (account: Account) =>
+ account.type === "liabilities" || account.type === "liability";
+
+export function allAssetIds(accounts: Item[]): string[] {
+ return accounts.filter((a) => a.attributes.active).map((a) => a.id);
+}
+
+export function currencyMeta(code: string) {
+ const found = session.currencies.find((c) => c.attributes.code === code);
+ return {
+ code,
+ decimals: found?.attributes.decimal_places ?? 2,
+ symbol: found?.attributes.symbol ?? code,
+ };
+}
+
+export function money(
+ code: string,
+ amount: number,
+ signed = false,
+ decimals?: number
+) {
+ const { primary } = session;
+ const meta =
+ code === "pc" && primary
+ ? {
+ code: primary.code,
+ decimals: primary.decimal_places,
+ symbol: primary.symbol,
+ }
+ : currencyMeta(code);
+ return formatMoney(
+ { amount, ...meta, decimals: decimals ?? meta.decimals },
+ signed
+ );
+}
+
+export function modeLabel(mode: string) {
+ return mode === "pc" ? `All in ${session.primary?.code ?? ""}` : mode;
+}
+
+export function navigate(
+ changes: Record,
+ path?: string
+) {
+ const url = new URL(page.url.href);
+ if (path) {
+ url.pathname = path;
+ }
+ for (const [name, value] of Object.entries(changes)) {
+ if (value === null || value === "") {
+ url.searchParams.delete(name);
+ } else {
+ url.searchParams.set(name, value);
+ }
+ }
+ return goto(url, { replace: true, reset: false });
+}
+
+export interface Drill {
+ asset?: string;
+ at?: string;
+ box: string;
+ dim?: Dimension | "day" | "flow";
+ dir?: 1 | -1;
+ key?: string;
+ label: string;
+}
+
+const DRILL_DIMS = new Set([
+ "asset",
+ "bill",
+ "budget",
+ "category",
+ "name",
+ "other",
+ "tag",
+ "day",
+ "flow",
+]);
+
+const DIRS: Record = { in: 1, out: -1 };
+const DIR_NAMES = { "-1": "out", "1": "in" } as const;
+
+function readDrill(url: {
+ searchParams: { get: (name: string) => string | null };
+}): Drill | null {
+ const box = url.searchParams.get("box");
+ if (!box) {
+ return null;
+ }
+ const raw = url.searchParams.get("drill") ?? "";
+ const split = raw.indexOf(":");
+ const dim = split > 0 ? raw.slice(0, split) : "";
+ const dir = url.searchParams.get("dir");
+ return {
+ asset: url.searchParams.get("acc") ?? undefined,
+ at: url.searchParams.get("at") ?? undefined,
+ box,
+ dim: DRILL_DIMS.has(dim) ? (dim as Drill["dim"]) : undefined,
+ dir: DIRS[dir ?? ""],
+ key: split > 0 ? raw.slice(split + 1) : undefined,
+ label: url.searchParams.get("dl") ?? "",
+ };
+}
+
+export function drillQuery(drill: Drill | null): Record {
+ if (!drill) {
+ return { acc: null, at: null, box: null, dir: null, dl: null, drill: null };
+ }
+ return {
+ acc: drill.asset ?? null,
+ at: drill.at ?? null,
+ box: drill.box,
+ dir: drill.dir ? DIR_NAMES[drill.dir] : null,
+ dl: drill.label || null,
+ drill:
+ drill.dim && drill.key !== undefined ? `${drill.dim}:${drill.key}` : null,
+ };
+}
+
+export function drillFilter(
+ drill: Drill,
+ bucketOf: (date: string) => string
+): (flow: Flow) => boolean {
+ return (flow) => {
+ if (drill.dir && flow.dir !== drill.dir) {
+ return false;
+ }
+ if (drill.at && bucketOf(flow.date) !== drill.at) {
+ return false;
+ }
+ if (drill.asset && flow.asset.id !== drill.asset) {
+ return false;
+ }
+ if (!drill.dim || drill.key === undefined) {
+ return true;
+ }
+ if (drill.dim === "day") {
+ return flow.date === drill.key;
+ }
+ if (drill.dim === "flow") {
+ return flow.journal === drill.key;
+ }
+ return matches(flow, drill.dim, drill.key);
+ };
+}
+
+function twins(
+ ids: string[],
+ expense: Item[],
+ revenue: Item[]
+): Set {
+ const pool = [...expense, ...revenue];
+ const chosen = pool.filter((a) => ids.includes(a.id));
+ const names = new Set(chosen.map((a) => a.attributes.name.toLowerCase()));
+ const ibans = new Set(
+ chosen
+ .map((a) => a.attributes.iban)
+ .filter((iban): iban is string => !!iban)
+ );
+ return new Set(
+ pool
+ .filter(
+ (a) =>
+ ids.includes(a.id) ||
+ names.has(a.attributes.name.toLowerCase()) ||
+ (a.attributes.iban && ibans.has(a.attributes.iban))
+ )
+ .map((a) => a.id)
+ );
+}
+
+export function createReport(params: () => ReportParams) {
+ const p = $derived(params());
+ const kind = $derived(kindOf(p.type));
+ const assets = createQuery(() => lists.accounts("asset"));
+ const liabilities = createQuery(() => lists.accounts("liabilities"));
+ const budgets = createQuery(() => ({
+ ...lists.budgets(),
+ enabled: p.type === "budget" || p.type === "default",
+ }));
+ const categories = createQuery(() => ({
+ ...lists.categories(),
+ enabled: p.type === "category",
+ }));
+ const tags = createQuery(() => ({
+ ...lists.tags(),
+ enabled: p.type === "tag",
+ }));
+ const expense = createQuery(() => ({
+ ...lists.accounts("expense"),
+ enabled: p.type === "double",
+ }));
+ const revenue = createQuery(() => ({
+ ...lists.accounts("revenue"),
+ enabled: p.type === "double",
+ }));
+ const tx = createQuery(() => ({
+ ...reportQueries.transactions(p.start, p.end),
+ enabled: p.type !== "audit",
+ }));
+
+ const accounts = $derived([
+ ...(assets.data ?? []),
+ ...(liabilities.data ?? []),
+ ]);
+ const accountsReady = $derived(!!(assets.data && liabilities.data));
+ const byId = $derived(new Map(accounts.map((a) => [a.id, a.attributes])));
+ const selected = $derived(
+ p.accounts.includes(ALL_ASSETS) ? allAssetIds(accounts) : p.accounts
+ );
+ const selectedSet = $derived(new Set(selected));
+ const items = $derived.by(() => {
+ const raw = p.items;
+ if (p.type === "budget") {
+ return raw.includes("allBudgets")
+ ? (budgets.data ?? [])
+ .filter((b) => b.attributes.active)
+ .map((b) => b.id)
+ : raw;
+ }
+ if (p.type === "category") {
+ return raw.includes("allCategories")
+ ? (categories.data ?? []).map((c) => c.id)
+ : raw;
+ }
+ if (p.type === "tag") {
+ const list = tags.data ?? [];
+ if (raw.includes("allTags")) {
+ return list.map((t) => t.id);
+ }
+ return raw.map(
+ (value) =>
+ list.find(
+ (t) =>
+ t.id === value ||
+ t.attributes.tag.toLowerCase() === value.toLowerCase()
+ )?.id ?? value
+ );
+ }
+ return raw;
+ });
+ const itemSet = $derived(new Set(items));
+ const itemNames = $derived.by(() => {
+ const names = new Map();
+ for (const b of budgets.data ?? []) {
+ names.set(`budget:${b.id}`, b.attributes.name);
+ }
+ for (const c of categories.data ?? []) {
+ names.set(`category:${c.id}`, c.attributes.name);
+ }
+ for (const t of tags.data ?? []) {
+ names.set(`tag:${t.id}`, t.attributes.tag);
+ }
+ for (const a of [...(expense.data ?? []), ...(revenue.data ?? [])]) {
+ names.set(`double:${a.id}`, a.attributes.name);
+ }
+ return names;
+ });
+ const tagNames = $derived(
+ new Set(
+ (tags.data ?? [])
+ .filter((t) => itemSet.has(t.id))
+ .map((t) => t.attributes.tag)
+ )
+ );
+ const counterparts = $derived(
+ p.type === "double"
+ ? twins(items, expense.data ?? [], revenue.data ?? [])
+ : new Set()
+ );
+
+ const flows = $derived(
+ tx.data && accountsReady
+ ? toFlows(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
+ : undefined
+ );
+
+ const swaps = $derived(
+ tx.data && accountsReady
+ ? exchanges(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
+ : []
+ );
+
+ const listReady = $derived(
+ {
+ audit: true,
+ budget: !!budgets.data,
+ category: !!categories.data,
+ default: true,
+ double: !!(expense.data && revenue.data),
+ tag: !!tags.data,
+ }[p.type]
+ );
+
+ const scoped = $derived.by(() => {
+ if (!(flows && listReady)) {
+ return;
+ }
+ switch (p.type) {
+ case "budget":
+ return flows.filter(
+ (f) => f.dir === -1 && f.budget && itemSet.has(f.budget.id)
+ );
+ case "category":
+ return flows.filter((f) => f.category && itemSet.has(f.category.id));
+ case "tag":
+ return flows.filter((f) => f.tags.some((t) => tagNames.has(t)));
+ case "double":
+ return flows.filter((f) => counterparts.has(f.other.id));
+ default:
+ return flows;
+ }
+ });
+
+ const totals = $derived(scoped ? total(scoped) : undefined);
+ const codes = $derived.by(() => {
+ const primary = session.primary?.code;
+ const all = session.currencies.map((c) => c.attributes.code);
+ return primary ? [primary, ...all.filter((c) => c !== primary)] : all;
+ });
+ const available = $derived(totals ? modes(totals, codes) : []);
+ const mode = $derived.by(() => {
+ const wanted = page.url.searchParams.get("cur");
+ if (wanted && available.includes(wanted)) {
+ return wanted;
+ }
+ return available[0] ?? session.primary?.code ?? "";
+ });
+ const grain = $derived(grainOf(p.start, p.end));
+ const template = $derived(templateOf(p.start, p.end));
+ const drill = $derived(readDrill(page.url));
+ const delta = $derived(page.url.searchParams.get("delta"));
+
+ return {
+ accounts: () => accounts,
+ get accountsReady() {
+ return accountsReady;
+ },
+ get available() {
+ return available;
+ },
+ get budgets() {
+ return budgets.data;
+ },
+ byId: () => byId,
+ get categories() {
+ return categories.data;
+ },
+ get codes() {
+ return codes;
+ },
+ get counterparts() {
+ return counterparts;
+ },
+ get delta() {
+ return delta;
+ },
+ get drill() {
+ return drill;
+ },
+ get error() {
+ return tx.error ?? assets.error ?? null;
+ },
+ get exchanges() {
+ return swaps;
+ },
+ get expense() {
+ return expense.data;
+ },
+ get flows() {
+ return flows;
+ },
+ get grain() {
+ return grain;
+ },
+ get groups() {
+ return tx.data;
+ },
+ itemName: (kindKey: string, id: string) =>
+ itemNames.get(`${kindKey}:${id}`),
+ get items() {
+ return items;
+ },
+ get kind() {
+ return kind;
+ },
+ get mode() {
+ return mode;
+ },
+ get params() {
+ return p;
+ },
+ path: (changes: Partial) => reportPath({ ...p, ...changes }),
+ get revenue() {
+ return revenue.data;
+ },
+ get scoped() {
+ return scoped;
+ },
+ get selected() {
+ return selected;
+ },
+ get tags() {
+ return tags.data;
+ },
+ get template() {
+ return template;
+ },
+ get totals() {
+ return totals;
+ },
+ };
+}
+
+export type Report = ReturnType;
+
+const KEY = Symbol("report");
+
+export const provideReport = (report: Report) => setContext(KEY, report);
+export const useReport = () => getContext(KEY);
diff --git a/src/lib/reports/view.ts b/src/lib/reports/view.ts
new file mode 100644
index 0000000..9870062
--- /dev/null
+++ b/src/lib/reports/view.ts
@@ -0,0 +1,75 @@
+import type { StackKey, StackRow } from "#lib/components/charts/chart.js";
+import { tones } from "#lib/components/charts/chart.js";
+import { compactMoney, formatDate } from "#lib/format.js";
+import { session } from "#lib/session.svelte.js";
+import { bucketOf, type Flow, flowValue } from "./flows.js";
+import type { Grain } from "./params.js";
+import { currencyMeta, money } from "./report.svelte.js";
+
+export function bucketLabel(key: string, grain: Grain, short = false): string {
+ if (grain === "year") {
+ return key;
+ }
+ if (grain === "month") {
+ const date = `${key}-01`;
+ return short
+ ? new Intl.DateTimeFormat(session.locale, {
+ month: "short",
+ year: "2-digit",
+ }).format(
+ new Date(Number(key.slice(0, 4)), Number(key.slice(5, 7)) - 1, 1)
+ )
+ : formatDate(date, "month");
+ }
+ return short ? formatDate(key, "short") : formatDate(key, "day");
+}
+
+export const FLOW_KEYS: StackKey[] = [
+ { color: tones.income, key: "in", label: "Earned" },
+ { color: tones.expense, key: "out", label: "Spent" },
+];
+
+export function bucketRows(
+ flows: Iterable,
+ keys: string[],
+ grain: Grain,
+ mode: string
+): StackRow[] {
+ const sums = new Map(keys.map((key) => [key, { in: 0, out: 0 }]));
+ for (const flow of flows) {
+ const value = flowValue(flow, mode);
+ const bucket = sums.get(bucketOf(flow.date, grain));
+ if (value === null || !bucket) {
+ continue;
+ }
+ bucket[flow.dir === 1 ? "in" : "out"] += value;
+ }
+ return keys.map((key) => {
+ const sum = sums.get(key) ?? { in: 0, out: 0 };
+ return {
+ key,
+ label: bucketLabel(key, grain, true),
+ values: { in: sum.in, out: -sum.out },
+ };
+ });
+}
+
+export function modeFormat(mode: string) {
+ return (value: number) => money(mode, value);
+}
+
+export function modeAxis(mode: string) {
+ const symbol =
+ mode === "pc" ? (session.primary?.symbol ?? "") : currencyMeta(mode).symbol;
+ return (value: number) => compactMoney(value, symbol);
+}
+
+export function upright(rows: StackRow[]): StackRow[] {
+ if (rows.some((row) => (row.values.in ?? 0) !== 0)) {
+ return rows;
+ }
+ return rows.map((row) => ({
+ ...row,
+ values: { out: Math.abs(row.values.out ?? 0) },
+ }));
+}
diff --git a/src/routes/reports/+page.svelte b/src/routes/reports/+page.svelte
new file mode 100644
index 0000000..fcb7cff
--- /dev/null
+++ b/src/routes/reports/+page.svelte
@@ -0,0 +1,384 @@
+
+
+
+
+
+
+
+
+ {#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
new file mode 100644
index 0000000..8111c28
--- /dev/null
+++ b/src/routes/reports/[type]/[...rest]/+page.svelte
@@ -0,0 +1,34 @@
+
+
+{#if parsed.ok}
+
+{:else}
+
+
+ {parsed.error}
+ Back to the report generator
+
+{/if}