feat: Reports page — spending trends, budget variance, cash flow#18
Conversation
Agent-Logs-Url: https://github.com/plures/pares-modulus/sessions/d50d53ce-371e-435f-8224-108ac453e3c0 Co-authored-by: kayodebristol <3579196+kayodebristol@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds the missing Financial Advisor Reports page (/reports) to back the already-registered route, providing analytics visualizations and summaries derived from transactions, category inferences, and budgets.
Changes:
- Implemented
/reportsSvelte page with derived analytics for category spend, cash flow, spending trends, and budget variance. - Added inline-SVG chart rendering (donut, line, grouped bars) with empty states and responsive layout.
- Implemented a reactive period selector (3/6/12 months) that recomputes charts without refetching.
| const fmt = (n: number) => | ||
| new Intl.NumberFormat('en-US', { | ||
| style: 'currency', | ||
| currency: 'USD', | ||
| maximumFractionDigits: 0, | ||
| }).format(n); | ||
|
|
There was a problem hiding this comment.
The currency formatter fmt forces maximumFractionDigits: 0, which rounds amounts and can make totals/variance disagree with other pages (e.g., Transactions/Budgets show cents). Consider using the same currency formatting behavior across the plugin (or at least keep cents for the table/stats) to avoid misleading numbers.
| const fmt = (n: number) => | |
| new Intl.NumberFormat('en-US', { | |
| style: 'currency', | |
| currency: 'USD', | |
| maximumFractionDigits: 0, | |
| }).format(n); | |
| const currencyFormatter = new Intl.NumberFormat('en-US', { | |
| style: 'currency', | |
| currency: 'USD', | |
| }); | |
| const fmt = (n: number) => currencyFormatter.format(n); |
| <p class="page-header__subtitle"> | ||
| {#if !loading} | ||
| {transactions.length} transactions analyzed | ||
| {:else} | ||
| Loading… | ||
| {/if} | ||
| </p> |
There was a problem hiding this comment.
The header shows {transactions.length} transactions analyzed, but the charts are computed only from the selected period (periodMonths). After changing the period selector, this count will remain the total imported transactions rather than the number included in the current report period, which is misleading. Consider showing the in-period count or adjusting the copy to clarify what is being counted.
| // ── Derived: per-month income / expense / by-category breakdown ──────────── | ||
| const monthlyData = $derived( | ||
| monthPrefixes.map((prefix): MonthSummary => { | ||
| const byCategory: Record<string, number> = {}; | ||
| let income = 0; | ||
| let expenses = 0; | ||
| for (const tx of transactions) { | ||
| if (!tx.date.startsWith(prefix)) continue; | ||
| if (tx.amount > 0) { | ||
| income += tx.amount; | ||
| } else { | ||
| const spend = Math.abs(tx.amount); | ||
| expenses += spend; | ||
| const cat = catMap.get(tx.id); | ||
| if (cat && cat !== 'Income' && cat !== 'Transfer' && cat !== 'Refund') { | ||
| byCategory[cat] = (byCategory[cat] ?? 0) + spend; | ||
| } | ||
| } | ||
| } | ||
| return { prefix, label: monthLabel(prefix), income, expenses, byCategory }; |
There was a problem hiding this comment.
monthlyData is computed by iterating over all transactions once per month prefix (nested loop). This makes recomputation O(periodMonths × transactions) every time periodMonths changes (and any time transactions changes). Consider pre-grouping transactions by month in a single pass (e.g., build a Map<prefix, MonthSummary> or Map<prefix, totals>), then slice to the requested period.
| // ── Derived: per-month income / expense / by-category breakdown ──────────── | |
| const monthlyData = $derived( | |
| monthPrefixes.map((prefix): MonthSummary => { | |
| const byCategory: Record<string, number> = {}; | |
| let income = 0; | |
| let expenses = 0; | |
| for (const tx of transactions) { | |
| if (!tx.date.startsWith(prefix)) continue; | |
| if (tx.amount > 0) { | |
| income += tx.amount; | |
| } else { | |
| const spend = Math.abs(tx.amount); | |
| expenses += spend; | |
| const cat = catMap.get(tx.id); | |
| if (cat && cat !== 'Income' && cat !== 'Transfer' && cat !== 'Refund') { | |
| byCategory[cat] = (byCategory[cat] ?? 0) + spend; | |
| } | |
| } | |
| } | |
| return { prefix, label: monthLabel(prefix), income, expenses, byCategory }; | |
| const monthlySummariesByPrefix = $derived( | |
| (() => { | |
| const summaries = new Map< | |
| string, | |
| { income: number; expenses: number; byCategory: Record<string, number> } | |
| >(); | |
| for (const tx of transactions) { | |
| const prefix = tx.date.slice(0, 7); | |
| let summary = summaries.get(prefix); | |
| if (!summary) { | |
| summary = { income: 0, expenses: 0, byCategory: {} }; | |
| summaries.set(prefix, summary); | |
| } | |
| if (tx.amount > 0) { | |
| summary.income += tx.amount; | |
| } else { | |
| const spend = Math.abs(tx.amount); | |
| summary.expenses += spend; | |
| const cat = catMap.get(tx.id); | |
| if (cat && cat !== 'Income' && cat !== 'Transfer' && cat !== 'Refund') { | |
| summary.byCategory[cat] = (summary.byCategory[cat] ?? 0) + spend; | |
| } | |
| } | |
| } | |
| return summaries; | |
| })(), | |
| ); | |
| // ── Derived: per-month income / expense / by-category breakdown ──────────── | |
| const monthlyData = $derived( | |
| monthPrefixes.map((prefix): MonthSummary => { | |
| const summary = monthlySummariesByPrefix.get(prefix); | |
| return { | |
| prefix, | |
| label: monthLabel(prefix), | |
| income: summary?.income ?? 0, | |
| expenses: summary?.expenses ?? 0, | |
| byCategory: summary ? { ...summary.byCategory } : {}, | |
| }; |
| const xLabels = monthlyData.map((m, i) => ({ | ||
| x: xOf(i), | ||
| label: m.label.split(' ')[0], | ||
| })); | ||
|
|
There was a problem hiding this comment.
X-axis labels use m.label.split(' ')[0], which drops the year from monthLabel (e.g., "Apr 2026" → "Apr"). For 12-month periods spanning year boundaries, the chart will show ambiguous duplicate month labels. Consider including year (or a shorter year suffix) in labels when needed.
| const xLabels = monthlyData.map((m, i) => ({ | |
| x: xOf(i), | |
| label: m.label.split(' ')[0], | |
| })); | |
| const monthParts = monthlyData.map(m => m.label.split(' ')); | |
| const monthCounts = monthParts.reduce( | |
| (counts, parts) => { | |
| const month = parts[0] ?? ''; | |
| counts.set(month, (counts.get(month) ?? 0) + 1); | |
| return counts; | |
| }, | |
| new Map<string, number>(), | |
| ); | |
| const useYearSuffix = Array.from(monthCounts.values()).some(count => count > 1); | |
| const xLabels = monthlyData.map((m, i) => { | |
| const [month, year] = m.label.split(' '); | |
| const shortYear = year ? `'${year.slice(-2)}` : ''; | |
| return { | |
| x: xOf(i), | |
| label: useYearSuffix && shortYear ? `${month} ${shortYear}` : month, | |
| }; | |
| }); |
|
|
||
| const groups = monthlyData.map((m, i) => { | ||
| const gx = C_X0 + i * groupW + groupW / 2; | ||
| return { | ||
| labelX: gx, | ||
| label: m.label.split(' ')[0], |
There was a problem hiding this comment.
Cash flow month labels also use m.label.split(' ')[0], which drops the year and can become ambiguous across year boundaries. Consider using a label format that includes the year when the reporting period spans multiple years.
| const groups = monthlyData.map((m, i) => { | |
| const gx = C_X0 + i * groupW + groupW / 2; | |
| return { | |
| labelX: gx, | |
| label: m.label.split(' ')[0], | |
| const monthTokenCounts = monthlyData.reduce((counts, m) => { | |
| const monthToken = m.label.split(' ')[0]; | |
| counts.set(monthToken, (counts.get(monthToken) ?? 0) + 1); | |
| return counts; | |
| }, new Map<string, number>()); | |
| const groups = monthlyData.map((m, i) => { | |
| const gx = C_X0 + i * groupW + groupW / 2; | |
| const monthToken = m.label.split(' ')[0]; | |
| return { | |
| labelX: gx, | |
| label: (monthTokenCounts.get(monthToken) ?? 0) > 1 ? m.label : monthToken, |
| loadAll().catch(() => { | ||
| ctx?.notify.error('Failed to load report data.'); | ||
| loading = false; | ||
| }); |
There was a problem hiding this comment.
loadAll() already catches errors and sets loading = false in finally, so the .catch(...) attached in onMount will never run (and is currently redundant). Either let loadAll() rethrow so the caller can handle it, or remove the outer .catch and keep error handling in one place.
| loadAll().catch(() => { | |
| ctx?.notify.error('Failed to load report data.'); | |
| loading = false; | |
| }); | |
| loadAll(); |
| txCollection?.query() ?? Promise.resolve([]), | ||
| infCollection?.query({ field: 'category' }) ?? Promise.resolve([]), | ||
| budgetCollection?.query() ?? Promise.resolve([]), | ||
| ]); | ||
| transactions = txData as Transaction[]; | ||
| inferences = infData as TransactionInference[]; | ||
| budgets = budgetData as Budget[]; |
There was a problem hiding this comment.
In loadAll, the Promise.resolve([]) fallbacks cause type widening, and the results are then forced back with as Transaction[] / as TransactionInference[] / as Budget[]. Prefer typed fallbacks (e.g., Promise.resolve([] as Transaction[])) or branch on collection existence so you can avoid unsafe casts.
| txCollection?.query() ?? Promise.resolve([]), | |
| infCollection?.query({ field: 'category' }) ?? Promise.resolve([]), | |
| budgetCollection?.query() ?? Promise.resolve([]), | |
| ]); | |
| transactions = txData as Transaction[]; | |
| inferences = infData as TransactionInference[]; | |
| budgets = budgetData as Budget[]; | |
| txCollection?.query() ?? Promise.resolve([] as Transaction[]), | |
| infCollection?.query({ field: 'category' }) ?? | |
| Promise.resolve([] as TransactionInference[]), | |
| budgetCollection?.query() ?? Promise.resolve([] as Budget[]), | |
| ]); | |
| transactions = txData; | |
| inferences = infData; | |
| budgets = budgetData; |
| {#if lineChartData === null} | ||
| <div class="chart-empty" role="status"> | ||
| Not enough categorized data to show trends. Try | ||
| <a href="/financial-advisor/review" class="chart-link">reviewing categorisations</a>. |
There was a problem hiding this comment.
Spelling is inconsistent with the rest of the plugin: other strings use "categorization/categorizations" (US spelling), but this link says "reviewing categorisations". Align spelling to avoid mixed UI copy.
| <a href="/financial-advisor/review" class="chart-link">reviewing categorisations</a>. | |
| <a href="/financial-advisor/review" class="chart-link">reviewing categorizations</a>. |
kayodebristol
left a comment
There was a problem hiding this comment.
Auto-approved: CI green + code review complete.
Implements the financial analytics Reports page (
/reports), which was already registered as a route inindex.tswith a 10-transaction guard but had no backing component.Charts
All charts are pure inline SVG — zero new runtime dependencies.
Data & reactivity
$derivedfrom a singleloadAll()fetch (transactions, inferences, budgets viaPromise.all)Income,Transfer, andRefundto avoid inflating spend totalsmonthlyLimit)UX details
<title>elements on every SVG shape for hover tooltipsrole="img"+aria-labelledbyon every chart,role="status"on empty states≤760pxDonut arc path generation
Single-category edge case (degenerate SVG full-circle arc) is handled explicitly: