Skip to content

fix(reports): compare against an equal-length baseline period - #149

Merged
KenTaniguchi-R merged 1 commit into
mainfrom
fix/143-comparison-baseline
Sep 2, 2026
Merged

fix(reports): compare against an equal-length baseline period#149
KenTaniguchi-R merged 1 commit into
mainfrom
fix/143-comparison-baseline

Conversation

@KenTaniguchi-R

Copy link
Copy Markdown
Owner

Closes #143.

Every percentage in the Spending tab's Change column was measured against a window of a different length than the one it described.

Range Days
Window Jun 2 – Sep 2, 2026 92
Baseline (before) Feb 2 – May 31, 2026 118
Baseline (after) Mar 2 – Jun 2, 2026 92

28% longer, plus a 2-day gap. The UI stated it itself — every badge read vs Feb 2 – May 31.

Cause

rangeToDateBounds("3M") returns a rolling window (Jun 2 → Sep 2) that touches four calendar months, so shiftDateRange's month-span arithmetic evaluated to 4:

(toDate.getFullYear() - fromDate.getFullYear()) * 12 +
(toDate.getMonth() - fromDate.getMonth()) + 1     // 4, not 3

and the end-of-month snap then pulled to back to May 31.

Calendar-month arithmetic is only meaningful for a range that genuinely spans whole months, so it now applies only when the range runs from the 1st to the last day of a month. Apr 1 – Jun 30 still shifts to Jan 1 – Mar 31 (existing test unchanged). A rolling window shifts by its own length instead, so the baseline is always the period immediately before it and exactly as long.

Two related defects in the same flow

The landing view showed no comparison at all. page.tsx read a bare /reports as all-time, while ReportFilterBar defaulted the same URL to the 3M preset and labelled it "Last 3 months". Identical URL and data, two different UIs — the Change column only appeared after re-picking the preset you were already on. Both now read resolveReportDateSelection, a single shared resolver, so they cannot drift apart again.

Dates never showed a year. formatDateShort formatted { month, day } only, so a custom 2019 range rendered as "Jan 1 – Mar 31" with nothing saying which year, and a year-over-year comparison label was indistinguishable from this year's. The year now appears only when it is not the current year — recent dates and chart axis ticks (which use this same helper as a tick formatter) are unchanged.

Testing

  • src/lib/date-utils.test.ts — the 118 → 92 regression pinned directly, equal-length assertion, back/forward symmetry, a property test over 1–200 day spans, month-aligned non-regression, and the year-formatting rule (fixture years derived from new Date() per the repo's time-in-tests convention).
  • src/lib/report-date-selection.test.ts — new, covering the bare default, ?preset=3M resolving identically to it, all-time, custom ranges, preset-wins-over-from/to, and half-specified ranges.

Typecheck and lint clean. Full suite 1055/1056 — the single failure, investment-queries.test.ts > returns dayChange from holdings_history, is pre-existing hardcoded-date rot that fails identically on main.

Note on verification

Unlike #142, this one is verified by tests only, not in a live browser. The dev Postgres container and its ledgr_pgdata volume were removed during the integration runs (likely the testcontainers reaper), taking the dev dataset with them, so there was no logged-in app to re-check against. The regression is pinned by unit tests at the exact values observed in production before the data was lost.

🤖 Generated with Claude Code

Every percentage in the Spending tab's Change column was measured against
a window of a different length than the one it described. For today's
"Last 3 months" (Jun 2 - Sep 2, 92 days) the baseline was Feb 2 - May 31
— 118 days, 28% longer, with a 2-day gap.

`rangeToDateBounds` returns a *rolling* window, which touches four
calendar months, so `shiftDateRange`'s month-span arithmetic counted 4
rather than 3 and the end-of-month snap then stretched the baseline
further. Calendar-month arithmetic is only meaningful for a range that
actually spans whole months, so it is now used only when the range runs
from the 1st to the last day of a month (Apr 1 - Jun 30 still shifts to
Jan 1 - Mar 31). A rolling window shifts by its own length instead, so
the baseline is always the period immediately before it and exactly as
long.

Two related defects in the same flow:

- The landing view showed no comparison at all. `page.tsx` read a bare
  /reports as all-time while ReportFilterBar defaulted it to the 3M preset
  and rendered "Last 3 months" — the same URL and data with two different
  UIs, and the Change column only appeared once you re-picked the preset
  you were already on. Both now read `resolveReportDateSelection`, so they
  cannot drift again.

- `formatDateShort` never emitted a year, so a custom 2019 range rendered
  as "Jan 1 - Mar 31" and a year-over-year comparison label was
  indistinguishable from the current year's. The year is now shown only
  when it is not the current one, which leaves recent dates and chart axis
  ticks unchanged.

Closes #143
@KenTaniguchi-R
KenTaniguchi-R merged commit 7005953 into main Sep 2, 2026
4 of 5 checks passed
@KenTaniguchi-R
KenTaniguchi-R deleted the fix/143-comparison-baseline branch September 2, 2026 11:29
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.

Reports comparison measures a 118-day baseline against a 92-day window

1 participant