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 @@ + + + +
+ + + + + + + + + + + {#each rows as row (row.id)} + {@const active = + report.drill?.box === "balances" && report.drill.key === row.id} + + + + + + + {/each} + + + {#each sums as [code, sum], i (code)} + + + + + + + {/each} + +
Name + Balance at start of period + + Balance at end of period + Difference
+ + {money(row.code, row.start)}{money(row.code, row.end)} + {money(row.code, row.end - row.start, true)} +
Sum ({code}){money(code, sum.start)}{money(code, sum.end)} + {money(code, sum.end - sum.start, true)} +
+
+
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")} + + {/if} + {#if show("description")} + + {/if} + {#if show("balance_before")} + + {/if} + {#if show("amount")} + + {/if} + {#if show("balance_after")} + + {/if} + {#if show("date")} + + {/if} + {#if show("from")} + + {/if} + {#if show("to")} + + {/if} + {#if show("budget")} + + {/if} + {#if show("category")} + + {/if} + {#if show("bill")} + + {/if} + {#if show("created_at")} + + {/if} + {#if show("updated_at")} + + {/if} + {#if show("notes")} + + {/if} + {#each dateColumns as key (key)} + + {/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} + {#if show("balance_before")} + + {/if} + {#if show("amount")} + + {/if} + {#if show("balance_after")} + + {/if} + {#if show("date")} + + {/if} + {#if show("from")} + + {/if} + {#if show("to")} + + {/if} + {#if show("budget")} + + {/if} + {#if show("category")} + + {/if} + {#if show("bill")} + + {/if} + {#if show("created_at")} + + {/if} + {#if show("updated_at")} + + {/if} + {#if show("notes")} + + {/if} + {#each dateColumns as key (key)} + + {/each} + + {/each} + +
TypeDescriptionBalance beforeAmountBalance afterDateFromToBudgetCategorySubscriptionCreated atUpdated atNotes + {key.replace("_date", "").replace("process", "processing")} + date +
+ + + + {#if row.title} + {row.title} + ({s.description}) + {:else} + {s.description} + {/if} + + + {money(code, row.before)} + + {money(code, row.amount)} + + {money(code, row.after)} + + {formatDate(s.date, "medium")} + + {s.source_name} + + {s.destination_name} + {s.budget_name ?? ""}{s.category_name ?? ""}{s.bill_name ?? ""} + {formatDateTime(row.created)} + + {formatDateTime(row.updated)} + + {s.notes ?? ""} + + {formatDate(s[key] ?? null, "medium")} +
+
+

+ 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)} + + {/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 @@ + + + +
+ + + + + + + + + + + + + + + {#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} + + + + + + + + + + + {/each} + + + {#each sums as [code, sum], i (code)} + + + + + + + + + + {/each} + +
BudgetDateBudgetedpct.Spentpct.LeftOverspent
+ + + {#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} +
Sum ({code}){money(code, sum.budgeted)} + {money(code, -sum.spent)} +
+
+
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 @@ + + + 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)} +
+
{column.label}
+ +
+ {/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")} + + {/if} + + {#if has("account")} + + {/if} + {#if has("other")} + + {/if} + {#if has("category")} + + {/if} + {#if has("budget")} + + {/if} + + + + + {#each flows as flow (`${flow.journal}:${flow.dir}`)} + + {#if has("date")} + + {/if} + + {#if has("account")} + + {/if} + {#if has("other")} + + {/if} + {#if has("category")} + + {/if} + {#if has("budget")} + + {/if} + + + {/each} + +
DateDescriptionAccountCounterpartyCategoryBudgetAmount
+ {formatDate(flow.date, "short")} + + {flow.description} + + {flow.asset.name} + + {flow.other.name} + + {flow.category?.name ?? ""} + + {flow.budget?.name ?? ""} + + {money(flow.currency, flow.dir * flow.amount)} +
+
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} +
+ + + + + + + + + + + {#each rows as row (row.code)} + + + + + + + {/each} + +
CurrencyInOutDifference
{row.label} + + + + + {money(row.code, row.in - row.out, true)} +
+
+
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 @@ + + +
+ + + + + {#each cols as col (col.key)} + + {/each} + {#if average} + + {/if} + + + + + {#each sorted as row (row.key)} + {@const rowTotal = amountOf(row.totals, mode, side)} + + + {#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)} + + {/each} + {#if average} + + {/if} + + + {/each} + + + + + {#each cols as col, i (col.key)} + + {/each} + {#if average} + + {/if} + + + +
+ {nameLabel} + + {col.label} + + Average + Sum
+ + + {#if multi && cell} + + {:else if !multi && (value || diff)} + + {/if} + + {short((sign * rowTotal) / Math.max(1, cols.length))} + + {#if multi} + + {:else} + {short(sign * rowTotal)} + {/if} +
+ Sum + + {#if multi} +
+ +
+ {:else if colTotals[i]} + + {/if} +
+ {short((sign * grand) / Math.max(1, cols.length))} + + {#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)} + + {/each} +
+ {/each} +
+ {:else if width > 0} +
+ + {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} + + {/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} + + + + +
+ +
+ + + {chosen.size} + of {options.length} +
+
+
+ {#each groups as [group, items] (group)} + {#if group} + {@const all = items.every((o) => chosen.has(o.id))} + + {/if} + {#each items as option (option.id)} + {@const index = visible.indexOf(option)} + + {/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 @@ + + +
+
+ +
+ + go({ accounts })} + {options} + value={accountValue} + bind:open={accountsOpen} + /> +
+ {#if list} +
+ + go({ items })} + options={itemOptions} + value={report.items} + bind:open={itemsOpen} + /> +
+ {/if} +
+ Date range +
+
+ + setRange(event.currentTarget.value, p.end)} + type="date" + value={p.start} + bind:this={startInput} + > + to + setRange(p.start, event.currentTarget.value)} + type="date" + value={p.end} + > + +
+ +
+
+
+ {#if report.available.length > 1} +
+ + Charts in + +
+ {#each report.available as mode (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 @@ + + + +
+ {#if loading} +

Loading

+ {:else if empty} +

{emptyText}

+ {:else} + {@render children()} + {/if} + {#if !row && report.drill?.box === id && !loading} + + {/if} +
+
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} +
  • + +
  • + {/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} + + {: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} + + {:else} +
+ {/if} + {/each} +
+ {/each} +
+
+
+

+ Biggest days +

+
    + {#each top as [day, info] (day)} +
  • + +
  • + {/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 @@ + + + +
+ + + + + + + + + + + + {#each rows as row (row.id)} + {@const code = row.a.currency_code ?? ""} + + + + + + + + {/each} + +
NameMinimum amountMaximum amountExpected onPaid
+ {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)} +
+ {formatDate(paid.date, "short")} + {money( + paid.currency_code ?? code, + -Number(paid.amount ?? 0) + )} +
+ {:else} + Not charged (yet) + {/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 @@ + + + + +
+ + + + + {#each columns as column (column.kind)} + + {/each} + + + + {#each shown as row (row.key)} + {@const active = + report.drill?.box === box && report.drill.key === row.key} + + + {#each columns as column (column.kind)} + + {/each} + + {/each} + + {#if footer && footer.n > 0} + + + + {#each columns as column (column.kind)} + + {/each} + + + {/if} +
{nameLabel} + {column.label} +
+ + {#if counts && row.totals.n > 1} + {row.totals.n} + transactions + {/if} + + {#each lines(row.totals, column.kind) as line (line.code)} +
{line.text}
+ {/each} +
Sum + {#if !( + column.kind.startsWith("pct") || column.kind.startsWith("avg") + )} + {#each lines(footer, column.kind) as line (line.code)} +
{line.text}
+ {/each} + {/if} +
+
+{#if limit && sorted.length > limit} + +{/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 @@ + + + + +
+
{ + 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 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}