Skip to content

feat: Reports page — spending trends, budget variance, cash flow#18

Merged
kayodebristol merged 3 commits into
mainfrom
copilot/add-reports-page-analytics
Apr 4, 2026
Merged

feat: Reports page — spending trends, budget variance, cash flow#18
kayodebristol merged 3 commits into
mainfrom
copilot/add-reports-page-analytics

Conversation

Copilot AI commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

Implements the financial analytics Reports page (/reports), which was already registered as a route in index.ts with a 10-transaction guard but had no backing component.

Charts

All charts are pure inline SVG — zero new runtime dependencies.

Section Type Notes
Spending by Category Donut + legend Period-aggregate spend per inferred category, with % share
Cash Flow Grouped bar Monthly income vs expenses side-by-side
Spending Trends Multi-line Top-5 categories over time
Budget Variance Grouped bar + table Current-month actual vs budget limit; over-budget rows highlighted red

Data & reactivity

  • All four charts are $derived from a single loadAll() fetch (transactions, inferences, budgets via Promise.all)
  • Period selector (3 / 6 / 12 months) reactively recomputes every chart without refetching
  • Category spend excludes Income, Transfer, and Refund to avoid inflating spend totals
  • Budget variance compares against the current month (the natural unit for monthlyLimit)

UX details

  • Summary stat-cards row: total income, total expenses, net cash flow, category count
  • Per-chart empty states with contextual links (e.g. "Set up budgets", "Review categorizations")
  • <title> elements on every SVG shape for hover tooltips
  • ARIA: role="img" + aria-labelledby on every chart, role="status" on empty states
  • Responsive: 2-column card grid → stacked on ≤760px

Donut arc path generation

Single-category edge case (degenerate SVG full-circle arc) is handled explicitly:

const sliceAngle =
  spendByCategory.length === 1
    ? 2 * Math.PI - 0.001   // avoid coincident start/end points
    : (cat.total / totalSpend) * 2 * Math.PI;

Copilot AI changed the title [WIP] Add reports page with financial analytics feat: Reports page — spending trends, budget variance, cash flow Apr 4, 2026
Copilot AI requested a review from kayodebristol April 4, 2026 10:27
@kayodebristol kayodebristol marked this pull request as ready for review April 4, 2026 10:45
Copilot AI review requested due to automatic review settings April 4, 2026 10:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /reports Svelte 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.

Comment on lines +78 to +84
const fmt = (n: number) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(n);

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +388 to +394
<p class="page-header__subtitle">
{#if !loading}
{transactions.length} transactions analyzed
{:else}
Loading…
{/if}
</p>

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +131
// ── 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 };

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// ── 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 } : {},
};

Copilot uses AI. Check for mistakes.
Comment on lines +245 to +249
const xLabels = monthlyData.map((m, i) => ({
x: xOf(i),
label: m.label.split(' ')[0],
}));

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
};
});

Copilot uses AI. Check for mistakes.
Comment on lines +317 to +322

const groups = monthlyData.map((m, i) => {
const gx = C_X0 + i * groupW + groupW / 2;
return {
labelX: gx,
label: m.label.split(' ')[0],

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Comment on lines +356 to +359
loadAll().catch(() => {
ctx?.notify.error('Failed to load report data.');
loading = false;
});

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
loadAll().catch(() => {
ctx?.notify.error('Failed to load report data.');
loading = false;
});
loadAll();

Copilot uses AI. Check for mistakes.
Comment on lines +366 to +372
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[];

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
{#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>.

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<a href="/financial-advisor/review" class="chart-link">reviewing categorisations</a>.
<a href="/financial-advisor/review" class="chart-link">reviewing categorizations</a>.

Copilot uses AI. Check for mistakes.

@kayodebristol kayodebristol left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved: CI green + code review complete.

@kayodebristol kayodebristol merged commit d6867ed into main Apr 4, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Reports page — spending trends, budget variance, cash flow

3 participants