Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/app/(dashboard)/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,20 @@ export default async function ReportsPage({
let sankeyData;
let safeToSpendData;
let cashFlowBarData;
let spendingTotalIncome;

switch (tab) {
case "spending":
spendingData = await withHousehold(householdId, (tx) =>
getSpendingByCategory(householdId, filters, tx, compPeriod));
case "spending": {
// Income comes along for the share-of-income figure in the headline.
const [spending, income] = await Promise.all([
withHousehold(householdId, (tx) =>
getSpendingByCategory(householdId, filters, tx, compPeriod)),
withHousehold(householdId, (tx) => getIncomeVsExpense(householdId, filters, tx)),
]);
spendingData = spending;
spendingTotalIncome = income.reduce((s, r) => s + r.income, 0);
break;
}
case "income-expense": {
const [ie, ieCat] = await Promise.all([
withHousehold(householdId, (tx) => getIncomeVsExpense(householdId, filters, tx)),
Expand Down Expand Up @@ -139,6 +147,7 @@ export default async function ReportsPage({
cashFlowBarData={cashFlowBarData}
safeToSpendData={safeToSpendData}
comparisonLabel={compLabel}
spendingTotalIncome={spendingTotalIncome}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
Expand Down
13 changes: 7 additions & 6 deletions src/components/atoms/spending-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ interface SpendingChartProps {
onItemClick?: (item: { id: string | null; name: string }) => void;
}

// The aggregated "Other" row (built below from categories past the top 8) is
// not a real category — give it a fixed neutral color instead of cycling back
// into CHART_COLORS, which would collide with an earlier slice's color. Real
// uncategorized spend also carries a null id, so key off the synthetic flag
// rather than the id to avoid recoloring legitimate rows.
// Two rows take the neutral rather than a palette slot. "Other" (rolled up from
// categories past the top 8) is not a real category, and cycling back into
// CHART_COLORS would collide with an earlier slice. Uncategorized is not a
// category either — it is the absence of one, and as the largest slice in most
// households it was taking CHART_COLORS[0], the loudest blue, making "we do not
// know" the visual hero of the chart.
function colorAt(item: SpendingChartItem, i: number): string {
if (item.synthetic) return "var(--chart-neutral)";
if (item.synthetic || item.id === null) return "var(--chart-neutral)";
return CHART_COLORS[i % CHART_COLORS.length];
}

Expand Down
30 changes: 18 additions & 12 deletions src/components/molecules/comparison-badge.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { comparisonState } from "@/lib/comparison-state";

interface ComparisonBadgeProps {
current: number;
Expand All @@ -9,20 +10,25 @@ interface ComparisonBadgeProps {
}

export function ComparisonBadge({ current, previous, periodLabel, pill, invertColor }: ComparisonBadgeProps) {
if (previous === null || previous === 0) {
if (pill) {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground rounded-full bg-muted px-2 py-0.5">
</span>
);
}
return null;
const state = comparisonState(current, previous);

// A category with no baseline row is new. It used to render as an empty cell,
// which read exactly like "no change".
if (state.kind === "new") {
return (
<span
className={`inline-flex items-center gap-1 text-xs text-muted-foreground${
pill ? " rounded-full bg-muted px-2 py-0.5" : ""
}`}
>
New
</span>
);
}

const change = ((current - previous) / previous) * 100;
const isUp = change > 0;
const isFlat = Math.abs(change) < 0.5;
const change = state.percent;
const isUp = state.kind === "up";
const isFlat = state.kind === "flat";

return (
<span
Expand Down
17 changes: 17 additions & 0 deletions src/components/molecules/date-range-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ interface DateRangePopoverProps {
active: boolean;
/** Text shown after "Date:" on the trigger; null renders a bare "Date". */
triggerValue: string | null;
/**
* The dates a named preset resolves to, shown in muted text beside it. A chip
* reading only "Last 3 months" never says which three months, and the
* resolved range appeared nowhere else on the page.
*/
triggerDetail?: string | null;
/** Current custom-range input values ("" when unset). */
from: string;
to: string;
Expand All @@ -42,6 +48,7 @@ export function DateRangePopover({
selectedId,
active,
triggerValue,
triggerDetail,
from,
to,
onSelectPreset,
Expand All @@ -66,6 +73,16 @@ export function DateRangePopover({
<>
<span className={cn("font-normal", active ? "opacity-70" : "text-muted-foreground")}>Date:</span>
<span className="ml-1 max-w-[160px] truncate">{triggerValue}</span>
{triggerDetail && (
<span
className={cn(
"ml-1.5 hidden truncate text-xs sm:inline",
active ? "opacity-60" : "text-muted-foreground",
)}
>
{triggerDetail}
</span>
)}
</>
) : (
"Date"
Expand Down
10 changes: 10 additions & 0 deletions src/components/organisms/report-filter-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ export function ReportFilterBar({ accounts, categories }: ReportFilterBarProps)
return null;
})();

// A preset names a window without saying which one. Resolve it here so the
// chip prints the dates the report below it actually used.
const dateDetail = (() => {
if (!dateActive || !effectivePreset) return null;
const { from, to } = rangeToDateBounds(effectivePreset);
if (!from) return null;
return `${formatDateShort(from)} – ${formatDateShort(to)}`;
})();

function handleDatePreset(id: string) {
const { from, to } = rangeToDateBounds(id);
updateFilters({ from, to, preset: id === "all" ? null : id });
Expand All @@ -95,6 +104,7 @@ export function ReportFilterBar({ accounts, categories }: ReportFilterBarProps)
selectedId={effectivePreset}
active={dateActive}
triggerValue={dateValue}
triggerDetail={dateDetail}
from={fromParam ?? ""}
to={toParam ?? ""}
onSelectPreset={handleDatePreset}
Expand Down
89 changes: 73 additions & 16 deletions src/components/organisms/report-spending.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
"use client";

import { useState } from "react";
import { Wallet, Layers, Crown } from "lucide-react";
import { CategoryIconTile } from "@/components/atoms/category-icon";
import { ChartViewToggle } from "@/components/atoms/chart-view-toggle";
import { SpendingChart } from "@/components/atoms/spending-chart";
import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar";
import { ComparisonBadge } from "@/components/molecules/comparison-badge";
import { DrillDownSheet, type DrillDownFilter } from "@/components/organisms/drill-down-sheet";
import {
Expand All @@ -19,6 +17,7 @@ import {
import { centsToDisplay } from "@/lib/money";
import { activateOnKey } from "@/lib/a11y";
import { CHART_COLORS } from "@/lib/chart-colors";
import { formatDateShort } from "@/lib/date-utils";
import type { SpendingRow } from "@/queries/reports";

interface ReportSpendingProps {
Expand All @@ -27,6 +26,8 @@ interface ReportSpendingProps {
dateFrom: string;
dateTo: string;
accountIds?: string[];
/** Total income over the same range, for the share-of-income figure. */
totalIncome?: number;
}

export function ReportSpending({
Expand All @@ -35,8 +36,11 @@ export function ReportSpending({
dateFrom,
dateTo,
accountIds,
totalIncome,
}: ReportSpendingProps) {
const [view, setView] = useState<"donut" | "bar">("donut");
// Nine categories spanning three orders of magnitude is a size comparison,
// which bars read directly and a donut does not.
const [view, setView] = useState<"donut" | "bar">("bar");
const [drillDown, setDrillDown] = useState<DrillDownFilter | null>(null);

const chartData = data.map((r) => ({
Expand All @@ -46,14 +50,10 @@ export function ReportSpending({
}));

const totalSpent = data.reduce((s, r) => s + r.total, 0);
const topCategory = data.length > 0 ? data[0] : null;
const summaryItems: SummaryItem[] = [
{ label: "Total Spent", value: totalSpent, color: "default", icon: Wallet },
{ label: "Categories", value: data.length, format: "number", icon: Layers },
...(topCategory
? [{ label: `Top: ${topCategory.categoryName}`, value: topCategory.total, icon: Crown } as SummaryItem]
: []),
];
const uncategorized = data.find((r) => r.categoryId === null)?.total ?? 0;
const categorized = totalSpent - uncategorized;
const shareOfIncome = totalIncome && totalIncome > 0 ? (totalSpent / totalIncome) * 100 : null;
const rangeLabel = `${formatDateShort(dateFrom)} – ${formatDateShort(dateTo)}`;

function handleDrillDown(item: { id: string | null; name: string }) {
// Keep the null: it means "uncategorized", not "every category".
Expand All @@ -66,7 +66,53 @@ export function ReportSpending({

return (
<div className="space-y-4">
<ReportSummaryBar items={summaryItems} />
{/* The old bar read Total Spent · Categories · Top: X. "Categories: 18" is
a number no decision turns on, and the crown landed on Uncategorized
whenever it was the largest line — a trophy for a data-quality gap.
What a reader needs instead is how much of the total is unaccounted
for, and what the total is measured against. */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-lg border p-4 lg:col-span-1">
<div className="text-xs text-muted-foreground">Total spent · {rangeLabel}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">{centsToDisplay(totalSpent)}</div>
{totalSpent > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: CHART_COLORS[0] }} />
Categorized
<span className="tabular-nums text-foreground">{centsToDisplay(categorized)}</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: "var(--chart-neutral)" }} />
Uncategorized
<span className="tabular-nums text-foreground">{centsToDisplay(uncategorized)}</span>
</span>
</div>
)}
</div>

<div className="rounded-lg border p-4">
<div className="text-xs text-muted-foreground">Compared with</div>
<div className="mt-1 text-lg font-medium">
{compLabel ? compLabel.replace(/^vs\s+/, "") : "Nothing — showing all time"}
</div>
{compLabel && (
<div className="mt-1 text-xs text-muted-foreground">the preceding period, same length</div>
)}
</div>

<div className="rounded-lg border p-4">
<div className="text-xs text-muted-foreground">Share of income</div>
<div className="mt-1 text-lg font-medium tabular-nums">
{shareOfIncome === null ? "—" : `${shareOfIncome.toFixed(1)}%`}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{totalIncome && totalIncome > 0
? `of ${centsToDisplay(totalIncome)} received`
: "no income recorded in this range"}
</div>
</div>
</div>

<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Spending by Category</h3>
Expand All @@ -83,6 +129,7 @@ export function ReportSpending({
<TableRow className="hover:bg-transparent text-muted-foreground">
<TableHead className="h-auto px-3 py-2">Category</TableHead>
<TableHead className="h-auto px-3 py-2 text-right">Amount</TableHead>
<TableHead className="h-auto px-3 py-2 text-right">% of total</TableHead>
{compLabel && <TableHead className="h-auto px-3 py-2 text-right">Change</TableHead>}
</TableRow>
</TableHeader>
Expand All @@ -106,13 +153,20 @@ export function ReportSpending({
<div className="flex items-center gap-3">
<CategoryIconTile
name={row.categoryIcon}
// Uncategorized takes the neutral here too, so the table
// and the chart agree about what is and is not a category.
style={
i < 8
row.categoryId === null
? {
color: CHART_COLORS[i],
backgroundColor: CHART_COLORS[i].replace(")", " / 0.12)"),
color: "var(--chart-neutral)",
backgroundColor: "color-mix(in oklab, var(--chart-neutral) 12%, transparent)",
}
: undefined
: i < 8
? {
color: CHART_COLORS[i],
backgroundColor: CHART_COLORS[i].replace(")", " / 0.12)"),
}
: undefined
}
/>
<div className="min-w-0">
Expand All @@ -126,6 +180,9 @@ export function ReportSpending({
<TableCell className="px-3 py-2 text-right tabular-nums font-medium">
{centsToDisplay(row.total)}
</TableCell>
<TableCell className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{totalSpent > 0 ? `${((row.total / totalSpent) * 100).toFixed(1)}%` : "—"}
</TableCell>
{compLabel && (
<TableCell className="px-3 py-2 text-right">
<ComparisonBadge
Expand Down
4 changes: 4 additions & 0 deletions src/components/organisms/report-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ interface ReportTabsProps {
cashFlowBarData?: IncomeExpenseRow[];
safeToSpendData?: SafeToSpendResult;
comparisonLabel: string | null;
/** Income over the selected range — the Spending tab's share-of-income figure. */
spendingTotalIncome?: number;
/**
* The range the figures on screen were actually computed over. Passed down
* rather than re-read from the URL, so a drill-down can never query a
Expand All @@ -69,6 +71,7 @@ export function ReportTabs({
cashFlowBarData,
safeToSpendData,
comparisonLabel,
spendingTotalIncome,
dateFrom,
dateTo,
accountIds,
Expand Down Expand Up @@ -109,6 +112,7 @@ export function ReportTabs({
<ReportSpending
data={spendingData}
comparisonLabel={comparisonLabel}
totalIncome={spendingTotalIncome}
dateFrom={dateFrom}
dateTo={dateTo}
accountIds={accountIds}
Expand Down
35 changes: 35 additions & 0 deletions src/lib/comparison-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, test, expect } from "vitest";
import { comparisonState } from "./comparison-state";

describe("comparisonState", () => {
test("a category absent from the baseline is new, not unchanged", () => {
// Both used to render as an empty cell, so "we have never seen this before"
// and "it did not move" looked identical in the Change column.
expect(comparisonState(5_000, null)).toEqual({ kind: "new" });
});

test("a baseline of zero is also new — there was nothing to grow from", () => {
expect(comparisonState(5_000, 0)).toEqual({ kind: "new" });
});

test("spending that went up", () => {
expect(comparisonState(150_00, 100_00)).toEqual({ kind: "up", percent: 50 });
});

test("spending that came down", () => {
expect(comparisonState(50_00, 100_00)).toEqual({ kind: "down", percent: -50 });
});

test("a move under half a percent reads as flat", () => {
expect(comparisonState(100_30, 100_00)).toEqual({ kind: "flat", percent: 0.3 });
expect(comparisonState(100_00, 100_00)).toEqual({ kind: "flat", percent: 0 });
});

test("half a percent is a move, not flat", () => {
expect(comparisonState(100_50, 100_00).kind).toBe("up");
});

test("a category that spent nothing this period against a real baseline", () => {
expect(comparisonState(0, 100_00)).toEqual({ kind: "down", percent: -100 });
});
});
Loading
Loading