diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 093c45c..465b2b7 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 a7452c1..8b24d4e 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 && (

{ + 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"); + }); + }); +}); diff --git a/src/lib/money.ts b/src/lib/money.ts index cd17683..fb0c4a6 100644 --- a/src/lib/money.ts +++ b/src/lib/money.ts @@ -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; } diff --git a/src/lib/net-worth-range.test.ts b/src/lib/net-worth-range.test.ts new file mode 100644 index 0000000..9ba2c0b --- /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 0000000..5fcbf3a --- /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 7841c7d..c555509 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, + ); +}