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
23 changes: 20 additions & 3 deletions src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getRecentTransactions,
getInvestmentsSummary,
getLatestActivityMonth,
getFullCoverageSince,
} from "@/queries/dashboard";
import { getAccountsByInstitution } from "@/queries/accounts";
import { getBudgetForMonth } from "@/queries/budgets";
Expand All @@ -16,6 +17,7 @@ import { getTransactionSummary } from "@/queries/transactions";
import { getCurrentMonth, shiftMonth, formatMonthLong } from "@/lib/date-utils";
import { uncategorizedShare } from "@/lib/uncategorized-share";
import { budgetPace } from "@/lib/budget-pace";
import { rangeSupport } from "@/lib/net-worth-range";
import { ReviewNudge } from "@/components/molecules/review-nudge";
import { getLayoutForUser } from "@/queries/dashboard-layout";
import { getDefaultLayout } from "@/components/organisms/widgets/registry";
Expand All @@ -33,15 +35,25 @@ export default async function DashboardPage() {
// widget's initial month must match what getMonthlySpending resolved to, so a
// returning user whose latest data is from an earlier month doesn't open on an
// empty current month.
const latestActivityMonth = await withHousehold(householdId, (tx) => getLatestActivityMonth(householdId, tx));
const [latestActivityMonth, fullCoverageSince] = await Promise.all([
withHousehold(householdId, (tx) => getLatestActivityMonth(householdId, tx)),
getFullCoverageSince(householdId),
]);
const spendingMonth = latestActivityMonth ?? getCurrentMonth();
const prevMonth = shiftMonth(spendingMonth, -1);

// Load the range the hero will actually open on. Hard-coding 6M meant a
// household whose history starts recently got six months of chart to hold a
// couple of days of net worth — almost all of it hatched. See
// lib/net-worth-range.
const heroRange =
rangeSupport(fullCoverageSince, new Date()).find((r) => r.recommended)?.range ?? "1M";

const [summary, prevSummary, netWorthHistory, monthlySpending, cashFlow, recentTransactions, accountGroups, budgetData, upcomingBills, investmentsData, savedLayout, unreviewedSummary] =
await Promise.all([
withHousehold(householdId, (tx) => getDashboardSummary(householdId, spendingMonth, tx)),
withHousehold(householdId, (tx) => getDashboardSummary(householdId, prevMonth, tx)),
getNetWorthHistory(householdId, "6M"),
getNetWorthHistory(householdId, heroRange === "All" ? "all" : heroRange),
withHousehold(householdId, (tx) => getMonthlySpending(householdId, spendingMonth, tx)),
withHousehold(householdId, (tx) => getCashFlow(householdId, 6, tx)),
withHousehold(householdId, (tx) => getRecentTransactions(householdId, 5, tx)),
Expand Down Expand Up @@ -112,7 +124,12 @@ export default async function DashboardPage() {
share={uncategorizedShare(monthlySpending)}
monthLabel={formatMonthLong(spendingMonth)}
/>
<NetWorthHero netWorth={summary.netWorth} initialHistory={netWorthHistory} />
<NetWorthHero
netWorth={summary.netWorth}
initialHistory={netWorthHistory}
initialRange={heroRange}
fullCoverageSince={fullCoverageSince}
/>
<DashboardStatRow
summary={summary}
prevSummary={prevSummary}
Expand Down
7 changes: 4 additions & 3 deletions src/components/atoms/net-worth-area-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
ReferenceLine,
ResponsiveContainer,
} from "recharts";
import { centsToDisplay, centsToCompact } from "@/lib/money";
import { centsToDisplay, centsToCompact, axisTickFormatter } from "@/lib/money";
import { formatDateShort } from "@/lib/date-utils";
import { INCOME_COLOR, EXPENSE_COLOR, POSITIVE_COLOR, UNCOVERED_COLOR } from "@/lib/chart-colors";
import { coverageBoundary } from "@/lib/net-worth-coverage";
Expand Down Expand Up @@ -74,6 +74,7 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
if (mode === "single") {
const points = data as SinglePoint[];
const boundary = coverageBoundary(points.map((p) => ({ ...p, netWorth: p.value })));
const formatTick = axisTickFormatter(points.map((p) => p.value));

// Split the series at the boundary so the stretch that isn't yet net worth
// renders as a muted dashed line instead of a confident solid one. The
Expand Down Expand Up @@ -108,9 +109,9 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" }
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis dataKey="date" tickFormatter={formatDateShort} tick={AXIS_TICK} axisLine={false} tickLine={false} minTickGap={48} />
<YAxis
tickFormatter={centsToCompact}
tickFormatter={formatTick}
tick={AXIS_TICK}
width={52}
width={68}
axisLine={false}
tickLine={false}
tickCount={4}
Expand Down
38 changes: 30 additions & 8 deletions src/components/molecules/date-range-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
"use client";

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

const RANGES = ["1M", "3M", "6M", "1Y", "All"] as const;
import { RANGES, type RangeSupport } from "@/lib/net-worth-range";

interface DateRangeSelectorProps {
value: string;
onChange: (range: string) => void;
/**
* Per-range availability. Omit to leave every range selectable — the reports
* series has no coverage data, so gating there would disable ranges for no
* stated reason.
*/
support?: RangeSupport[];
}

export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps) {
export function DateRangeSelector({ value, onChange, support }: DateRangeSelectorProps) {
const supportByRange = new Map(support?.map((r) => [r.range, r]) ?? []);

return (
<ToggleGroup
value={[value]}
Expand All @@ -19,11 +26,26 @@ export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps) {
}}
size="sm"
>
{RANGES.map((range) => (
<ToggleGroupItem key={range} value={range} className="text-xs px-2">
{range}
</ToggleGroupItem>
))}
{RANGES.map((range) => {
const info = supportByRange.get(range);
const disabled = info ? !info.supported : false;

return (
<ToggleGroupItem
key={range}
value={range}
disabled={disabled}
// The reason is on the control itself rather than only in a
// tooltip, so it reaches keyboard and screen-reader users too — a
// range that goes grey without saying why reads as a bug.
title={info?.reason ?? undefined}
aria-description={info?.reason ?? undefined}
className="text-xs px-2"
>
{range}
</ToggleGroupItem>
);
})}
</ToggleGroup>
);
}
59 changes: 53 additions & 6 deletions src/components/organisms/net-worth-hero.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"use client";

import { useState, useTransition } from "react";
import { useState, useTransition, useMemo } from "react";
import { NetWorthAreaChart } from "@/components/atoms/net-worth-area-chart";
import { DateRangeSelector } from "@/components/molecules/date-range-selector";
import { centsToDisplay } from "@/lib/money";
import { formatDateShort } from "@/lib/date-utils";
import { coverageBoundary, coveredTrendDelta } from "@/lib/net-worth-coverage";
import { rangeSupport, RANGES } from "@/lib/net-worth-range";
import { cn } from "@/lib/utils";
import type { NetWorthPoint } from "@/queries/dashboard";

Expand All @@ -21,18 +22,54 @@ interface NetWorthHeroProps {
netWorth: number;
initialHistory: NetWorthPoint[];
initialRange?: string;
/**
* Date from which every account has a known balance, or null when history is
* complete. Drives which ranges the control offers — see lib/net-worth-range.
*/
fullCoverageSince?: string | null;
}

export function NetWorthHero({ netWorth, initialHistory, initialRange = "6M" }: NetWorthHeroProps) {
const [range, setRange] = useState(initialRange);
export function NetWorthHero({
netWorth,
initialHistory,
initialRange,
fullCoverageSince = null,
}: NetWorthHeroProps) {
// Availability is fixed for a given household, so it is computed once rather
// than per render. `asOf` only needs day precision here.
const support = useMemo(
() => rangeSupport(fullCoverageSince, new Date()),
[fullCoverageSince],
);

// Open on the widest range the data can actually answer. When nothing
// qualifies — coverage began within the last month — fall back to the
// narrowest, which still renders its uncovered stretch dashed and hatched.
const defaultRange =
initialRange ?? support.find((r) => r.recommended)?.range ?? RANGES[0];

const [range, setRange] = useState<string>(defaultRange);
const [history, setHistory] = useState(initialHistory);
const [isLoading, startTransition] = useTransition();

// When coverage began too recently for even the narrowest range, plotting the
// window anyway spends almost the whole chart on a stretch that is not net
// worth. Show the covered span instead — the pre-coverage points are dropped
// rather than silently un-marked, so nothing partial is ever drawn unmarked,
// and the note below still says what is missing.
const noRangeFits = !support.some((r) => r.supported);
const fullBoundary = coverageBoundary(history);
const trimmed =
noRangeFits && fullBoundary.hasPartial && fullBoundary.index > 0
? history.slice(fullBoundary.index)
: history;

// Measured across the fully covered span only. A delta from a partial
// baseline reports accounts appearing, not money arriving — that is what
// produced the "+2430.7% past 6 months" this replaces.
const delta = coveredTrendDelta(history);
const coverage = coverageBoundary(history);
const coverage = coverageBoundary(trimmed);
const trimmedTo = trimmed !== history ? fullBoundary : null;
const [dollars, cents] = centsToDisplay(netWorth).split(".");

function handleRangeChange(next: string) {
Expand Down Expand Up @@ -91,20 +128,30 @@ export function NetWorthHero({ netWorth, initialHistory, initialRange = "6M" }:
)}
</div>
</div>
<DateRangeSelector value={range} onChange={handleRangeChange} />
<DateRangeSelector value={range} onChange={handleRangeChange} support={support} />
</div>
<div className={cn("h-56 mt-3 transition-opacity", isLoading && "opacity-50")}>
<NetWorthAreaChart
mode="single"
seriesName="Net worth"
data={history.map((p) => ({
data={trimmed.map((p) => ({
date: p.date,
value: p.netWorth,
coveredAccounts: p.coveredAccounts,
totalAccounts: p.totalAccounts,
}))}
/>
</div>
{trimmedTo && (
<p className="mt-2 text-xs text-muted-foreground">
Showing full history from {formatDateShort(trimmedTo.date ?? "")}. Earlier dates are
omitted because only {trimmedTo.minCovered === trimmedTo.maxPartialCovered
? trimmedTo.minCovered
: `${trimmedTo.minCovered}–${trimmedTo.maxPartialCovered}`}{" "}
of {trimmedTo.totalAccounts} accounts had balance history then, so those totals are not
net worth. Longer ranges unlock as history accumulates.
</p>
)}
{coverage.hasPartial && (
<p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span
Expand Down
75 changes: 75 additions & 0 deletions src/lib/axis-tick-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { axisTickFormatter } from "./money";

/**
* Lives in its own file rather than money.test.ts so the chart-axis concern is
* findable — money.test.ts is already long and about conversion, not display.
*/
describe("axisTickFormatter", () => {
describe("wide domains keep the compact form", () => {
it.each([
[[0, 10_000_00], 100_000, "$1K"],
[[0, 500_000_00], 5_294_244, "$52.9K"],
[[0, 900_000_000], 1_500_000_00, "$1.5M"],
])("spread %j formats %d as %s", (values, cents, expected) => {
expect(axisTickFormatter(values as number[])(cents)).toBe(expected);
});
});

describe("narrow domains switch to whole dollars", () => {
// A two-day window on a near-flat series spans less than the $100 that the
// compact form resolves to, so every tick rendered identically — the axis
// printed "$52.9K" four times.
it("distinguishes ticks the compact form would collapse", () => {
const format = axisTickFormatter([5_294_244, 5_294_244]);
expect(format(5_294_244)).toBe("$52,942");
expect(format(5_295_244)).toBe("$52,952");
});

it("groups thousands with separators", () => {
expect(axisTickFormatter([100, 200])(1_234_567_89)).toBe("$1,234,568");
});

it("keeps the sign on negatives", () => {
expect(axisTickFormatter([100, 200])(-5_294_244)).toBe("-$52,942");
});

it("rounds to the nearest dollar rather than truncating", () => {
expect(axisTickFormatter([100, 200])(1_50)).toBe("$2");
expect(axisTickFormatter([100, 200])(1_49)).toBe("$1");
});
});

describe("the threshold between the two", () => {
// $100 is exactly the compact form's resolution, so it is the boundary.
it("uses compact at exactly $100 of spread", () => {
expect(axisTickFormatter([0, 100_00])(5_294_244)).toBe("$52.9K");
});

it("uses whole dollars just below $100 of spread", () => {
expect(axisTickFormatter([0, 99_99])(5_294_244)).toBe("$52,942");
});
});

describe("degenerate inputs", () => {
it("falls back to compact for an empty series", () => {
// No data means no domain to measure; the chart renders its empty state
// anyway, so the formatter just must not throw.
expect(axisTickFormatter([])(5_294_244)).toBe("$52.9K");
});

it("treats a single point as a zero spread", () => {
expect(axisTickFormatter([5_294_244])(5_294_244)).toBe("$52,942");
});

it("measures spread regardless of value order", () => {
// Max/min, not first/last — a descending series has the same spread.
expect(axisTickFormatter([500_000_00, 0])(5_294_244)).toBe("$52.9K");
expect(axisTickFormatter([0, 500_000_00])(5_294_244)).toBe("$52.9K");
});

it("handles a domain spanning zero", () => {
expect(axisTickFormatter([-300_000_00, 600_000_00])(5_294_244)).toBe("$52.9K");
});
});
});
21 changes: 21 additions & 0 deletions src/lib/money.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ export function centsToCompact(cents: number): string {
return `${sign}$${Math.round(abs)}`;
}

/**
* Axis tick formatter chosen from how wide the plotted values actually are.
*
* `centsToCompact` rounds to one decimal of a thousand — $100 of resolution —
* so a chart whose values span less than that renders every tick with the same
* text. That happens whenever the visible window is short: a household whose
* balance history starts a couple of days ago got four ticks all reading
* "$52.9K".
*/
export function axisTickFormatter(valuesInCents: number[]): (cents: number) => string {
if (valuesInCents.length === 0) return centsToCompact;

const spread = Math.max(...valuesInCents) - Math.min(...valuesInCents);
if (spread >= 10_000) return centsToCompact;

// Narrow domain: whole dollars with separators, distinguishable without the
// noise of cents on an axis.
return (cents: number) =>
`${cents < 0 ? "-" : ""}$${Math.round(Math.abs(cents) / 100).toLocaleString("en-US")}`;
}

function trimZero(s: string): string {
return s.endsWith(".0") ? s.slice(0, -2) : s;
}
Expand Down
Loading
Loading