Skip to content

fix(frontend): seed Purchase History dates from the local calendar day - #1321

Merged
IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-51
Oct 24, 2026
Merged

IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-51

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

The Purchase History From/To inputs were seeded with the UTC calendar day, while <input type="date"> shows the local day, so evening users west of UTC saw tomorrow in To and morning users east of UTC saw yesterday. Because /api/history filters on whole UTC days, a user east of UTC filtering on today silently lost this morning's purchases. This PR seeds the inputs from the local day through a shared helper and widens the request to the UTC days that cover the local range, so no purchase near either edge is dropped.

Closes #51

What changed

  • New frontend/src/lib/local-date.ts: toLocalDateInputValue and parseLocalDateInputValue moved out of apikeys.ts (which now imports them), plus a new localDayRangeToUtcDays.
  • frontend/src/history.ts: all four sites use toLocalDateInputValue (initHistoryDateRange From/To and snapDateInputsToPurchases min/max). loadHistory passes the inputs through localDayRangeToUtcDays, so the request carries the UTC days that cover local From midnight through the end of the local To day. Empty or malformed values pass through unchanged.
  • localDayRangeToUtcDays keeps every range the server accepts as typed within its 366-day cap (MaxHistoryDateRangeDays, internal/api/handler_history.go; the two dates may be at most 365 days apart). When widening would push such a range one day over, the From side gives the extra day back, so the request keeps the user's whole To day (usually today) and drops only the hours of the oldest day outside the server window. A range already over the cap, or with From after To, is sent exactly as typed so the server reports the same error as on main. The cap is exported as MAX_HISTORY_RANGE_DAYS.
  • Tests: new src/__tests__/local-date.test.ts, updated src/__tests__/history.test.ts, new Playwright spec tests-e2e/history-local-dates.spec.ts.
  • grep -rE "toISOString\(\)\.split\('T'\)\[0\]" frontend/src now only matches a doc comment in the new helper.

Why this approach

Switching only the seeding to local days would have caused a regression: a UTC-8 user at 20:00 would get To = today, and the backend would end that filter at UTC midnight, hiding this evening's purchases. Widening the request on the client keeps the inputs honest and never drops rows, with no backend change. The trade-off is that results can include a few hours past one edge of the picked range, which is a visible superset rather than a silent gap.

The other toISOString().slice(0, 10) sites (Plans earliestStartDate, calendar-date, plan-draft-dates, ladder timeline) are left alone on purpose: they model server-side UTC days and are labeled "(UTC)" in the UI.

Screenshots

Before is main (a14dc8f2), after is this branch. Both are headless Chromium at 1440x900 against the repo's mocked e2e API, with a fixed clock, the browser time zone set, and a mock that applies the backend's UTC-day filter.

Before After
auckland-default-range before auckland-default-range after
auckland-today-only before auckland-today-only after
los-angeles-default-range before los-angeles-default-range after
  1. Pacific/Auckland at 10:00 on Jul 28. Before: the default range is 07/20 to 07/27 (the UTC day), yet the table lists a purchase dated Jul 28. After: the range is 07/21 to 07/28 (local today) and includes it.
  2. Pacific/Auckland, filtering From = To = 07/28 (today). Before: "No purchase history found" even though a purchase ran at 09:00 local today. After: this morning's purchase is shown (the request start is widened to UTC day 07-27).
  3. America/Los_Angeles at 20:00 on Jul 27. Before: To = 07/28, which is tomorrow. After: To = 07/27 and From = 07/20, and this evening's purchase is still listed because the request end is widened to UTC 07-28.

Verification

