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
4 changes: 4 additions & 0 deletions src/components/atoms/net-worth-area-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ReferenceArea,
ReferenceLine,
ResponsiveContainer,
Legend,
} from "recharts";
import { centsToDisplay, centsToCompact, axisTickFormatter } from "@/lib/money";
import { formatDateShort } from "@/lib/date-utils";
Expand Down Expand Up @@ -175,6 +176,9 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
domain={["auto", "auto"]}
/>
<Tooltip content={<CustomTooltip />} />
{/* Three series identified by colour alone, and only on hover, until
this. A legend is not optional past one series. */}
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="netWorth"
Expand Down
40 changes: 35 additions & 5 deletions src/components/atoms/trend-line-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, LabelList } from "recharts";
import { centsToDisplay } from "@/lib/money";
import { formatMonthShort } from "@/lib/date-utils";

Expand All @@ -9,6 +9,27 @@ interface TrendLineChartProps {
categories: { name: string; color: string }[];
}

/** Room on the right for the end-of-line labels. */
const LABEL_GUTTER = 96;

/**
* Label only the final point of a series, so each line is named where it ends.
* `LabelList` otherwise prints a number on every point, which is the thing the
* dataviz rules call out: never a value on every mark.
*/
function endLabel(lastIndex: number, name: string) {
return function EndLabel(props: { x?: string | number; y?: string | number; index?: number }) {
const x = Number(props.x);
const y = Number(props.y);
if (props.index !== lastIndex || !Number.isFinite(x) || !Number.isFinite(y)) return null;
return (
<text x={x + 8} y={y} dy="0.32em" fontSize={11} fill="var(--muted-foreground)">
{name}
</text>
);
};
}

