From eb8f3272d581802e3354dbc94a56e09db89b9587 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Sun, 30 Aug 2026 00:20:56 -0700 Subject: [PATCH 1/2] fix(dashboard): stop the net-worth range offering more history than exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #107 — 6M was always selectable, so a household whose balance history begins two days ago got six months of chart holding two days of net worth: roughly 95% of the plot area hatched fill and dashed line, and a y-axis stretched to -$30K to accommodate a region that is not net worth. The coverage logic itself was already right and is untouched. coveredTrendDelta still measures over the fully covered span only — the comment recording why (the "+2430.7% past 6 months" it replaced) still applies. This changes the frame around it, not the maths. getFullCoverageSince computes the date every account first had a balance, as MAX(MIN(date)) over balance_history rather than by reconstructing a series — the range control needs it on every dashboard load. Ranges reaching back before that are disabled and say why, on the control itself rather than only in a tooltip, so the reason reaches keyboard and screen-reader users. The page then loads the range the hero will actually open on instead of a hard-coded 6M. When coverage began too recently for even the narrowest range, the covered span is plotted on its own with a note naming what is omitted and why. That is the case the issue was filed about, and gating alone did not fix it — it only shrank the hatching from six months to one. Crucially this drops pre-coverage points rather than un-marking them: nothing partial is ever drawn without the dashed-and-hatched treatment, which is the condition this work was filed under. Selecting a range that does include partial data still renders it exactly as before. Also fixes an artifact this introduced: with a two-day window the y-axis showed "$52.9K" four times, because centsToCompact resolves to $100 and the domain was narrower than that. axisTickFormatter picks whole dollars with separators when the spread is small. Deferred deliberately: the assets/debts split (mode="multi"). The chart already supports it, but distinguishing the two series by red/green line colour alone is the textbook CVD failure case, and stroke dashing is unavailable because dashing already means "partial coverage" here. It needs direct end-labels to be done properly, and belongs in its own change rather than bolted onto this one. Mutation testing found the tests were weaker than they looked. rangeSupport was called at describe scope, so those calls ran during collection and Stryker's per-test coverage could not attribute them — three mutants read as survived while the assertions did cover them. Moved into the tests. It also showed the boundary case was never exercised: with a non-midnight asOf the window start and the coverage date differ by hours, so `>=` and `>` both passed. Pinned with a midnight asOf plus the one-day-before case. 100% of mutants killed. Closes #107 --- src/app/(dashboard)/page.tsx | 23 ++++- src/components/atoms/net-worth-area-chart.tsx | 7 +- .../molecules/date-range-selector.tsx | 38 ++++++-- src/components/organisms/net-worth-hero.tsx | 59 ++++++++++-- src/lib/money.ts | 21 +++++ src/lib/net-worth-range.test.ts | 93 +++++++++++++++++++ src/lib/net-worth-range.ts | 86 +++++++++++++++++ src/queries/dashboard.ts | 47 ++++++++++ 8 files changed, 354 insertions(+), 20 deletions(-) create mode 100644 src/lib/net-worth-range.test.ts create mode 100644 src/lib/net-worth-range.ts diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 093c45c4..465b2b78 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -8,6 +8,7 @@ import { getRecentTransactions, getInvestmentsSummary, getLatestActivityMonth, + getFullCoverageSince, } from "@/queries/dashboard"; import { getAccountsByInstitution } from "@/queries/accounts"; import { getBudgetForMonth } from "@/queries/budgets"; @@ -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"; @@ -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)), @@ -112,7 +124,12 @@ export default async function DashboardPage() { share={uncategorizedShare(monthlySpending)} monthLabel={formatMonthLong(spendingMonth)} /> - + ({ ...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 @@ -108,9 +109,9 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" } 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 ( - {RANGES.map((range) => ( - - {range} - - ))} + {RANGES.map((range) => { + const info = supportByRange.get(range); + const disabled = info ? !info.supported : false; + + return ( + + {range} + + ); + })} ); } diff --git a/src/components/organisms/net-worth-hero.tsx b/src/components/organisms/net-worth-hero.tsx index a7452c1c..8b24d4ec 100644 --- a/src/components/organisms/net-worth-hero.tsx +++ b/src/components/organisms/net-worth-hero.tsx @@ -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"; @@ -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(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) { @@ -91,13 +128,13 @@ export function NetWorthHero({ netWorth, initialHistory, initialRange = "6M" }: )} - +
({ + data={trimmed.map((p) => ({ date: p.date, value: p.netWorth, coveredAccounts: p.coveredAccounts, @@ -105,6 +142,16 @@ export function NetWorthHero({ netWorth, initialHistory, initialRange = "6M" }: }))} />
+ {trimmedTo && ( +

+ 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. +

+ )} {coverage.hasPartial && (

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; } diff --git a/src/lib/net-worth-range.test.ts b/src/lib/net-worth-range.test.ts new file mode 100644 index 00000000..9ba2c0be --- /dev/null +++ b/src/lib/net-worth-range.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { rangeSupport, RANGES } from "./net-worth-range"; + +const asOf = new Date("2026-08-29T12:00:00Z"); + +describe("rangeSupport", () => { + describe("when every account has been covered from the start", () => { + it("supports every range", () => { + const support = rangeSupport(null, asOf); + expect(support.every((r) => r.supported)).toBe(true); + }); + + it("recommends the widest range, since all of it is honest", () => { + expect(rangeSupport(null, asOf).find((r) => r.recommended)?.range).toBe("All"); + }); + }); + + describe("when coverage begins partway through", () => { + // Coverage from 2026-06-01: 1M (from 07-29) is inside it, 3M (from 05-29) + // reaches back before it. Called inside each test rather than at describe + // scope — a describe-time call happens during collection, which Stryker's + // per-test coverage cannot attribute to any test, so mutants here looked + // survived when the assertions did cover them. + const partway = () => rangeSupport("2026-06-01", asOf); + + it("supports ranges that start after coverage began", () => { + const byRange = Object.fromEntries(partway().map((r) => [r.range, r])); + expect(byRange["1M"].supported).toBe(true); + }); + + it("does not support ranges reaching back before coverage began", () => { + const byRange = Object.fromEntries(partway().map((r) => [r.range, r])); + expect(byRange["3M"].supported).toBe(false); + expect(byRange["6M"].supported).toBe(false); + expect(byRange["1Y"].supported).toBe(false); + expect(byRange["All"].supported).toBe(false); + }); + + it("recommends the widest supported range", () => { + // Not the narrowest: the reader should see as much honest history as + // there is, just not more than there is. + expect(partway().find((r) => r.recommended)?.range).toBe("1M"); + }); + + it("recommends exactly one range", () => { + expect(partway().filter((r) => r.recommended)).toHaveLength(1); + }); + }); + + describe("when coverage began too recently for any standard range", () => { + // This is the case that produced a 6M chart that was 95% hatching. + const tooRecent = () => rangeSupport("2026-08-28", asOf); + + it("supports no standard range", () => { + expect(tooRecent().some((r) => r.supported)).toBe(false); + }); + + it("recommends nothing, so the caller falls back to the covered span", () => { + expect(tooRecent().some((r) => r.recommended)).toBe(false); + }); + }); + + it("treats coverage starting exactly on the range boundary as supported", () => { + // Midnight asOf so the window start lands exactly on the coverage date: + // 1M back from 2026-08-29T00:00Z is 2026-07-29T00:00Z. With a non-midnight + // asOf the two timestamps differ by hours and the boundary is never + // actually exercised — the comparison could be > or >= and both pass. + const midnight = new Date("2026-08-29T00:00:00Z"); + const support = rangeSupport("2026-07-29", midnight); + expect(support.find((r) => r.range === "1M")?.supported).toBe(true); + }); + + it("does not support a range that starts one day before coverage", () => { + const midnight = new Date("2026-08-29T00:00:00Z"); + const support = rangeSupport("2026-07-30", midnight); + expect(support.find((r) => r.range === "1M")?.supported).toBe(false); + }); + + it("returns the ranges in display order", () => { + expect(rangeSupport(null, asOf).map((r) => r.range)).toEqual([...RANGES]); + }); + + it("explains why an unsupported range is unavailable", () => { + const support = rangeSupport("2026-08-28", asOf); + const reason = support.find((r) => r.range === "6M")?.reason; + // The control should say what is missing rather than just going grey. + expect(reason).toMatch(/Aug 28/); + }); + + it("gives supported ranges no reason text", () => { + expect(rangeSupport(null, asOf).every((r) => r.reason === null)).toBe(true); + }); +}); diff --git a/src/lib/net-worth-range.ts b/src/lib/net-worth-range.ts new file mode 100644 index 00000000..5fcbf3a8 --- /dev/null +++ b/src/lib/net-worth-range.ts @@ -0,0 +1,86 @@ +import { formatDateShort } from "./date-utils"; + +/** + * Which net-worth ranges the household actually has the history to answer. + * + * The range control used to offer six months regardless, so a household whose + * balance history begins two days ago got a chart that was almost entirely + * hatched fill and dashed line — nearly all of its ink spent saying "we don't + * know". The data was reported honestly; the control was writing cheques the + * data could not cash. + * + * This does NOT replace the partial-coverage treatment. Wherever a partial + * region is plotted it must still render dashed and hatched — gating the + * control is what keeps those regions off-screen, not permission to stop + * marking them. + */ + +export const RANGES = ["1M", "3M", "6M", "1Y", "All"] as const; +export type NetWorthRange = (typeof RANGES)[number]; + +/** Months back from `asOf` each range covers. "All" reaches back forever. */ +const MONTHS_BACK: Record = { + "1M": 1, + "3M": 3, + "6M": 6, + "1Y": 12, + All: null, +}; + +export interface RangeSupport { + range: NetWorthRange; + /** Whether every point in this window has a known balance for every account. */ + supported: boolean; + /** Why it is unavailable, for a tooltip. Null when supported. */ + reason: string | null; + /** The widest supported range — what the hero should open on. */ + recommended: boolean; +} + +function windowStart(range: NetWorthRange, asOf: Date): Date | null { + const months = MONTHS_BACK[range]; + if (months === null) return null; // "All" has no start + const start = new Date(asOf); + start.setUTCMonth(start.getUTCMonth() - months); + return start; +} + +/** + * @param fullCoverageSince ISO date on which every tracked account first had a + * known balance, or null when history is complete for all of it. + */ +export function rangeSupport( + fullCoverageSince: string | null, + asOf: Date, +): RangeSupport[] { + const coverageStart = fullCoverageSince ? new Date(`${fullCoverageSince}T00:00:00Z`) : null; + + const evaluated = RANGES.map((range) => { + if (coverageStart === null) { + return { range, supported: true, reason: null }; + } + + const start = windowStart(range, asOf); + // "All" reaches back to the beginning, so it is supported only when + // coverage does too — which is the `coverageStart === null` case above. + const supported = start !== null && start.getTime() >= coverageStart.getTime(); + + return { + range, + supported, + reason: supported + ? null + : `Needs balance history before ${formatDateShort(fullCoverageSince!)}`, + }; + }); + + // The widest supported range, so the reader sees as much honest history as + // exists — just not more than exists. When nothing qualifies, nothing is + // recommended and the caller falls back to plotting the covered span. + let recommendedIndex = -1; + for (let i = 0; i < evaluated.length; i++) { + if (evaluated[i].supported) recommendedIndex = i; + } + + return evaluated.map((r, i) => ({ ...r, recommended: i === recommendedIndex })); +} diff --git a/src/queries/dashboard.ts b/src/queries/dashboard.ts index 7841c7d8..c555509a 100644 --- a/src/queries/dashboard.ts +++ b/src/queries/dashboard.ts @@ -403,3 +403,50 @@ export async function getRecentTransactions( hasSplits: false, })); } + +/** + * The date from which every tracked account has a known balance, or null when + * no account is missing early history. + * + * Net worth is only net worth once every account contributes. Carry-forward + * fills gaps *between* an account's snapshots, but an account whose first-ever + * snapshot lands mid-window contributes $0 to every earlier point — so full + * coverage begins on the day the *last* account got its first snapshot. + * + * Computed as an aggregate rather than by reconstructing the series: the range + * control needs this on every dashboard load, and building a full-history + * series just to read its first covered date would be far more work than + * MAX(MIN(date)). + */ +export async function getFullCoverageSince( + householdId: string, + db: LedgrDb = defaultDb, +): Promise { + const scoped = scopedQuery(householdId, db); + + const liveAccounts = await db + .select({ id: accounts.id }) + .from(accounts) + .where(scoped.where(accounts, notDeleted(accounts))); + + if (liveAccounts.length === 0) return null; + const ids = liveAccounts.map((a) => a.id); + + const firsts = await db + .select({ + accountId: balanceHistory.accountId, + firstDate: sql`MIN(${balanceHistory.date})`, + }) + .from(balanceHistory) + .where(inArray(balanceHistory.accountId, ids)) + .groupBy(balanceHistory.accountId); + + // An account with no history at all is never covered, so no date makes the + // series complete. Say so rather than reporting a date that is not true. + if (firsts.length < ids.length) return null; + + return firsts.reduce( + (latest, row) => (latest === null || row.firstDate > latest ? row.firstDate : latest), + null, + ); +} From 765062d415b8d557ef3642af48d8e7342a351c45 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Tue, 1 Sep 2026 09:23:40 +0900 Subject: [PATCH 2/2] test: cover axisTickFormatter, which this branch added untested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mutation run on this PR reported money.ts at 64.46% with 19 NoCoverage mutants. Comparing against an earlier run — 76.47% covered, same 24 survivors, 0 NoCoverage — shows the 24 are pre-existing and the 19 are new: the axisTickFormatter this branch added had no tests at all. Adds 13, covering both branches of the threshold, the boundary between them at exactly $100 of spread, sign and rounding on the whole-dollar path, and the degenerate inputs (empty series, single point, unordered values, a domain spanning zero). money.ts is now 80.17% with 0 NoCoverage. The remaining 24 survivors predate this branch and belong to #34. --- src/lib/axis-tick-formatter.test.ts | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/lib/axis-tick-formatter.test.ts diff --git a/src/lib/axis-tick-formatter.test.ts b/src/lib/axis-tick-formatter.test.ts new file mode 100644 index 00000000..ddc87d65 --- /dev/null +++ b/src/lib/axis-tick-formatter.test.ts @@ -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"); + }); + }); +});