Skip to content

Stop the net-worth range offering more history than exists - #127

Merged
KenTaniguchi-R merged 2 commits into
mainfrom
fix/107-net-worth-range-coverage
Sep 1, 2026
Merged

KenTaniguchi-R merged 2 commits into
mainfrom
fix/107-net-worth-range-coverage

Conversation

@KenTaniguchi-R

Copy link
Copy Markdown
Owner

The range control always offered 6M, 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.

What this does not change

The coverage logic is untouched. coveredTrendDelta still measures over the fully covered span only, and the comment recording why — the "+2430.7% past 6 months" it replaced — still stands. This changes the frame around the number, not the maths.

What it changes

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, not only in a tooltip, so the reason reaches keyboard and screen-reader users. A range that goes grey without explanation reads as a bug. 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 case is what the issue was actually about, and gating alone did not fix it — it only shrank the hatching from six months to one. I checked in the browser rather than assuming the gating was enough.

The honesty condition holds: pre-coverage points are dropped, not un-marked. Nothing partial is ever drawn without the dashed-and-hatched treatment. Selecting a range that does include partial data renders it exactly as before.

An artifact this introduced, and fixed

With a two-day window the y-axis showed $52.9K four timescentsToCompact resolves to $100 and the domain was narrower than that. axisTickFormatter now picks whole dollars with separators when the spread is small.

Deliberately deferred

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 as a fallback because dashing already means "partial coverage" in this chart. It needs direct end-labels to be done properly, and belongs in its own change rather than bolted onto this one.

What mutation testing caught

My 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 genuinely did cover them. Moved inside the tests.

It also showed the boundary case was never actually 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.

net-worth-range.ts: 100% (21 killed, 0 survived).

Verification

  • 536 unit tests pass (13 new)
  • tsc --noEmit and eslint src/ clean
  • Walked the running app: ranges correctly gated, covered-span fallback renders with its explanation, axis labels distinguishable

Closes #107.

🤖 Generated with Claude Code

@KenTaniguchi-R
KenTaniguchi-R force-pushed the fix/107-net-worth-range-coverage branch 2 times, most recently from 1e30ddf to aeff6a1 Compare August 31, 2026 23:58
KenTaniguchi-R added a commit that referenced this pull request Sep 1, 2026
Follow-up to #130, which did not fix the race. Part of #125.

#130 added `standalone: true` on the theory that the node_modules layout was
what raced. It is not. pnpm/action-setup clears its install directory before
writing to it regardless of install mode, so with the default ~/setup-pnpm that
`rmdir` is shared by every job on a self-hosted box. #130's own run passed only
because nothing else was running beside it; the moment two jobs started together
again, both #127 and #129 reproduced the identical error on branches that
already contained the fix:

  ENOTEMPTY: directory not empty, rmdir
    '~/setup-pnpm/node_modules/.bin/store/v11/files/03'

`dest: ${{ runner.temp }}/setup-pnpm` is the actual fix: runner.temp is per-job
and cleaned up by the runner, so no two jobs share a directory to race on.

That alone would regress something worth keeping. The pnpm store lived under the
install dir, so making the install dir disposable would make the store disposable
too, and every job would re-download ~1100 packages — contradicting the reason
this workflow deliberately skips `cache: pnpm`. So the store is pinned to a
stable path with `--store-dir ~/.pnpm-store`.

That separation is also what makes the original failure impossible to repeat: a
shared content-addressed store is pnpm's normal mode and is safe for concurrent
readers. It was only fragile here because it happened to sit inside the one
directory the action deletes.
…xists

#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
@KenTaniguchi-R
KenTaniguchi-R force-pushed the fix/107-net-worth-range-coverage branch from aeff6a1 to eb8f327 Compare September 1, 2026 00:11
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.
@KenTaniguchi-R

Copy link
Copy Markdown
Owner Author

Note on the mutation (diff) check, so the red is not mistaken for a defect in this diff.

The run reports 39.13%, under the 60 threshold. Per file:

File Score Killed Survived NoCoverage
lib/net-worth-range.ts 100.00 21 0 0
lib/money.ts 64.46 → 80.17 78 → 97 24 19 → 0
queries/dashboard.ts 0.00 0 0 111

Two different things in there, and only one was mine to fix:

Mine, now fixed. money.ts showed 19 NoCoverage mutants. Comparing to an earlier run — 76.47% covered, the same 24 survivors, 0 NoCoverage — makes clear the 24 predate this branch and the 19 were new: axisTickFormatter was added here with no tests. Covered in 765062d; the file is now 80.17% with 0 NoCoverage. The remaining 24 survivors are pre-existing and belong to #34.

Structural, and not fixable here. queries/dashboard.ts reports 0.00% with 111 NoCoverage because DB-backed query files are deliberately excluded from vitest.stryker.config.ts — that exclusion is #100's fix for the container tax. Every mutant in the file therefore lands as NoCoverage rather than as a real result, and 111 of them dominate the average. This branch adds getFullCoverageSince to that file, which is why it appears in the diff scope at all.

So the score is arithmetic over a denominator that includes 111 mutants no unit test could ever reach. That is exactly #103, and it is why the workflow sets continue-on-error: true on this job with the comment "Non-blocking (reports only) … a blocking gate would fail legitimate PRs".

Blocking checks — typecheck · lint · test, CodeQL, Analyze, GitGuardian — all pass.

@KenTaniguchi-R
KenTaniguchi-R merged commit bfb59ee into main Sep 1, 2026
4 of 5 checks passed
@KenTaniguchi-R
KenTaniguchi-R deleted the fix/107-net-worth-range-coverage branch September 1, 2026 00:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Net worth range control offers ranges the coverage cannot support

1 participant