export function TrendLineChart({ data, categories: cats }: TrendLineChartProps) {
if (data.length === 0) {
return (
Expand All @@ -18,9 +39,11 @@ export function TrendLineChart({ data, categories: cats }: TrendLineChartProps)
);
}

const lastIndex = data.length - 1;

return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<LineChart data={data} margin={{ top: 5, right: LABEL_GUTTER, bottom: 5, left: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="period" tickFormatter={formatMonthShort} tick={{ fontSize: 11 }} />
<YAxis
Expand All @@ -33,13 +56,20 @@ export function TrendLineChart({ data, categories: cats }: TrendLineChartProps)
{cats.map((cat) => (
<Line
key={cat.name}
type="monotone"
// Straight segments between the months that were actually measured.
// A smoothed curve through monthly totals drew spending on days no
// money moved — a lump sum on one day arced up mid-month and back.
type="linear"
dataKey={cat.name}
name={cat.name}
stroke={cat.color}
strokeWidth={2}
dot={false}
/>
// The dots are the measurement; the line between them is inference.
dot={{ r: 3, strokeWidth: 2, stroke: "var(--background)" }}
activeDot={{ r: 5 }}
>
<LabelList dataKey={cat.name} content={endLabel(lastIndex, cat.name)} />
</Line>
))}
</LineChart>
</ResponsiveContainer>
Expand Down
19 changes: 11 additions & 8 deletions src/components/organisms/report-net-worth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,30 @@
import { Wallet, TrendingUp } from "lucide-react";
import { NetWorthAreaChart } from "@/components/atoms/net-worth-area-chart";
import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar";
import { netWorthChange } from "@/lib/net-worth-change";
import type { NetWorthSeriesPoint } from "@/queries/dashboard";

interface ReportNetWorthProps {
data: NetWorthSeriesPoint[];
}

export function ReportNetWorth({ data }: ReportNetWorthProps) {
const latest = data.length > 0 ? data[data.length - 1] : null;
const earliest = data.length > 0 ? data[0] : null;
const change = latest && earliest ? latest.netWorth - earliest.netWorth : 0;
const changePct = earliest && earliest.netWorth !== 0
? ((change / Math.abs(earliest.netWorth)) * 100).toFixed(1)
: "0.0";
const { current, change, percent } = netWorthChange(data);

const summaryItems: SummaryItem[] = [
{ label: "Current Net Worth", value: latest?.netWorth ?? 0, color: "dynamic", icon: Wallet },
{ label: "Current Net Worth", value: current, color: "dynamic", icon: Wallet },
{
label: "Change",
value: change,
color: "dynamic",
secondaryLabel: `${change >= 0 ? "+" : ""}${changePct}%`,
// A percentage off a near-zero opening balance describes the balance, not
// the period — $5.15 to $539.79 printed as "+10381.3%" as the headline.
// Below the cutoff the absolute change stands on its own, with a line
// saying why the ratio is missing rather than leaving a silent gap.
secondaryLabel:
percent !== null
? `${percent >= 0 ? "+" : ""}${percent.toFixed(1)}%`
: "from a near-zero opening balance",
icon: TrendingUp,
},
];
Expand Down
52 changes: 36 additions & 16 deletions src/components/organisms/report-trends.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Wallet, CalendarDays } from "lucide-react";
import { TrendLineChart } from "@/components/atoms/trend-line-chart";
import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar";
import { Checkbox } from "@/components/ui/checkbox";
import { CHART_COLORS } from "@/lib/chart-colors";
import { MAX_TREND_SERIES, trendSeriesColor } from "@/lib/series-colors";
import type { CategoryTrendRow } from "@/queries/reports";

interface ReportTrendsProps {
Expand All @@ -14,24 +14,28 @@ interface ReportTrendsProps {

export function ReportTrends({ data }: ReportTrendsProps) {
const allCategories = [...new Set(data.map((r) => r.categoryName))];
const [selected, setSelected] = useState<Set<string>>(new Set(allCategories.slice(0, 10)));
const [selected, setSelected] = useState<Set<string>>(
new Set(allCategories.slice(0, MAX_TREND_SERIES)),
);

function toggle(name: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else if (next.size < 10) {
} else if (next.size < MAX_TREND_SERIES) {
next.add(name);
}
return next;
});
}

const selectedList = allCategories.filter((c) => selected.has(c));
const cats = selectedList.map((name, i) => ({
// Colour keys off the category, not off its position in this filtered list —
// otherwise unchecking one category repainted every line that remained.
const cats = selectedList.map((name) => ({
name,
color: CHART_COLORS[i % CHART_COLORS.length],
color: trendSeriesColor(allCategories, name),
}));

// Pivot data for Recharts: { period, CatA: 1000, CatB: 2000, ... }
Expand Down Expand Up @@ -59,20 +63,36 @@ export function ReportTrends({ data }: ReportTrendsProps) {
<ReportSummaryBar items={summaryItems} />
<h3 className="text-lg font-medium">Category Trends</h3>

<div className="h-[300px]">
<div className="h-[340px]">
<TrendLineChart data={chartData} categories={cats} />
</div>

<div className="flex flex-wrap gap-3">
{allCategories.map((name) => (
<label key={name} className="flex items-center gap-1.5 text-sm cursor-pointer">
<Checkbox
checked={selected.has(name)}
onCheckedChange={() => toggle(name)}
/>
{name}
</label>
))}
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Comparing {selected.size} of {MAX_TREND_SERIES}. Four lines is what the
chart palette can keep apart at a glance — clear one to add another.
</p>
<div className="flex flex-wrap gap-3">
{allCategories.map((name) => {
const isSelected = selected.has(name);
const atCapacity = !isSelected && selected.size >= MAX_TREND_SERIES;
return (
<label
key={name}
className={`flex items-center gap-1.5 text-sm ${
atCapacity ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
<Checkbox
checked={isSelected}
disabled={atCapacity}
onCheckedChange={() => toggle(name)}
/>
{name}
</label>
);
})}
</div>
</div>

</div>
Expand Down
8 changes: 8 additions & 0 deletions src/components/organisms/sankey-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ interface LayoutNode extends SankeyNode {
x1?: number;
y0?: number;
y1?: number;
/** Set by d3-sankey: the larger of the node's in- and out-flow. */
value?: number;
}

interface LayoutLink {
Expand Down Expand Up @@ -171,6 +173,11 @@ export function SankeyChart({ nodes, links, onNodeClick, height = 400 }: SankeyC
clickable ? () => onNodeClick(node.id, node.type) : undefined,
)}
/>
{/* A money-flow diagram that never states an amount leaves the
reader guessing whether the widest ribbon is $2,300 or
$23,000. The value sits in muted ink beside the name, not in
the node's own colour, which would read as another encoding.
Nodes too thin for a label keep their value in the tooltip. */}
{nodeHeight > 12 && (
<text
x={node.type === "income" ? x0 - 4 : x1 + 4}
Expand All @@ -180,6 +187,7 @@ export function SankeyChart({ nodes, links, onNodeClick, height = 400 }: SankeyC
className="text-[10px] fill-foreground"
>
{node.name}
<tspan className="fill-muted-foreground"> · {centsToDisplay(node.value ?? 0)}</tspan>
</text>
)}
</g>
Expand Down
56 changes: 56 additions & 0 deletions src/lib/net-worth-change.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, test, expect } from "vitest";
import { netWorthChange, MIN_BASE_FOR_PERCENT } from "./net-worth-change";

const pt = (netWorth: number) => ({ netWorth });

describe("netWorthChange", () => {
test("reports the current value and the movement across the range", () => {
const r = netWorthChange([pt(1_000_00), pt(1_200_00), pt(1_500_00)]);
expect(r.current).toBe(1_500_00);
expect(r.change).toBe(500_00);
});

test("a percentage off a meaningful base is kept", () => {
const r = netWorthChange([pt(1_000_00), pt(1_500_00)]);
expect(r.percent).toBeCloseTo(50, 5);
});

test("a percentage off a near-zero base is suppressed", () => {
// The reported case: an opening balance of $5.15 turned a $534 gain into
// "+10381.3%" — a number about the opening balance, not about the year.
const r = netWorthChange([pt(515), pt(539_79)]);
expect(r.change).toBe(534_64);
// 534.64 / 5.15 is the +10381.3% the tile actually printed.
expect(r.change / 515 * 100).toBeCloseTo(10381.4, 0);
expect(r.percent).toBeNull();
});

test("the cutoff is the base's magnitude, so a negative opening still qualifies", () => {
const r = netWorthChange([pt(-1_000_00), pt(-500_00)]);
expect(r.percent).toBeCloseTo(50, 5);
});

test("a base exactly at the cutoff still reports a percentage", () => {
const r = netWorthChange([pt(MIN_BASE_FOR_PERCENT), pt(MIN_BASE_FOR_PERCENT * 2)]);
expect(r.percent).toBeCloseTo(100, 5);
});

test("a base one cent under the cutoff does not", () => {
expect(netWorthChange([pt(MIN_BASE_FOR_PERCENT - 1), pt(50_000_00)]).percent).toBeNull();
});

test("an opening balance of exactly zero cannot yield a ratio", () => {
expect(netWorthChange([pt(0), pt(1_000_00)]).percent).toBeNull();
});

test("a single point has nothing to compare against", () => {
const r = netWorthChange([pt(1_000_00)]);
expect(r.current).toBe(1_000_00);
expect(r.change).toBe(0);
expect(r.percent).toBeNull();
});

test("no points at all", () => {
expect(netWorthChange([])).toEqual({ current: 0, change: 0, percent: null });
});
});
31 changes: 31 additions & 0 deletions src/lib/net-worth-change.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* A percentage needs a base worth dividing by. Below $100 of opening net worth
* the ratio stops describing the period and starts describing the base: a $5.15
* opening balance turned a $534 gain into "+10381.3%", which was the headline
* figure on the tile.
*/
export const MIN_BASE_FOR_PERCENT = 100_00;

export interface NetWorthChange {
current: number;
change: number;
/** `null` when the opening balance is too small for a ratio to mean anything. */
percent: number | null;
}

export function netWorthChange(points: readonly { netWorth: number }[]): NetWorthChange {
if (points.length === 0) return { current: 0, change: 0, percent: null };

const opening = points[0].netWorth;
const current = points[points.length - 1].netWorth;
const change = current - opening;

// A single snapshot is not a period: "0%" would be a claim about a span the
// data does not cover.
const comparable = points.length > 1 && Math.abs(opening) >= MIN_BASE_FOR_PERCENT;
return {
current,
change,
percent: comparable ? (change / Math.abs(opening)) * 100 : null,
};
}
36 changes: 36 additions & 0 deletions src/lib/series-colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, test, expect } from "vitest";
import { CHART_COLORS } from "./chart-colors";
import { MAX_TREND_SERIES, trendSeriesColor } from "./series-colors";

const CATEGORIES = [
"Rent/Mortgage", "Groceries", "Car Payment", "Home Goods",
"Gas", "Electric", "Electronics", "Internet", "Phone", "Clothing",
];

describe("trendSeriesColor", () => {
test("a category's colour does not depend on what else is selected", () => {
// The old code coloured by position in the *filtered* list, so unchecking
// one category repainted every line that remained.
const before = trendSeriesColor(CATEGORIES, "Gas");
const after = trendSeriesColor(CATEGORIES, "Gas");
expect(after).toBe(before);
expect(before).toBe(CHART_COLORS[4]);
});

test("the first categories take the palette in its published order", () => {
expect(CATEGORIES.slice(0, 8).map((c) => trendSeriesColor(CATEGORIES, c))).toEqual(CHART_COLORS);
});

test("a category the list does not know gets the neutral, never a guessed hue", () => {
expect(trendSeriesColor(CATEGORIES, "Nonexistent")).toBe("var(--chart-neutral)");
});
});

describe("MAX_TREND_SERIES", () => {
test("is within what the palette can separate when every line overlaps", () => {
// Validated with the dataviz palette checker: at 5 simultaneous series no
// subset of this palette clears the all-pairs CVD floor in both themes.
expect(MAX_TREND_SERIES).toBeLessThanOrEqual(4);
expect(MAX_TREND_SERIES).toBeGreaterThan(1);
});
});
Loading