Skip to content

fix: re-derive POST day keys from record timestamps at read time (#4168) - #4308

Merged
atomantic merged 2 commits into
mainfrom
claim/issue-4168
Aug 15, 2026
Merged

fix: re-derive POST day keys from record timestamps at read time (#4168)#4308
atomantic merged 2 commits into
mainfrom
claim/issue-4168

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

A POST record's stored date is frozen in whatever timezone was configured when it was written. #2681 fixed the steady state (write and read both use the configured zone), but left a residual gap: once the user changes settings.timezone, every existing day key disagrees with the new-zone readers — a session saved as 2026-07-15 in one zone reads as "not today" after a move, breaking the streak, the stats window and the history list.

The streak/stats/history readers now re-derive each record's day key from the instant it happened (startedAtcompletedAttimestamp) instead of trusting the stored date, which becomes a cache they ignore. This is the same read-time derivation the reminder path has always used (meatspacePostReminder.js isOnLocalDay).

  • server/lib/postStreak.js — new recordDayKey(record, timezone) and withDerivedDayKeys(records, timezone). computePostStreaks / computeUnifiedStreak key off them, and computeUnifiedStreak's activity projection now carries the instants through instead of stripping them to a bare { date }.
  • Derivation is applied at the read boundaries so one place fixes every consumer, the client included (records are returned verbatim over the API): getPostSessions (meatspacePost.js), getAllTrainingEntries (postTrainingLogStore.js), and getMorseProgress (meatspacePostMorse.js). getPostSessions also re-sorts, since re-derivation can move a record across a day boundary.
  • The remaining named readers (getPostStats, getPostProgress, getUnifiedActivityStreak, getTrainingStats, getMorseProgress, plus the mastery-window aggregations and practicedTodayFromActivity) now call recordDayKey directly rather than normalizeYmd(x?.date, tz), so a caller passing raw records is covered too.
  • Write paths are untouched — loadSessions / loadTrainingLog stay raw so submits still round-trip the stored record. No on-disk change, so no migration.
  • A legacy record with no usable instant keeps its authored day key (there is nothing left to re-derive from), and with no resolved timezone the stored date still wins — absent must not collapse into "UTC" and silently re-key a whole history.

Closes #4168

Test plan

  • cd server && NODE_ENV=test npx vitest run — 1408 files / 29500 tests pass. The only 2 failures are routes/health.test.js (gh-CLI forge reachability + PM2 process health), reproduced identically on a clean main checkout, so they are pre-existing and environment-dependent.
  • cd client && NODE_ENV=test npx vitest run — 650 files / 7938 tests pass.
  • New coverage, all with invented fixtures:
    • server/lib/postStreak.test.jsrecordDayKey field precedence, epoch-ms instants, the no-instant and no-timezone fallbacks, non-mutation in withDerivedDayKeys, and a unified-streak case where the same history reads correctly in both the original and the new zone.
    • server/services/meatspacePost.test.js — a new #4168 block holding LA-written history fixed and flipping only the setting: getPostSessions re-keys, getPostStats reports completedToday/todayScore against the new zone, the from/to range filter matches the re-derived days, the same history still reads correctly back in the original zone, and a legacy no-instant record stays on its authored day.
    • server/services/meatspacePostTraining.test.jsgetTrainingStats streak/active-days/window and getTrainingEntries display dates after a zone change.
    • server/services/meatspacePostMorse.test.jsgetMorseProgress window cutoff and emitted series re-keyed from each round's timestamp.
  • Bypass probe: stashing only the source changes (tests kept) fails 7 of the 8 new service-level tests, confirming they are not vacuous. The 2 that pass either way are deliberate regression guards (same-zone round-trip, legacy fallback).

A stored POST `date` is frozen in the timezone that was configured when it was
written, so changing `settings.timezone` after accumulating history left the old
day keys disagreeing with the new-zone readers — a session saved as one day read
as "not today" after the move, breaking streaks, stats windows and history.

The readers now key each record off the instant it happened
(`startedAt`/`completedAt`/`timestamp`) via the new `recordDayKey` /
`withDerivedDayKeys` in `server/lib/postStreak.js`, making the stored `date` a
cache they ignore. This is the same read-time derivation the reminder path has
always used (`meatspacePostReminder.js` `isOnLocalDay`). Derivation happens at
the read boundaries — `getPostSessions`, `getAllTrainingEntries`, and
`getMorseProgress` — so every server reader and the client see one
timezone-current day key. The write paths are untouched, and a legacy record
carrying no instant keeps its authored day.
getPostSession returned the raw record, so the detail view quoted the frozen
write-time day while the history row beside it showed the re-derived one.
@atomantic
atomantic merged commit 3a5bc7c into main Aug 15, 2026
7 checks passed
@atomantic
atomantic deleted the claim/issue-4168 branch August 15, 2026 19:13
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.

Re-derive POST day keys from timestamps at read time

1 participant