diff --git a/.changelog/next/fixed-issue-4168.md b/.changelog/next/fixed-issue-4168.md new file mode 100644 index 0000000000..0ae81221a2 --- /dev/null +++ b/.changelog/next/fixed-issue-4168.md @@ -0,0 +1 @@ +- POST streak/stats/history readers now re-derive each record's day key from its own timestamp in the current timezone, so changing settings.timezone re-keys existing practice history instead of leaving it frozen in the zone it was written in diff --git a/server/lib/README.md b/server/lib/README.md index e34467904b..e66cc568b2 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -303,7 +303,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `spacedRepetition.js` | The shared SM-2-inspired review scheduler, extracted from `services/meatspacePostMemory.js` when SongBook practice became its second consumer (#4102). One four-field schedule shape (`{ ease, intervalDays, nextReview, lastReviewed }`, no `repetitions` counter — the 0 → 1 → 6 → `round(prev * ease)` ladder derives from the previous interval, so a schedule round-trips through import/export/federation on those four fields alone). `advanceSchedule(schedule, ratio, now)` is the core (ease clamped to `MIN_EASE`..`MAX_EASE`, interval capped at `MAX_INTERVAL_DAYS` so `new Date(now + interval*DAY)` can't overflow into an Invalid Date; quality < 3 zeroes the interval to resurface the record but still applies the ease penalty). `mergeScheduleAdvance(prev, advanced, now)` gates interval GROWTH to once per review day (a shrink always applies) for UIs that submit per chunk/section; `isSameReviewDay` is that gate's own predicate, reusable by callers gating their own once-a-day progression. `scheduleOrDefault(record, schedule?)` is the read-path fallback — a record with no schedule derives one anchored to its `updatedAt`/`createdAt` (stable and in the past → due now), so "due" can't flap between two reads a millisecond apart. `ratioToQuality` / `qualityToRatio` are exact inverses over the integers, so a self-graded 0..5 review lands on the quality the user picked. `isScheduleDue` treats an absent/unparseable `nextReview` as due — a record we can't schedule is one to surface, never one to hide forever. Pure. | | `songPractice.js` | SongBook repertoire practice scheduling (#4102) — the pure core behind `POST /api/brain/songbook/:id/practice`. `applySongPractice(song, quality, now)` → `{ stage, practice }`, the exact partial to hand `brainStorage.updateWith`: it advances the shared `spacedRepetition` schedule from a 0..5 self-grade and moves the song's `stage` along `SONG_STAGE_ORDER` (≥ `SONG_PROMOTE_MIN_QUALITY` promotes, ≤ `SONG_REGRESS_MAX_QUALITY` regresses, a 3 holds). Promotion is gated to once per practice day (regression never is), and a practiced song floors at `learning` — `new` means "never picked up", which stops being true after one session. `songPracticeOrDefault(song)` derives the schedule on READ for every song predating the feature (nothing is backfilled to disk: a migration would restamp `updatedAt` on every record and spray federation churn, and would break the `data.reference/` seeds' byte-identity). `nextSongStage` no-ops on a stage this install doesn't recognize, so a practice log can't rewrite a value synced from a newer peer. `isSongDue(song, now)` mirrors the client's `songDueAt` in `client/src/components/songbook/constants.js`. Pure. | | `postTopics.js` | Pure POST practice-topic registry — `POST_TOPICS` (`{ id, label, module, surface, drillTypes }`) is the single source of truth for "what am I studying?", plus `resolveTopicForDrillType` / `isTopicEnabled` / `isMemoryItemEnabled`. Gates session composition and recommendations, including Memory's composed and dedicated practice routes plus standalone Morse. Mirrored to the client's post `constants.js` (parity test). | -| `postStreak.js` | Pure DST-safe POST practice-streak math — the single `computePostStreaks` implementation shared by scored sessions and the training log, plus `computeUnifiedStreak` (a day is active with EITHER a session or a practice entry) and `normalizeYmd` (day-key prefix for a date that may be a full ISO timestamp). | +| `postStreak.js` | Pure DST-safe POST practice-streak math — the single `computePostStreaks` implementation shared by scored sessions and the training log, plus `computeUnifiedStreak` (a day is active with EITHER a session or a practice entry), `normalizeYmd` (day-key prefix for a date that may be a full ISO timestamp), and `recordDayKey` / `withDerivedDayKeys` (re-derive a record's day key from its `startedAt`/`completedAt`/`timestamp` instant, so readers ignore a `date` frozen in a previously-configured timezone). | | `activeDays.js` | Cross-domain "days active" set math (#4120) — `unionActiveDayKeys(sources, timezone)` unions several domains' stored date values into one sorted array of user-local `YYYY-MM-DD` keys (`.length` is the honest day count; summing per-domain counts would double-count any day logged in two domains), and `toUserDayKey(value, timezone)` is its per-value normalizer. Owns the day-boundary reconciliation between POST (stamps `userLocalToday()`) and the health logs (stamp the server-local `getDateString()`): a value carrying an INSTANT (the pre-#2681 full-ISO shape some legacy training entries still store) is re-keyed into the user's timezone rather than `split('T')[0]`'d to the UTC day, while a value that is already a bare day LABEL is taken as authored — there is no instant left to re-derive from, so retro-normalizing stored health keys is out of scope by construction, not by omission. Non-day values are dropped rather than coerced. Pure. | | `planIds.js` | Utilities for PLAN.md `[slug]` IDs. | | `renderSlot.js` | Render-slot helpers for `(proof\|final)Image` per stage. | diff --git a/server/lib/postStreak.js b/server/lib/postStreak.js index dafd79cf4a..032bf7fb3f 100644 --- a/server/lib/postStreak.js +++ b/server/lib/postStreak.js @@ -25,6 +25,74 @@ export function normalizeYmd(value, timezone) { return timezone && raw.includes('T') ? toUserDayKey(raw, timezone) : raw.split('T')[0]; } +// Field order matters: `startedAt` is when the activity actually happened, and +// `submitPostSession` deliberately preserves it across an idempotent re-submit, +// so it is the stable anchor. `completedAt` moves on re-submit and `timestamp` +// is what the training log / Morse rounds carry instead. +const INSTANT_FIELDS = ['startedAt', 'completedAt', 'timestamp']; + +/** + * Day key for an INSTANT, in the user's timezone — or null when the value isn't + * one. A bare `YYYY-MM-DD` string is a day LABEL, not an instant: it carries no + * zone to re-derive from, so `toUserDayKey` takes it as authored rather than + * reading it as UTC midnight (which would shift it a day west of UTC). + */ +function instantDayKey(instant, timezone) { + if (!timezone) return null; + if (typeof instant === 'number' && Number.isFinite(instant)) { + const at = new Date(instant); + // Round-trip through ISO so `toUserDayKey` stays the ONE place that turns an + // instant into a user-local day (this module keeps its pure, single-import shape). + return Number.isNaN(at.getTime()) ? null : toUserDayKey(at.toISOString(), timezone); + } + return typeof instant === 'string' ? toUserDayKey(instant, timezone) : null; +} + +/** + * The day key a record belongs to, RE-DERIVED from the instant it happened + * (`startedAt` / `completedAt` / `timestamp`) rather than read off the stored + * `date` (issue #4168). + * + * A stored `date` is frozen in whatever timezone was configured when it was + * written, so once the user changes `settings.timezone` the old keys disagree + * with the new-zone readers — a session saved as `2026-07-15` under one zone + * reads as "not today" under another. Deriving from the instant makes the + * stored `date` a pure cache the readers ignore, which is what the reminder + * path (`meatspacePostReminder.js` `isOnLocalDay`) has always done. + * + * Falls back to the stored `date` only when no usable instant survives (legacy + * records written before the timestamps existed) — there is nothing left to + * re-derive from there, so it is taken as authored. + * + * @param {object} record - an activity record (session / training entry / round) + * @param {string} [timezone] - user timezone; without it the stored `date` wins + * @returns {string|null} a `YYYY-MM-DD` day key, or null when undatable + */ +export function recordDayKey(record, timezone) { + if (!record || typeof record !== 'object') return null; + for (const field of INSTANT_FIELDS) { + const derived = instantDayKey(record[field], timezone); + if (derived) return derived; + } + return normalizeYmd(record.date, timezone); +} + +/** + * Re-stamp a batch of activity records with their re-derived day key, so every + * downstream reader (and the client, which receives these records verbatim) + * sees ONE timezone-current `date`. Apply this at the read boundary — the + * loaders (`getPostSessions`, `getAllTrainingEntries`) — never on the write + * path, which must keep stamping and preserving the original stored value. + * + * @param {Array} records + * @param {string} [timezone] + * @returns {Array} records with `date` replaced by the derived key + */ +export function withDerivedDayKeys(records, timezone) { + if (!Array.isArray(records)) return []; + return records.map(r => (r && typeof r === 'object' ? { ...r, date: recordDayKey(r, timezone) } : r)); +} + // Local-date arithmetic on `YYYY-MM-DD` strings via UTC midnight so day math // never drifts across DST boundaries (the activity-streak bug class). export function ymdToUTC(s) { @@ -55,13 +123,13 @@ export function ymdShift(s, deltaDays) { * @param {string} [timezone] - user timezone for legacy ISO dates */ export function computePostStreaks(records, todayStr, timezone) { - const dateSet = new Set((records || []).map(s => normalizeYmd(s?.date, timezone)).filter(Boolean)); + const dateSet = new Set((records || []).map(s => recordDayKey(s, timezone)).filter(Boolean)); const dates = Array.from(dateSet).sort(); const completedToday = dateSet.has(todayStr); const lastDate = dates.length ? dates[dates.length - 1] : null; const todayScores = (records || []) - .filter(s => normalizeYmd(s?.date, timezone) === todayStr && typeof s?.score === 'number') + .filter(s => recordDayKey(s, timezone) === todayStr && typeof s?.score === 'number') .map(s => s.score); const todayScore = todayScores.length ? Math.max(...todayScores) : null; @@ -98,9 +166,13 @@ export function computePostStreaks(records, todayStr, timezone) { * @param {string} [timezone] - user timezone for legacy ISO dates */ export function computeUnifiedStreak(sessions, trainingEntries, todayStr, timezone) { + // Project the day key AND the instants it is re-derived from (#4168) — a bare + // `{ date }` projection would strip the timestamps and silently fall the whole + // unified streak back onto the stale stored keys. + const toActivity = r => ({ date: r?.date, startedAt: r?.startedAt, completedAt: r?.completedAt, timestamp: r?.timestamp }); const activity = [ - ...(sessions || []).map(s => ({ date: s?.date })), - ...(trainingEntries || []).map(e => ({ date: e?.date })), + ...(sessions || []).map(toActivity), + ...(trainingEntries || []).map(toActivity), ]; const { currentStreak, longestStreak, lastDate } = computePostStreaks(activity, todayStr, timezone); return { current: currentStreak, longest: longestStreak, lastActiveDate: lastDate }; diff --git a/server/lib/postStreak.test.js b/server/lib/postStreak.test.js index 93ac36b108..3cc9326459 100644 --- a/server/lib/postStreak.test.js +++ b/server/lib/postStreak.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { computePostStreaks, computeUnifiedStreak, ymdShift } from './postStreak.js'; +import { computePostStreaks, computeUnifiedStreak, recordDayKey, withDerivedDayKeys, ymdShift } from './postStreak.js'; const rec = (date, score) => (score == null ? { date } : { date, score }); @@ -100,4 +100,66 @@ describe('computeUnifiedStreak (sessions OR training-log activity)', () => { current: 0, longest: 0, lastActiveDate: null, }); }); + + it('re-derives day keys from record instants, not the stale stored `date` (#4168)', () => { + // Written while the user lived in Los Angeles: 2026-07-17 22:00 PDT is + // already 2026-07-18 in UTC. After the user moves the setting to UTC, the + // frozen stored key would report a gap; the instant says otherwise. + const sessions = [ + { date: '2026-07-16', startedAt: '2026-07-17T05:00:00.000Z', score: 80 }, + { date: '2026-07-17', startedAt: '2026-07-18T05:00:00.000Z', score: 90 }, + ]; + const training = [{ date: '2026-07-15', timestamp: '2026-07-16T05:00:00.000Z' }]; + + const inUtc = computeUnifiedStreak(sessions, training, '2026-07-18', 'UTC'); + expect(inUtc).toEqual({ current: 3, longest: 3, lastActiveDate: '2026-07-18' }); + + // Back in the original zone the same instants still key to the stored days. + const inLa = computeUnifiedStreak(sessions, training, '2026-07-17', 'America/Los_Angeles'); + expect(inLa).toEqual({ current: 3, longest: 3, lastActiveDate: '2026-07-17' }); + }); + + it('scores today off the re-derived day, not the stored one (#4168)', () => { + const sessions = [{ date: '2026-07-17', startedAt: '2026-07-18T05:00:00.000Z', score: 91 }]; + expect(computePostStreaks(sessions, '2026-07-18', 'UTC')).toMatchObject({ + completedToday: true, + todayScore: 91, + }); + }); +}); + +describe('recordDayKey / withDerivedDayKeys (#4168)', () => { + it('prefers startedAt, then completedAt, then timestamp', () => { + expect(recordDayKey({ date: '2000-01-01', startedAt: '2026-07-18T05:00:00.000Z', completedAt: '2026-07-19T05:00:00.000Z', timestamp: '2026-07-20T05:00:00.000Z' }, 'UTC')).toBe('2026-07-18'); + expect(recordDayKey({ date: '2000-01-01', completedAt: '2026-07-19T05:00:00.000Z', timestamp: '2026-07-20T05:00:00.000Z' }, 'UTC')).toBe('2026-07-19'); + expect(recordDayKey({ date: '2000-01-01', timestamp: '2026-07-20T05:00:00.000Z' }, 'UTC')).toBe('2026-07-20'); + }); + + it('falls back to the stored date when no instant survives', () => { + // Legacy records predate the timestamps — there is nothing to re-derive from, + // so the authored day key stands rather than becoming null. + expect(recordDayKey({ date: '2026-07-17' }, 'America/Los_Angeles')).toBe('2026-07-17'); + expect(recordDayKey({ date: '2026-07-17T22:00:00.000Z' }, 'Asia/Tokyo')).toBe('2026-07-18'); + expect(recordDayKey({ date: '2026-07-17', startedAt: 'not-a-date' }, 'UTC')).toBe('2026-07-17'); + }); + + it('accepts an epoch-ms instant and rejects a non-record', () => { + expect(recordDayKey({ timestamp: Date.UTC(2026, 6, 18, 5) }, 'UTC')).toBe('2026-07-18'); + expect(recordDayKey(null, 'UTC')).toBeNull(); + expect(recordDayKey({}, 'UTC')).toBeNull(); + }); + + it('leaves the stored date alone without a timezone', () => { + // No zone resolved ⇒ nothing to re-derive INTO. Absent must not collapse into + // "UTC" and silently re-key a whole history. + expect(recordDayKey({ date: '2026-07-17', startedAt: '2026-07-18T05:00:00.000Z' })).toBe('2026-07-17'); + }); + + it('re-stamps a batch without mutating the inputs', () => { + const records = [{ id: 'a', date: '2026-07-17', startedAt: '2026-07-18T05:00:00.000Z', score: 70 }]; + const derived = withDerivedDayKeys(records, 'UTC'); + expect(derived[0]).toEqual({ id: 'a', date: '2026-07-18', startedAt: '2026-07-18T05:00:00.000Z', score: 70 }); + expect(records[0].date).toBe('2026-07-17'); + expect(withDerivedDayKeys(null, 'UTC')).toEqual([]); + }); }); diff --git a/server/services/meatspacePost.js b/server/services/meatspacePost.js index 8f28e028de..a56ec5d749 100644 --- a/server/services/meatspacePost.js +++ b/server/services/meatspacePost.js @@ -36,7 +36,7 @@ import { applySessionToReviewSchedule, getDueReviews, getRetentionReport } from // meatspacePostTraining.js would close that into a 3-file circular import. import { getAllTrainingEntries } from './postTrainingLogStore.js'; import { getMorseProgress, MAX_KOCH_LEVEL } from './meatspacePostMorse.js'; -import { computePostStreaks, computeUnifiedStreak, normalizeYmd, ymdToUTC, ymdShift } from '../lib/postStreak.js'; +import { computePostStreaks, computeUnifiedStreak, normalizeYmd, recordDayKey, withDerivedDayKeys, ymdToUTC, ymdShift } from '../lib/postStreak.js'; import { getUserTimezone, todayInTimezone, userLocalToday as localToday } from '../lib/timezone.js'; // Re-export the shared streak helper so existing importers of @@ -238,22 +238,41 @@ async function loadSessions({ strict = false } = {}) { return data; } +/** + * All scored sessions, with each record's `date` RE-DERIVED from its `startedAt` + * instant in the user's CURRENT timezone (issue #4168). This is the single read + * boundary for sessions — every stats/streak/history reader and the client go + * through it — so a `settings.timezone` change re-keys existing history on read + * instead of leaving days frozen in the zone that was active when they were + * written. The stored `date` stays as a cache the readers ignore; the write path + * (`submitPostSession`, via `loadSessions`) is untouched. + */ export async function getPostSessions(from, to, options) { const data = await loadSessions(options); - let sessions = data.sessions; + const timezone = await getUserTimezone(); + let sessions = withDerivedDayKeys(data.sessions, timezone); + // Re-derivation can move a record across a day boundary, so re-sort rather + // than trusting the stored-date order `submitPostSession` wrote. + sessions.sort((a, b) => (a?.date || '').localeCompare(b?.date || '')); if (from || to) { - const timezone = await getUserTimezone(); sessions = sessions.filter((session) => { - const date = normalizeYmd(session?.date, timezone); + const date = session?.date; return date && (!from || date >= from) && (!to || date <= to); }); } return sessions; } +/** + * One session by id, with the same re-derived `date` the list view gets (#4168) + * — otherwise the detail drawer would quote the frozen write-time day while the + * history row beside it shows the re-keyed one. + */ export async function getPostSession(id) { const data = await loadSessions(); - return data.sessions.find(s => s.id === id) || null; + const session = data.sessions.find(s => s.id === id); + if (!session) return null; + return { ...session, date: recordDayKey(session, await getUserTimezone()) }; } export async function submitPostSession(sessionData) { @@ -670,7 +689,7 @@ export async function getPostStats(days = 30) { // UTC midnight, DST-safe) so the cutoff matches the tz-correct session dates. const cutoffStr = ymdShift(todayStr, -days); recent = sessions.filter(s => { - const date = normalizeYmd(s?.date, timezone); + const date = recordDayKey(s, timezone); return date && date >= cutoffStr; }); } @@ -802,13 +821,13 @@ export async function getPostProgress({ days = 90 } = {}) { } const sessions = cutoffStr ? allSessions.filter(s => { - const date = normalizeYmd(s?.date, timezone); + const date = recordDayKey(s, timezone); return date && date >= cutoffStr; }) : allSessions; const training = cutoffStr ? allTraining.filter(e => { - const date = normalizeYmd(e?.date, timezone); + const date = recordDayKey(e, timezone); return date && date >= cutoffStr; }) : allTraining; @@ -825,7 +844,7 @@ export async function getPostProgress({ days = 90 } = {}) { }; for (const s of sessions) { - const date = normalizeYmd(s?.date, timezone); + const date = recordDayKey(s, timezone); if (!date) continue; const day = ensureDay(date); day.sessions += 1; @@ -851,7 +870,7 @@ export async function getPostProgress({ days = 90 } = {}) { // Practice time (Morse / memory) folds into each day's minutes — a practice- // only day still shows time-in-training even with no scored session. for (const e of training) { - const date = normalizeYmd(e?.date, timezone); + const date = recordDayKey(e, timezone); if (!date) continue; ensureDay(date).minutes += (e.totalMs || 0) / 60000; } @@ -1100,18 +1119,19 @@ export function practicedTodayFromActivity(sessions = [], trainingEntries = [], const today = normalizeYmd(todayStr, timezone); if (!today) return { drillTypes, memoryItemIds, completedSession }; - // Both feeds go through normalizeYmd: session dates are stored as the day - // prefix already, but some training-log entries (memory practice) carry a full - // ISO timestamp, and a raw `!==` would read those as not-practiced. + // Both feeds go through recordDayKey, which re-derives the day from each + // record's own instant in the CURRENT timezone (#4168) — a raw `!==` against + // the stored `date` would read a record written under a previous timezone (or + // a memory-practice entry carrying a full ISO timestamp) as not-practiced. for (const session of sessions || []) { - if (normalizeYmd(session?.date, timezone) !== today) continue; + if (recordDayKey(session, timezone) !== today) continue; completedSession = true; for (const task of session.tasks || []) { if (task?.type) drillTypes.add(task.type); } } for (const entry of trainingEntries || []) { - if (normalizeYmd(entry?.date, timezone) !== today) continue; + if (recordDayKey(entry, timezone) !== today) continue; // Training entries are two shapes sharing one log: drill practice carries // `drillType`, memory practice carries `memoryItemId` + `mode`. if (entry.drillType) drillTypes.add(entry.drillType); @@ -1588,7 +1608,7 @@ async function getMultiplicationLevelStats(windowDays = MASTERY_DEFAULTS.windowD const anyAnswered = (task.questions || []).some(q => q?.answered != null); if (anyAnswered && level > floorLevel) floorLevel = level; // Mastery stats are windowed — skip out-of-window sessions for the buckets. - const date = normalizeYmd(session?.date, timezone); + const date = recordDayKey(session, timezone); if (cutoffStr && (!date || date < cutoffStr)) continue; const bucket = byLevel[level] || (byLevel[level] = { samples: 0, correct: 0, totalResponseMs: 0 }); for (const q of task.questions || []) { @@ -1628,7 +1648,7 @@ async function getPowersLevelStats(windowDays = POWERS_MASTERY_DEFAULTS.windowDa if (level == null) continue; const anyAnswered = (task.questions || []).some(question => question?.answered != null); if (anyAnswered && level > floorLevel) floorLevel = level; - const date = normalizeYmd(session?.date, timezone); + const date = recordDayKey(session, timezone); if (cutoffStr && (!date || date < cutoffStr)) continue; for (const question of task.questions || []) { if (question?.answered == null) continue; @@ -1717,7 +1737,7 @@ async function getCognitiveLevelStats(type, windowDays = COGNITIVE_MASTERY_DEFAU const reached = ((task.totalCount ?? (task.questions?.length || 0)) > 0); if (reached && level > floorLevel) floorLevel = level; // Mastery stats are windowed. - const date = normalizeYmd(session?.date, timezone); + const date = recordDayKey(session, timezone); if (cutoffStr && (!date || date < cutoffStr)) continue; const acc = Number.isFinite(task.accuracy) ? task.accuracy : null; if (acc == null) continue; diff --git a/server/services/meatspacePost.test.js b/server/services/meatspacePost.test.js index 9ba1bf39ba..5cfd967cb1 100644 --- a/server/services/meatspacePost.test.js +++ b/server/services/meatspacePost.test.js @@ -48,6 +48,7 @@ import { getAdaptivePreview, getPostStats, getPostSessions, + getPostSession, deriveTaskAccuracy, deriveTaskCompletion, getCognitiveProgress, @@ -1501,6 +1502,117 @@ describe('getPostStats / submitPostSession — timezone-correct day boundary (is }); }); +// ============================================================================= +// Re-derived day keys after a timezone CHANGE (issue #4168) +// +// #2681 fixed the steady state: a session is stamped in the zone configured at +// write time. The residual gap is what happens when the user later CHANGES +// settings.timezone — the stored `date` is frozen in the old zone and disagrees +// with the new-zone readers. These tests hold the history fixed (the exact bytes +// a previous zone wrote) and flip only the setting, which is what a user moving +// house actually does. +// ============================================================================= + +describe('POST readers re-derive day keys after a timezone change (issue #4168)', () => { + beforeEach(() => { vi.clearAllMocks(); }); + afterEach(() => { + vi.useRealTimers(); + settingsState.current = { timezone: 'UTC' }; + }); + + // History written while the user was in Los Angeles: each session's `date` is + // the LA day, while `startedAt` is the true instant (late local evening, so it + // already belongs to the NEXT UTC day). + const laHistory = [ + { + date: '2026-07-16', + startedAt: '2026-07-17T05:30:00.000Z', + completedAt: '2026-07-17T05:45:00.000Z', + score: 70, + tasks: [{ module: 'mental-math', type: 'doubling-chain', score: 70 }], + }, + { + date: '2026-07-17', + startedAt: '2026-07-18T05:30:00.000Z', + completedAt: '2026-07-18T05:45:00.000Z', + score: 90, + tasks: [{ module: 'mental-math', type: 'doubling-chain', score: 90 }], + }, + ]; + + function mockHistory(sessions) { + readJSONFile.mockImplementation((path, defaultValue) => { + if (String(path).includes('post-sessions')) return Promise.resolve({ sessions }); + return Promise.resolve(defaultValue); + }); + } + + function freezeAt(utcIso, timezone) { + settingsState.current = { timezone }; + vi.useFakeTimers(); + vi.setSystemTime(new Date(utcIso)); + } + + it('getPostSessions re-keys stored dates into the CURRENT timezone', async () => { + freezeAt('2026-07-18T12:00:00Z', 'UTC'); + mockHistory(laHistory); + const sessions = await getPostSessions(); + expect(sessions.map(s => s.date)).toEqual(['2026-07-17', '2026-07-18']); + // The stored record is untouched — the derived key is a read-time view. + expect(laHistory.map(s => s.date)).toEqual(['2026-07-16', '2026-07-17']); + }); + + it('getPostStats counts the streak and today against the new zone', async () => { + // Under UTC the second session lands on 2026-07-18, which IS today there. + freezeAt('2026-07-18T12:00:00Z', 'UTC'); + mockHistory(laHistory); + const stats = await getPostStats(30); + // The frozen LA keys would have read completedToday=false (last stored day 07-17). + expect(stats.completedToday).toBe(true); + expect(stats.todayScore).toBe(90); + expect(stats.currentStreak).toBe(2); + }); + + it('the same history still reads correctly back in the original zone', async () => { + // Nothing was rewritten, so moving the setting back restores the LA days. + freezeAt('2026-07-18T05:45:00Z', 'America/Los_Angeles'); + mockHistory(laHistory); + const stats = await getPostStats(30); + expect(stats.completedToday).toBe(true); + expect(stats.todayScore).toBe(90); + expect(stats.currentStreak).toBe(2); + expect(stats.lastDate).toBe('2026-07-17'); + }); + + it('the from/to range filter matches the re-derived days, not the stored ones', async () => { + freezeAt('2026-07-18T12:00:00Z', 'UTC'); + mockHistory(laHistory); + // '2026-07-16' is nobody's day under UTC — both records moved forward one day. + expect(await getPostSessions('2026-07-16', '2026-07-16')).toHaveLength(0); + expect(await getPostSessions('2026-07-18', '2026-07-18')).toHaveLength(1); + }); + + it('getPostSession re-keys the single record the same way the list does', async () => { + freezeAt('2026-07-18T12:00:00Z', 'UTC'); + mockHistory(laHistory.map((s, i) => ({ ...s, id: `s${i}` }))); + const session = await getPostSession('s1'); + expect(session.date).toBe('2026-07-18'); + expect(await getPostSession('missing')).toBeNull(); + }); + + it('leaves a legacy record with no instant on its authored day', async () => { + // Pre-timestamp history carries only a day label — there is nothing to + // re-derive from, so it must stay put rather than vanish from the stats. + freezeAt('2026-07-18T12:00:00Z', 'UTC'); + mockHistory([ + { date: '2026-07-18', score: 55, tasks: [{ module: 'mental-math', type: 'doubling-chain', score: 55 }] }, + ]); + const stats = await getPostStats(30); + expect(stats.completedToday).toBe(true); + expect(stats.todayScore).toBe(55); + }); +}); + // ============================================================================= // resolveDrillConfig / getAdaptivePreview — adaptive integration across ALL // math drill types (issue #2102 gap 3). The pure `adaptDrillConfig` policy is diff --git a/server/services/meatspacePostMorse.js b/server/services/meatspacePostMorse.js index 0c4fb87956..548295e6d7 100644 --- a/server/services/meatspacePostMorse.js +++ b/server/services/meatspacePostMorse.js @@ -25,8 +25,8 @@ import { join } from 'path'; import { randomUUID } from 'crypto'; import { atomicWrite, PATHS, ensureDir, readJSONFile } from '../lib/fileUtils.js'; -import { userLocalToday } from '../lib/timezone.js'; -import { ymdShift } from '../lib/postStreak.js'; +import { getUserTimezone, todayInTimezone, userLocalToday } from '../lib/timezone.js'; +import { withDerivedDayKeys, ymdShift } from '../lib/postStreak.js'; const MEATSPACE_DIR = PATHS.meatspace; const MORSE_FILE = join(MEATSPACE_DIR, 'post-morse-progress.json'); @@ -184,12 +184,17 @@ export async function setKochLevel({ kochLevel, adopt = false, settings } = {}) */ export async function getMorseProgress(days = 30) { const data = await loadMorseProgress(); - let rounds = data.rounds; + // Re-derive each round's day key from its own `timestamp` in the CURRENT + // timezone (issue #4168) — the stored `date` is frozen in whatever zone was + // configured when the round was appended, so after a `settings.timezone` + // change the window cutoff and the emitted series would key off stale days. + const timezone = await getUserTimezone(); + let rounds = withDerivedDayKeys(data.rounds, timezone); if (days > 0) { // Window off the user's local today (DST-safe day math) so the cutoff matches // the local-day round dates now stamped above (issue #2681). - const cutoffStr = ymdShift(await userLocalToday(), -days); + const cutoffStr = ymdShift(todayInTimezone(timezone), -days); rounds = rounds.filter((r) => (r.date || '') >= cutoffStr); } diff --git a/server/services/meatspacePostMorse.test.js b/server/services/meatspacePostMorse.test.js index 22224245c0..1e80bcf4cd 100644 --- a/server/services/meatspacePostMorse.test.js +++ b/server/services/meatspacePostMorse.test.js @@ -8,10 +8,12 @@ vi.mock('../lib/fileUtils.js', () => ({ })); // appendMorseRound / getMorseProgress derive the local day via userLocalToday → -// getSettings (issue #2681). Pin to UTC so the day-key is the UTC day regardless -// of the runner's system timezone (matching the UTC-today assertions below). +// getSettings (issue #2681). Default-pin to UTC so the day-key is the UTC day +// regardless of the runner's system timezone (matching the UTC-today assertions +// below); the timezone-change tests below set settingsState.current themselves. +const settingsState = vi.hoisted(() => ({ current: { timezone: 'UTC' } })); vi.mock('../services/settings.js', () => ({ - getSettings: () => Promise.resolve({ timezone: 'UTC' }), + getSettings: () => Promise.resolve(settingsState.current), })); import { readJSONFile, atomicWrite } from '../lib/fileUtils.js'; @@ -238,4 +240,30 @@ describe('getMorseProgress', () => { expect(p.confusionMatrix.M).toBeTruthy(); expect(p.confusionMatrix.K).toBeUndefined(); }); + + it('re-derives round day keys from `timestamp` after a timezone change (issue #4168)', async () => { + // Rounds appended while the user was in Los Angeles: `date` is the LA day, + // `timestamp` is the true instant (late local evening = next UTC day). Only + // the setting moves here — the stored rounds are byte-identical. + const laRounds = [ + { id: 'r1', date: '2026-07-16', timestamp: '2026-07-17T05:30:00.000Z', mode: 'copy', wpm: 18, accuracy: 80, items: [{ sent: 'K', guessed: 'K', correct: true }] }, + { id: 'r2', date: '2026-07-17', timestamp: '2026-07-18T05:30:00.000Z', mode: 'copy', wpm: 18, accuracy: 90, items: [{ sent: 'M', guessed: 'M', correct: true }] }, + ]; + settingsState.current = { timezone: 'UTC' }; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T12:00:00Z')); + try { + readJSONFile.mockResolvedValue({ kochLevel: 5, settings: null, rounds: laRounds }); + // A 1-day UTC window cuts at 2026-07-17: both re-derived days survive, where + // the frozen LA keys would have dropped the older round. + const p = await getMorseProgress(1); + expect(p.totalRounds).toBe(2); + expect(p.series.copy.map((s) => s.date)).toEqual(['2026-07-17', '2026-07-18']); + // The stored rounds are untouched — derivation is a read-time view. + expect(laRounds.map((r) => r.date)).toEqual(['2026-07-16', '2026-07-17']); + } finally { + vi.useRealTimers(); + settingsState.current = { timezone: 'UTC' }; + } + }); }); diff --git a/server/services/meatspacePostTraining.js b/server/services/meatspacePostTraining.js index e7bed65c2d..ec5fa05c94 100644 --- a/server/services/meatspacePostTraining.js +++ b/server/services/meatspacePostTraining.js @@ -7,7 +7,7 @@ import { randomUUID } from 'crypto'; import { getUserTimezone, todayInTimezone, userLocalToday } from '../lib/timezone.js'; -import { normalizeYmd, ymdShift } from '../lib/postStreak.js'; +import { recordDayKey, ymdShift } from '../lib/postStreak.js'; import { getUnifiedActivityStreak } from './postActivityStreak.js'; import { loadTrainingLog, saveTrainingLog, getAllTrainingEntries } from './postTrainingLogStore.js'; @@ -76,7 +76,7 @@ export async function getTrainingStats(days = 30) { // a UTC-day cutoff would clip the oldest local day or admit an extra one. const cutoffStr = ymdShift(todayStr, -days); entries = allEntries.filter(e => { - const date = normalizeYmd(e?.date, timezone); + const date = recordDayKey(e, timezone); return date && date >= cutoffStr; }); } @@ -90,7 +90,7 @@ export async function getTrainingStats(days = 30) { byDrill[key].totalCorrect += e.correctCount || 0; byDrill[key].totalQuestions += e.questionCount || 0; byDrill[key].totalMs += e.totalMs || 0; - const date = normalizeYmd(e?.date, timezone); + const date = recordDayKey(e, timezone); if (date) byDrill[key].dates.add(date); } @@ -99,7 +99,7 @@ export async function getTrainingStats(days = 30) { // getAllTrainingEntries() — postActivityStreak.js takes training as a // parameter specifically so it doesn't need to import this module. const { current: currentStreak, longest: longestStreak } = await getUnifiedActivityStreak(allEntries, todayStr, timezone); - const activeDays = new Set(entries.map(e => normalizeYmd(e?.date, timezone)).filter(Boolean)).size; + const activeDays = new Set(entries.map(e => recordDayKey(e, timezone)).filter(Boolean)).size; // Summarize const summary = {}; @@ -123,10 +123,13 @@ export async function getTrainingStats(days = 30) { } /** - * Get recent training entries for display. + * Get recent training entries for display. Reads through + * `getAllTrainingEntries`, so each entry's `date` is re-derived in the user's + * current timezone (#4168) and the history list agrees with the streak/stats + * that are computed off the same day keys. */ export async function getTrainingEntries(limit = 20) { - const data = await loadTrainingLog(); - if (!limit) return data.entries.slice().reverse(); - return data.entries.slice(-limit).reverse(); + const entries = await getAllTrainingEntries(); + if (!limit) return entries.slice().reverse(); + return entries.slice(-limit).reverse(); } diff --git a/server/services/meatspacePostTraining.test.js b/server/services/meatspacePostTraining.test.js index 006b2f746b..dbbb6ce495 100644 --- a/server/services/meatspacePostTraining.test.js +++ b/server/services/meatspacePostTraining.test.js @@ -242,7 +242,63 @@ describe('getTrainingStats', () => { }); }); +describe('getTrainingStats — re-derived day keys after a timezone change (issue #4168)', () => { + // Entries written while the user was in Los Angeles: `date` is the LA day, while + // `timestamp` is the true instant (late local evening = next UTC day). Only the + // SETTING changes below — the stored bytes stay exactly as they were written. + const laEntries = [ + { id: 'e1', module: 'morse', drillType: 'morse-copy', date: '2026-07-16', timestamp: '2026-07-17T05:30:00.000Z', questionCount: 5, correctCount: 5, totalMs: 30000 }, + { id: 'e2', module: 'morse', drillType: 'morse-copy', date: '2026-07-17', timestamp: '2026-07-18T05:30:00.000Z', questionCount: 5, correctCount: 4, totalMs: 30000 }, + ]; + + it('counts the streak against the CURRENT zone, not the frozen stored keys', async () => { + settingsState.current = { timezone: 'UTC' }; + vi.useFakeTimers(); + // 07-19 UTC: today is unpracticed, so the grace window anchors on 07-18. + // Under UTC the instants key to 07-17 + 07-18 → a live 2-day streak, where + // the frozen LA keys (07-16 + 07-17) reach neither and report 0. + vi.setSystemTime(new Date('2026-07-19T12:00:00Z')); + try { + readJSONFile.mockResolvedValue({ entries: laEntries }); + const stats = await getTrainingStats(30); + expect(stats.currentStreak).toBe(2); + expect(stats.activeDays).toBe(2); + expect(stats.byDrill['morse:morse-copy'].daysActive).toBe(2); + // The stored records are untouched — derivation is a read-time view. + expect(laEntries.map(e => e.date)).toEqual(['2026-07-16', '2026-07-17']); + } finally { + vi.useRealTimers(); + settingsState.current = { timezone: 'UTC' }; + } + }); + + it('windows the same history off the re-derived days', async () => { + settingsState.current = { timezone: 'UTC' }; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T12:00:00Z')); + try { + readJSONFile.mockResolvedValue({ entries: laEntries }); + // A 1-day window ends at 2026-07-17 under UTC, so both re-derived days survive; + // the older stored key (2026-07-16) would have been clipped. + const stats = await getTrainingStats(1); + expect(stats.totalEntries).toBe(2); + } finally { + vi.useRealTimers(); + settingsState.current = { timezone: 'UTC' }; + } + }); +}); + describe('getTrainingEntries', () => { + it('re-derives each displayed entry date in the current timezone (issue #4168)', async () => { + settingsState.current = { timezone: 'UTC' }; + readJSONFile.mockResolvedValue({ + entries: [{ id: 'a', date: '2026-07-16', timestamp: '2026-07-17T05:30:00.000Z' }], + }); + const entries = await getTrainingEntries(10); + expect(entries[0].date).toBe('2026-07-17'); + }); + it('returns entries in reverse order (most recent first)', async () => { readJSONFile.mockResolvedValue({ entries: [ diff --git a/server/services/postTrainingLogStore.js b/server/services/postTrainingLogStore.js index b4eb434e4d..75f4e2b0f8 100644 --- a/server/services/postTrainingLogStore.js +++ b/server/services/postTrainingLogStore.js @@ -13,6 +13,8 @@ import { join } from 'path'; import { atomicWrite, PATHS, ensureDir, readJSONFile } from '../lib/fileUtils.js'; +import { withDerivedDayKeys } from '../lib/postStreak.js'; +import { getUserTimezone } from '../lib/timezone.js'; const MEATSPACE_DIR = PATHS.meatspace; const TRAINING_LOG_FILE = join(MEATSPACE_DIR, 'post-training-log.json'); @@ -37,14 +39,20 @@ export async function saveTrainingLog(data) { } /** - * All training-log entries in chronological (append) order — the raw feed the + * All training-log entries in chronological (append) order — the feed the * unified progress aggregation reads (both meatspacePostTraining and * meatspacePostMemory practice write to the same `post-training-log.json`). * + * Each entry's `date` is RE-DERIVED from its `timestamp` in the user's CURRENT + * timezone (issue #4168), so a `settings.timezone` change re-keys existing + * practice history on read rather than leaving it frozen in the zone that was + * active when it was written. `loadTrainingLog` above stays raw — the write + * paths must round-trip the stored record untouched. + * * @param {{ strict?: boolean }} [options] - `strict: true` throws rather than * reporting an unreadable log as zero entries (#2726). */ export async function getAllTrainingEntries(options) { const data = await loadTrainingLog(options); - return data.entries; + return withDerivedDayKeys(data.entries, await getUserTimezone()); }