+ 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