Run on this branch's head a7b65a64 on top of main a14dc8f2:

  • npm run lint: 0 errors (142 warnings already on main). Typecheck and build pass.
  • Jest: 110 suites, 3503 passed, 1 skipped.
  • local-date.test.ts covers local vs UTC day, padding, parse round-trip, range widening, the DST-end day and pass-through, plus the 366-day cap. The jest sandbox cannot switch zones, so the cap tests run the module in a child node under America/Los_Angeles, Pacific/Auckland and UTC: a non-leap one-year range (2025-10-24..2026-10-24), a leap-year range (2028-01-01..2028-12-31), a 365-day range that has room to widen fully, a one-over-cap range across Feb 29 (sent unchanged, still rejected), and a sweep of To days around DST changes in both hemispheres, Feb 29 and today with gaps of 0, 7, 364 and 365 days. Each request is checked against the server's rule (end + 23:59:59 minus start at most 366 x 24h) and must cover the user's whole To day. Without the cap handling, six of these fail (Los Angeles and Auckland); UTC passes either way.
  • history.test.ts pins the clock to 23:30 EDT and checks that the seeded range is local, that the request covers a purchase from earlier the same local day (the acceptance test asked for in the issue comments), and that the snap uses the local day. The loadHistory expectation now uses end 2024-03-02.
  • With --randomize, the touched files pass on 3 seeds. One seed failed an existing loadHistory status-chip test through test-order dependence; the same flake appears on main a14dc8f2 with seeds 1 and 7.
  • Playwright: history-local-dates.spec.ts runs under Pacific/Auckland and America/Los_Angeles with a fixed clock. Full e2e: 159 passed.
  • Secrets scan clean (planted key flagged). scripts/test-git-secrets-allowlist.sh: 137 PASS.

Notes for review

  • The request bounds can now differ from the visible inputs by up to one day (for example, To 07/27 in Los Angeles sends end=2026-07-28), so edge rows can appear just outside the picked local range. The table and summary are not trimmed on the client, since trimming would make the server-computed summary disagree with the table.
  • At the 366-day cap the widening is trimmed on the From side, so up to a day's worth of hours at the start of the oldest picked day can fall outside the request. The default range (7 days back from today, 9 UTC days at most once widened) is far from the cap. The proper fix for this and the point above is a backend change that accepts a tz offset or RFC3339 bounds on /api/history; that would be a separate issue.
  • The API-key half of the issue (default expiry seeding and parsing the submitted date as local midnight) was already fixed on main in apikeys.ts (commit 62984e0e). This PR only moves those helpers into the shared lib.
  • The issue comment's grep for new Date(...input) found no other bare-date parses of date inputs.
  • The severity question raised in the issue comments is left to the maintainer.

Overlaps

Note for the UI owner

This changes UI code that @cristim owns. IAmJSD reviewed the before/after screenshots and asked for it to be merged; any design point you disagree with can be a follow-up.

🤖 Generated with Claude Code

The Purchase History From/To inputs were seeded (and snapped to a plan's
purchases) with toISOString().split('T')[0], i.e. the UTC calendar day,
while <input type="date"> shows the user's local day. Evening users west
of UTC saw tomorrow in To; morning users east of UTC saw yesterday.

Move toLocalDateInputValue and parseLocalDateInputValue out of apikeys.ts
into a shared lib/local-date.ts and use them for all four history.ts
sites. Because /api/history filters on whole UTC days, loadHistory now
widens the local From/To pair to the UTC days that cover it
(localDayRangeToUtcDays), so a local range never silently drops a
purchase made near either edge (e.g. a UTC+12 user filtering on today
previously lost this morning's purchases).

Refs LeanerCloud#51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/this-quarter Within the quarter impact/few Limited audience effort/s Hours type/bug Defect labels Oct 24, 2026
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): REQUEST_CHANGES 201f8e4f8e4b4a29e8b8caed172a98b016919355

Blocking:

  • frontend/src/lib/local-date.ts:41-54 (used at frontend/src/history.ts:363): widening the local range to covering UTC days adds a day, which pushes an ordinary one-year range over the backend cap. The cap is internal/api/handler_history.go:808, MaxHistoryDateRangeDays=366, with the end rolled to 23:59:59. Failure scenario: a user in America/Los_Angeles (or any non-UTC zone) picks From 2025-10-24, To 2026-10-24 ("same day last year to today"). On main that sends a span of 366d minus 1s, which is accepted. This branch sends start=2025-10-24, end=2026-10-25, a span of 367d minus 1s, so the table becomes "Failed to load history: date range too large: maximum allowed range is 366 days". Simulated in UTC, Los Angeles and Auckland: UTC is unchanged, both other zones break. The PR notes call this "exactly the 366-day server maximum", but that is any natural 1-year non-leap range, for every user outside UTC. Fix: when the widened span would exceed 366 days, fall back to the unwidened bound on one side (or clamp), and add a test for it.
    Non-blocking:
  • lib/local-date.ts:24 parseLocalDateInputValue duplicates utils.ts parseDateInput (utils.ts:69), which the issue comment pointed at. It moves existing code, so this is fine, but a dedupe is worth a follow-up.
  • The summary and table can now include rows from up to roughly a day outside the picked local range. This is acknowledged in the PR and an acceptable trade-off pending backend timezone support.
  • "Closes Sweep toISOString().split('T')[0] -> toLocalDateInputValue across frontend date inputs #51" is justified apart from the regression above: all four sites are fixed, and the API-key submit-side parse was already fixed on main.
    Attacked: Traced the request bounds against the backend parseHistoryDateRange/parseHistoryDateBounds semantics (UTC, inclusive end, 366-day cap). Ran a node simulation of localDayRangeToUtcDays under TZ=UTC, America/Los_Angeles and Pacific/Auckland for typical and 1-year ranges. Checked the viewPlanHistory snap path, the UTC no-op claim and the DST and invalid-input passthrough. Checked that the new history test pins a non-UTC instant and asserts a same-local-day purchase is covered.

…y cap

Widening the local From/To days to the UTC days that cover them adds a
day, so any off-UTC user picking a non-leap one-year range (e.g.
2025-10-24..2026-10-24 in Los Angeles) sent a 367-day span and got
"date range too large" for a range the server accepts as typed. When the
widened span would exceed the cap, the From side now gives the extra
day back: the request keeps the user's whole To day (usually today, the
newest rows) and loses only the hours of the oldest day that fall
outside the server's window. A range already over the cap, or with From
after To, passes through unchanged so the server reports the same error
as before.

The jest sandbox cannot switch zones, so the new range tests run the
module in a child node under America/Los_Angeles, Pacific/Auckland and
UTC, checking one-year, leap-year and cap-boundary ranges plus a sweep
of To days around DST changes and Feb 29 against the server's rule.

Refs LeanerCloud#51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context) of the original head: REQUEST_CHANGES 201f8e4f

Blocking:

  • frontend/src/lib/local-date.ts:41-54 (used at frontend/src/history.ts:363): widening the local range to covering UTC days adds a day, which pushes an ordinary one-year range over the backend cap. The cap is internal/api/handler_history.go:808, MaxHistoryDateRangeDays=366, with the end rolled to 23:59:59. Failure scenario: a user in America/Los_Angeles (or any non-UTC zone) picks From 2025-10-24, To 2026-10-24 ("same day last year to today"). On main that sends a span of 366d minus 1s, which is accepted. This branch sends start=2025-10-24, end=2026-10-25, a span of 367d minus 1s, so the table becomes "Failed to load history: date range too large: maximum allowed range is 366 days". Simulated in UTC, Los Angeles and Auckland: UTC is unchanged, both other zones break. The PR notes call this "exactly the 366-day server maximum", but that is any natural 1-year non-leap range, for every user outside UTC. Fix: when the widened span would exceed 366 days, fall back to the unwidened bound on one side (or clamp), and add a test for it.
    Non-blocking:
  • lib/local-date.ts:24 parseLocalDateInputValue duplicates utils.ts parseDateInput (utils.ts:69), which the issue comment pointed at. It moves existing code, so this is fine, but a dedupe is worth a follow-up.
  • The summary and table can now include rows from up to roughly a day outside the picked local range. This is acknowledged in the PR and an acceptable trade-off pending backend timezone support.
  • "Closes Sweep toISOString().split('T')[0] -> toLocalDateInputValue across frontend date inputs #51" is justified apart from the regression above: all four sites are fixed, and the API-key submit-side parse was already fixed on main.
    Attacked: Traced the request bounds against the backend parseHistoryDateRange/parseHistoryDateBounds semantics (UTC, inclusive end, 366-day cap). Ran a node simulation of localDayRangeToUtcDays under TZ=UTC, America/Los_Angeles and Pacific/Auckland for typical and 1-year ranges. Checked the viewPlanHistory snap path, the UTC no-op claim and the DST and invalid-input passthrough. Checked that the new history test pins a non-UTC instant and asserts a same-local-day purchase is covered.

Fixed in a7b65a64. The reviewer was right. The server allows the two dates to be at most 365 days apart: it reads end as 23:59:59 UTC and rejects anything over 366 × 24h. Widening the range added one day, so a one-year range picked outside UTC was rejected.

  • When the widened span would exceed the cap, localDayRangeToUtcDays now gives the extra day back on the From side. The user's whole To day is always requested, which is usually today and holds the newest purchases. At most part of the oldest day, a year back, falls outside the request.
  • I chose this over a blind clamp because it keeps the edge that matters most and changes nothing below the cap.
  • Ranges that are already over the cap, or have From after To, are sent exactly as typed, so the server reports the same error as on main. That also fixes an inverted Auckland range that used to widen into a valid one-day request.
  • The default 7-day range widens to at most 9 UTC days.
  • New jest tests run the date logic under America/Los_Angeles, Pacific/Auckland and UTC: a one-year non-leap range, a leap year, the cap boundary, and a sweep around DST changes and Feb 29. Every request is checked against the server's rule. 6 of the 15 tests fail without the fix.
  • Checks at a7b65a64: lint 0 errors, typecheck and build pass, jest 3503 passed, Playwright 159 passed, and both git-secrets checks pass. A fresh re-review of the new head follows.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): APPROVE a7b65a643047998f5b4fd709449b7d4acbf3deed

Blocking:

  • none
    Non-blocking:
  • frontend/src/lib/local-date.ts:24: parseLocalDateInputValue still duplicates utils.ts parseDateInput. This is carried over from the first review; a dedupe can be a follow-up.
  • The request is still a superset of the picked local range, by up to about a day at either edge, and the table and summary are not trimmed. This is acknowledged in the PR body; the proper fix is backend tz/RFC3339 bounds.
    Attacked: Re-derived the server rule from internal/api/handler_history.go parseHistoryDateRange/parseHistoryDateBounds: start is UTC midnight, end is +23:59:59, and the span must be <= 366*24h, so the two dates may be at most 365 days apart. That matches maxGap = MAX_HISTORY_RANGE_DAYS - 1. The clamp is generic (lastDay - maxGap), so it also holds for 25h DST days and +13/+14 zones. An over-cap or inverted range is passed through, so the server error is the same as on main, and one-sided ranges are still widened only on the side given. Read the child-node multi-zone tests: they transpile the real module, assert the child's TZ offset and check every result against the server rule, so they test what they claim. Checked the history.test non-UTC assertions, the e2e UTC-day mock, and that no other history-start/end consumer was missed.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Head e51f0afd is the approved a7b65a64 plus a conflict-free merge of main, done to get a fresh CI run. Its added and removed lines against main are identical to the reviewed diff (checked line by line), so the approval above carries over to e51f0afd.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at e51f0afd merged onto current main (51aa5b13; merge-tree 54321344; Linux, Node, headless Chromium):

  • This branch's own checks passed at this head: lint, typecheck, build, the full jest suite, the full Playwright e2e suite, and the git-secrets checks (see Verification in the PR body).
  • The combined tree of every UI PR in this merge wave (tree dc090cc2, main plus 17 PRs) passes npm ci, lint, typecheck, build, jest (1 skipped, 4097 passed, 4098 total), and the full Playwright suite (232 passed).
  • CI is green on e51f0afd (CI Success, Run pre-commit hooks).
  • The independent review above approves this exact head.
  • Nothing was applied to any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 24, 2026 18:50
@IAmJSD
IAmJSD merged commit 8e823c7 into LeanerCloud:main Oct 24, 2026
29 checks passed
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Marked ready and merged by IAmJSD's agent, at IAmJSD's request (IAmJSD reviewed the before/after screenshots; cristim is traveling), after the independent review and local verification above, all at e51f0afd.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sweep toISOString().split('T')[0] -> toLocalDateInputValue across frontend date inputs

1 participant