From d9a748078d624db9075b8bac2b1b7c9c4ef05115 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 06:01:12 +0000 Subject: [PATCH 1/3] fix: align POST day keys with user timezone (#4211) --- .changelog/next/fixed-issue-4211.md | 1 + server/lib/postStreak.js | 36 +++++--- server/lib/postStreak.test.js | 22 +++++ server/services/characterMetrics.js | 14 ++-- server/services/characterMetrics.test.js | 13 +++ server/services/characterSignals.js | 9 +- server/services/meatspacePost.js | 83 +++++++++++++------ server/services/meatspacePost.test.js | 39 +++++++++ server/services/meatspacePostProgress.test.js | 30 ++++++- .../meatspacePostRecommendations.test.js | 42 +++++++++- server/services/meatspacePostTraining.js | 20 +++-- server/services/meatspacePostTraining.test.js | 26 ++++++ server/services/postActivityStreak.js | 10 ++- 13 files changed, 279 insertions(+), 66 deletions(-) create mode 100644 .changelog/next/fixed-issue-4211.md diff --git a/.changelog/next/fixed-issue-4211.md b/.changelog/next/fixed-issue-4211.md new file mode 100644 index 0000000000..d08ff229ce --- /dev/null +++ b/.changelog/next/fixed-issue-4211.md @@ -0,0 +1 @@ +- POST streaks and activity statistics now use the user's local day for legacy timestamped practice. diff --git a/server/lib/postStreak.js b/server/lib/postStreak.js index fd4f292070..dafd79cf4a 100644 --- a/server/lib/postStreak.js +++ b/server/lib/postStreak.js @@ -10,12 +10,19 @@ * circular dependency back into meatspacePost.js. */ -// A date key is always the local `YYYY-MM-DD` prefix. Session dates are stored -// that way already; some training-log entries (memory practice) store a full -// ISO timestamp, so normalize both to the day prefix before any set math. -export function normalizeYmd(value) { +import { toUserDayKey } from './activeDays.js'; + +/** + * Normalize a stored day label or legacy ISO instant. + * + * @param {unknown} value - a bare day label or full ISO timestamp + * @param {string} [timezone] - user timezone for re-keying legacy instants + * @returns {string|null} a day key, or null for an absent/invalid timezone-aware value + */ +export function normalizeYmd(value, timezone) { if (!value) return null; - return String(value).split('T')[0]; + const raw = String(value); + return timezone && raw.includes('T') ? toUserDayKey(raw, timezone) : raw.split('T')[0]; } // Local-date arithmetic on `YYYY-MM-DD` strings via UTC midnight so day math @@ -42,15 +49,19 @@ export function ymdShift(s, deltaDays) { * - `longestStreak` — longest consecutive-day run in all history * - `lastDate` — most recent record date (null if never active) * - `todayScore` — best record score recorded today (null if none) + * + * @param {Array} records - activity records with a `date` field + * @param {string} todayStr - user's local `YYYY-MM-DD` today + * @param {string} [timezone] - user timezone for legacy ISO dates */ -export function computePostStreaks(records, todayStr) { - const dateSet = new Set((records || []).map(s => normalizeYmd(s?.date)).filter(Boolean)); +export function computePostStreaks(records, todayStr, timezone) { + const dateSet = new Set((records || []).map(s => normalizeYmd(s?.date, 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) === todayStr && typeof s?.score === 'number') + .filter(s => normalizeYmd(s?.date, timezone) === todayStr && typeof s?.score === 'number') .map(s => s.score); const todayScore = todayScores.length ? Math.max(...todayScores) : null; @@ -80,12 +91,17 @@ export function computePostStreaks(records, todayStr) { * memory practice). Reuses `computePostStreaks` so the DST-safe grace-window * semantics are identical to the scored-session streak. Returns the progress-API * shape (`current` / `longest` / `lastActiveDate`). + * + * @param {Array} sessions - scored POST sessions + * @param {Array} trainingEntries - training-log entries + * @param {string} todayStr - user's local `YYYY-MM-DD` today + * @param {string} [timezone] - user timezone for legacy ISO dates */ -export function computeUnifiedStreak(sessions, trainingEntries, todayStr) { +export function computeUnifiedStreak(sessions, trainingEntries, todayStr, timezone) { const activity = [ ...(sessions || []).map(s => ({ date: s?.date })), ...(trainingEntries || []).map(e => ({ date: e?.date })), ]; - const { currentStreak, longestStreak, lastDate } = computePostStreaks(activity, todayStr); + 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 ca270fec57..93ac36b108 100644 --- a/server/lib/postStreak.test.js +++ b/server/lib/postStreak.test.js @@ -14,6 +14,28 @@ describe('computePostStreaks (shared helper)', () => { expect(r.lastDate).toBe('2026-06-28'); }); + it('keys legacy instants to the user-local day while preserving bare day labels', () => { + // The pair crosses UTC midnight: it is still July 17 in Los Angeles, but July 17 and 18 + // in Tokyo. The same local boundary must drive both the streak and active-days tile. + const legacy = [ + rec('2026-07-17T09:00:00.000Z'), + rec('2026-07-18T02:00:00.000Z'), + ]; + + const behindUtc = computePostStreaks(legacy, '2026-07-18', 'America/Los_Angeles'); + expect(behindUtc).toMatchObject({ currentStreak: 1, longestStreak: 1, lastDate: '2026-07-17' }); + + const aheadOfUtc = computePostStreaks(legacy, '2026-07-18', 'Asia/Tokyo'); + expect(aheadOfUtc).toMatchObject({ currentStreak: 2, longestStreak: 2, lastDate: '2026-07-18' }); + + const bareLabels = computePostStreaks( + [rec('2026-07-17'), rec('2026-07-18')], + '2026-07-18', + 'America/Los_Angeles' + ); + expect(bareLabels).toMatchObject({ currentStreak: 2, longestStreak: 2, lastDate: '2026-07-18' }); + }); + it('honors the grace window (today not done, yesterday done)', () => { const r = computePostStreaks([rec('2026-06-26'), rec('2026-06-27')], '2026-06-28'); expect(r.completedToday).toBe(false); diff --git a/server/services/characterMetrics.js b/server/services/characterMetrics.js index df3fd61e23..6bd3df4813 100644 --- a/server/services/characterMetrics.js +++ b/server/services/characterMetrics.js @@ -84,12 +84,13 @@ export const METRICS = [ // day boundary) the Progress page and the dashboard widgets use, so the Character sheet // can't quote a different streak than the rest of PortOS (#2091). compute: async (read) => { - const [sessions, training, today] = await Promise.all([ + const [sessions, training, today, timezone] = await Promise.all([ read('postSessions'), read('postTraining'), read('postToday'), + read('userTimezone'), ]); - return computeUnifiedStreak(sessions, training, today).current; + return computeUnifiedStreak(sessions, training, today, timezone).current; }, }, { @@ -115,13 +116,8 @@ export const METRICS = [ // documents exactly what it can and cannot fix — notably that a stored bare day LABEL is // taken as authored, because no instant survives to re-derive it from. // - // KNOWN DIVERGENCE (#4211): `postStreakDays` above resolves its day keys through - // `postStreak.js`'s `normalizeYmd`, which still takes the UTC day (`split('T')[0]`). For - // the bare `YYYY-MM-DD` keys every writer has stamped since #2681 the two agree exactly; - // they can disagree only on a LEGACY full-ISO entry straddling UTC midnight, where this - // tile is the correct one and the streak is not. Aligning the shared streak helper is - // cross-cutting (it feeds the Progress page and the dashboard widgets too), so it is - // tracked separately rather than smuggled in here. + // The shared streak helper re-keys legacy full-ISO entries through the same user-local + // boundary as this union, while bare `YYYY-MM-DD` labels remain authored day keys. // // Missing keys are a FAILED read, not an idle install: each source is validated as an // array and a throw here lands in `readMetric`'s `unavailable`, so a shape mismatch can diff --git a/server/services/characterMetrics.test.js b/server/services/characterMetrics.test.js index f603b3b40e..1085def431 100644 --- a/server/services/characterMetrics.test.js +++ b/server/services/characterMetrics.test.js @@ -205,6 +205,19 @@ describe('getCharacterMetrics — populated domains', () => { expect(userLocalToday).toHaveBeenCalled(); }); + it('re-keys legacy POST instants with the same timezone as Days Active', async () => { + stats.today = '2026-07-18'; + stats.timezone = 'America/Los_Angeles'; + stats.training = [ + { date: '2026-07-17T09:00:00.000Z' }, + { date: '2026-07-18T02:00:00.000Z' }, + ]; + + expect(byId(await getCharacterMetrics(), 'postStreakDays')).toMatchObject({ + value: 1, unavailable: false, + }); + }); + it('reports a broken POST streak as a real 0, not as unavailable', async () => { stats.today = '2026-07-17'; stats.sessions = [{ date: '2026-01-01' }]; // active once, long ago diff --git a/server/services/characterSignals.js b/server/services/characterSignals.js index 5f82ee5453..d864f74ccf 100644 --- a/server/services/characterSignals.js +++ b/server/services/characterSignals.js @@ -71,10 +71,11 @@ export const SIGNAL_READERS = { // a day for any user whose configured timezone isn't the server's. postToday: () => userLocalToday(), // The user's IANA timezone itself, for consumers that must re-key a stored INSTANT to the - // user's day rather than just compare against today (`daysActive` — see - // `server/lib/activeDays.js`). Same settings read `postToday` bottoms out in, so keeping it - // as its own signal costs one extra `getSettings()` per request and keeps the day-boundary - // decision in ONE place instead of forking a second "which day is this?" implementation. + // user's day rather than just compare against today (`daysActive` and `postStreakDays` — see + // `server/lib/activeDays.js` and `server/lib/postStreak.js`). Same settings read `postToday` + // bottoms out in, so keeping it as its own signal costs one extra `getSettings()` per request + // and keeps the day-boundary decision in ONE place instead of forking a second "which day is + // this?" implementation. userTimezone: () => getUserTimezone(), // `withActiveDayKeys` (#4120): the raw union of stored health-log day keys, which the // cross-domain `daysActive` metric unions with POST's days. Opt-in at the service so the diff --git a/server/services/meatspacePost.js b/server/services/meatspacePost.js index 19cbfc6df2..dfbbddc140 100644 --- a/server/services/meatspacePost.js +++ b/server/services/meatspacePost.js @@ -37,7 +37,7 @@ import { applySessionToReviewSchedule, getDueReviews, getRetentionReport } from import { getAllTrainingEntries } from './postTrainingLogStore.js'; import { getMorseProgress, MAX_KOCH_LEVEL } from './meatspacePostMorse.js'; import { computePostStreaks, computeUnifiedStreak, normalizeYmd, ymdToUTC, ymdShift } from '../lib/postStreak.js'; -import { userLocalToday as localToday } from '../lib/timezone.js'; +import { getUserTimezone, todayInTimezone, userLocalToday as localToday } from '../lib/timezone.js'; // Re-export the shared streak helper so existing importers of // `computePostStreaks` from this module keep working after it moved to @@ -241,8 +241,13 @@ async function loadSessions({ strict = false } = {}) { export async function getPostSessions(from, to, options) { const data = await loadSessions(options); let sessions = data.sessions; - if (from) sessions = sessions.filter(s => s.date >= from); - if (to) sessions = sessions.filter(s => s.date <= to); + if (from || to) { + const timezone = await getUserTimezone(); + sessions = sessions.filter((session) => { + const date = normalizeYmd(session?.date, timezone); + return date && (!from || date >= from) && (!to || date <= to); + }); + } return sessions; } @@ -641,16 +646,17 @@ export function deriveTaskAvgResponseMs(task) { export async function getPostStats(days = 30) { const sessions = await getPostSessions(); - const todayStr = await localToday(); + const timezone = await getUserTimezone(); + const todayStr = todayInTimezone(timezone); // Streaks are computed over ALL history, independent of the stats window, and // over BOTH scored sessions and the training log so the launcher/dashboard // streak matches the Morse trainer and the Progress page (issue #2091). // `completedToday`/`todayScore` stay SCORED-session specific — they answer // "did you complete a scored POST today / what did you score", which a // practice-only day legitimately doesn't satisfy. - const sessionStreaks = computePostStreaks(sessions, todayStr); + const sessionStreaks = computePostStreaks(sessions, todayStr, timezone); const training = await getAllTrainingEntries(); - const unified = computeUnifiedStreak(sessions, training, todayStr); + const unified = computeUnifiedStreak(sessions, training, todayStr, timezone); const streaks = { ...sessionStreaks, currentStreak: unified.current, @@ -662,7 +668,10 @@ export async function getPostStats(days = 30) { // Window the stats relative to the user's local today (day-string math via // UTC midnight, DST-safe) so the cutoff matches the tz-correct session dates. const cutoffStr = ymdShift(todayStr, -days); - recent = sessions.filter(s => s.date >= cutoffStr); + recent = sessions.filter(s => { + const date = normalizeYmd(s?.date, timezone); + return date && date >= cutoffStr; + }); } if (recent.length === 0) { @@ -775,21 +784,32 @@ function finalizeMetricSeries(map) { * fallback for legacy sessions. */ export async function getPostProgress({ days = 90 } = {}) { - const todayStr = await localToday(); + const timezone = await getUserTimezone(); + const todayStr = todayInTimezone(timezone); const window = Number.isFinite(days) && days > 0 ? Math.min(days, 365) : 0; const allSessions = await getPostSessions(); const allTraining = await getAllTrainingEntries(); // Unified streak is computed over ALL history, independent of the window. - const streak = computeUnifiedStreak(allSessions, allTraining, todayStr); + const streak = computeUnifiedStreak(allSessions, allTraining, todayStr, timezone); let cutoffStr = null; if (window > 0) { cutoffStr = new Date(ymdToUTC(todayStr) - window * 86400000).toISOString().split('T')[0]; } - const sessions = cutoffStr ? allSessions.filter(s => (s.date || '') >= cutoffStr) : allSessions; - const training = cutoffStr ? allTraining.filter(e => String(e.date || '').split('T')[0] >= cutoffStr) : allTraining; + const sessions = cutoffStr + ? allSessions.filter(s => { + const date = normalizeYmd(s?.date, timezone); + return date && date >= cutoffStr; + }) + : allSessions; + const training = cutoffStr + ? allTraining.filter(e => { + const date = normalizeYmd(e?.date, timezone); + return date && date >= cutoffStr; + }) + : allTraining; // Per-day buckets for the headline trends, plus per-domain/per-drill series. const dayMap = new Map(); // date -> { scores, accs, resp, minutes, sessions } @@ -803,7 +823,7 @@ export async function getPostProgress({ days = 90 } = {}) { }; for (const s of sessions) { - const date = s.date; + const date = normalizeYmd(s?.date, timezone); if (!date) continue; const day = ensureDay(date); day.sessions += 1; @@ -829,7 +849,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 = String(e.date || '').split('T')[0]; + const date = normalizeYmd(e?.date, timezone); if (!date) continue; ensureDay(date).minutes += (e.totalMs || 0) / 60000; } @@ -1066,29 +1086,30 @@ export function memoryItemIdFromReview(review) { * @param {Array} sessions - all scored sessions (`{ date, tasks: [{ type }] }`) * @param {Array} trainingEntries - all training-log entries * @param {string|null} todayStr - the user's local `YYYY-MM-DD` + * @param {string} [timezone] - user timezone for re-keying legacy ISO dates * @returns {{ drillTypes: Set, memoryItemIds: Set, completedSession: boolean }} */ -export function practicedTodayFromActivity(sessions = [], trainingEntries = [], todayStr = null) { +export function practicedTodayFromActivity(sessions = [], trainingEntries = [], todayStr = null, timezone) { const drillTypes = new Set(); const memoryItemIds = new Set(); let completedSession = false; // No resolvable local day ⇒ report nothing practiced rather than guessing, so // the routine degrades to its pre-#3563 ordering instead of silently demoting. - const today = normalizeYmd(todayStr); + 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. for (const session of sessions || []) { - if (normalizeYmd(session?.date) !== today) continue; + if (normalizeYmd(session?.date, 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) !== today) continue; + if (normalizeYmd(entry?.date, 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); @@ -1277,7 +1298,7 @@ export function isRecDrillRunnable(config, module, type, memoryItemId = null) { * config can actually run. */ export async function getPostRecommendations({ limit = RECOMMENDATION_LIMIT } = {}) { - const [dueMemoryItems, dueReviews, stats, mulProgress, powersProgress, cogProgress, morse, sessions, config, training, todayStr] = await Promise.all([ + const [dueMemoryItems, dueReviews, stats, mulProgress, powersProgress, cogProgress, morse, sessions, config, training, timezone] = await Promise.all([ getDueMemoryItems(), getDueReviews(new Date(), Infinity), getPostStats(MASTERY_DEFAULTS.windowDays), @@ -1288,8 +1309,9 @@ export async function getPostRecommendations({ limit = RECOMMENDATION_LIMIT } = getPostSessions(), getPostConfig(), getAllTrainingEntries(), - localToday(), + getUserTimezone(), ]); + const todayStr = todayInTimezone(timezone); // Weakest skill — drop it unless the drill is currently runnable; a memory // weak-skill deep-links to its own tab rather than a composed session. @@ -1339,7 +1361,7 @@ export async function getPostRecommendations({ limit = RECOMMENDATION_LIMIT } = weakestSkill, stalled, hasHistory: sessions.length > 0, - practicedToday: practicedTodayFromActivity(sessions, training, todayStr), + practicedToday: practicedTodayFromActivity(sessions, training, todayStr, timezone), limit, }), }; @@ -1546,7 +1568,9 @@ async function getMultiplicationLevelStats(windowDays = MASTERY_DEFAULTS.windowD // Window off the user's local today (DST-safe day math) so the cutoff stays // consistent with the tz-correct session dates submitPostSession now stamps // (issue #2681) — a UTC-day cutoff would skew the window edge by the tz offset. - const cutoffStr = windowDays > 0 ? ymdShift(await localToday(), -windowDays) : null; + const timezone = await getUserTimezone(); + const todayStr = todayInTimezone(timezone); + const cutoffStr = windowDays > 0 ? ymdShift(todayStr, -windowDays) : null; const byLevel = {}; let floorLevel = 0; @@ -1560,7 +1584,8 @@ 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. - if (cutoffStr && session.date < cutoffStr) continue; + const date = normalizeYmd(session?.date, 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 || []) { if (q?.answered == null) continue; @@ -1586,7 +1611,9 @@ async function getMultiplicationLevelStats(windowDays = MASTERY_DEFAULTS.windowD async function getPowersLevelStats(windowDays = POWERS_MASTERY_DEFAULTS.windowDays) { const sessions = await getPostSessions(); - const cutoffStr = windowDays > 0 ? ymdShift(await localToday(), -windowDays) : null; + const timezone = await getUserTimezone(); + const todayStr = todayInTimezone(timezone); + const cutoffStr = windowDays > 0 ? ymdShift(todayStr, -windowDays) : null; const byLevel = {}; let floorLevel = 0; for (const session of sessions) { @@ -1596,7 +1623,8 @@ 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; - if (cutoffStr && session.date < cutoffStr) continue; + const date = normalizeYmd(session?.date, timezone); + if (cutoffStr && (!date || date < cutoffStr)) continue; for (const question of task.questions || []) { if (question?.answered == null) continue; const match = typeof question.prompt === 'string' ? question.prompt.match(/^(\d+)\^(\d+)$/) : null; @@ -1667,7 +1695,9 @@ async function getCognitiveLevelStats(type, windowDays = COGNITIVE_MASTERY_DEFAU const sessions = await getPostSessions(); // Window off the user's local today (DST-safe) so the cutoff stays consistent // with the tz-correct session dates submitPostSession now stamps (issue #2681). - const cutoffStr = windowDays > 0 ? ymdShift(await localToday(), -windowDays) : null; + const timezone = await getUserTimezone(); + const todayStr = todayInTimezone(timezone); + const cutoffStr = windowDays > 0 ? ymdShift(todayStr, -windowDays) : null; const byLevel = {}; let floorLevel = 0; @@ -1681,7 +1711,8 @@ 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. - if (cutoffStr && session.date < cutoffStr) continue; + const date = normalizeYmd(session?.date, timezone); + if (cutoffStr && (!date || date < cutoffStr)) continue; const acc = Number.isFinite(task.accuracy) ? task.accuracy : null; if (acc == null) continue; // Skip low-completion runs: accuracy is answered-only, so a run that diff --git a/server/services/meatspacePost.test.js b/server/services/meatspacePost.test.js index a2c78da2ab..6c13164cb0 100644 --- a/server/services/meatspacePost.test.js +++ b/server/services/meatspacePost.test.js @@ -42,6 +42,7 @@ import { getMultiplicationProgress, getAdaptivePreview, getPostStats, + getPostSessions, deriveTaskAccuracy, deriveTaskCompletion, getCognitiveProgress, @@ -955,6 +956,11 @@ describe('resolveDrillConfig — progressive multiplication', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.useRealTimers(); + settingsState.current = { timezone: 'UTC' }; + }); + it('starts a fresh user at level 0 (single×single) and strips maxDigits', async () => { mockSessions([]); const { config, progression } = await resolveDrillConfig('multiplication', { count: 10, maxDigits: 2 }); @@ -998,6 +1004,16 @@ describe('resolveDrillConfig — progressive multiplication', () => { expect(config.factors).toEqual([1, 1, 1]); }); + it('includes legacy ISO sessions whose user-local day is at the window edge', async () => { + settingsState.current = { timezone: 'Asia/Tokyo' }; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T00:30:00.000Z')); + mockSessions([masteredSession(0, 14, 2500, '2026-06-17T15:30:00.000Z')]); + + const progression = await getMultiplicationProgress(); + expect(progression.level).toBe(1); + }); + it('getMultiplicationProgress exposes the full ladder + thresholds', async () => { mockSessions([masteredSession(0)]); const progress = await getMultiplicationProgress(); @@ -1283,6 +1299,7 @@ describe('adaptive signal is accuracy-driven — fast-sloppy vs slow-accurate di describe('getPostStats — byModule averaging, days window cutoff, empty-window shape', () => { beforeEach(() => { vi.clearAllMocks(); }); + afterEach(() => { settingsState.current = { timezone: 'UTC' }; }); function mockSessions(sessions) { readJSONFile.mockImplementation((path, defaultValue) => { @@ -1327,6 +1344,16 @@ describe('getPostStats — byModule averaging, days window cutoff, empty-window expect(stats.overall).toBe(100); }); + it('filters legacy ISO session history by the configured local day', async () => { + settingsState.current = { timezone: 'America/Los_Angeles' }; + mockSessions([ + { date: '2026-07-18T00:30:00.000Z', score: 100, tasks: [] }, + ]); + + const sessions = await getPostSessions('2026-07-17', '2026-07-17'); + expect(sessions).toHaveLength(1); + }); + it('returns the zeroed empty-window shape when nothing falls inside the window, but streaks still pass through from all-time history', async () => { const oldDate = new Date(Date.now() - 90 * 86400000).toISOString().split('T')[0]; mockSessions([ @@ -1425,6 +1452,18 @@ describe('getPostStats / submitPostSession — timezone-correct day boundary (is expect(stats.todayScore).toBe(72); }); + it('uses the user-local day for legacy ISO sessions in the stats window (Asia/Tokyo)', async () => { + // The instant is July 17 in Tokyo but its UTC prefix is July 16. A one-day + // local window ending July 18 must still include it. + freezeAt('2026-07-18T00:30:00Z', 'Asia/Tokyo'); + mockSessions([ + { date: '2026-07-16T15:30:00.000Z', score: 84, tasks: [{ module: 'mental-math', type: 'doubling-chain', score: 84 }] }, + ]); + const stats = await getPostStats(1); + expect(stats.sessionCount).toBe(1); + expect(stats.overall).toBe(84); + }); + it('submitPostSession stamps a new session date in the user local timezone, not the server UTC day', async () => { // UTC day July 16 / LA day July 15 — a freshly submitted session must be // dated by the user's local day so completedToday later agrees with it. diff --git a/server/services/meatspacePostProgress.test.js b/server/services/meatspacePostProgress.test.js index f8dbdb8b64..5cf5cb8ae2 100644 --- a/server/services/meatspacePostProgress.test.js +++ b/server/services/meatspacePostProgress.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Route file reads by path so getPostProgress sees sessions, the training log, // and memory items independently (all share the same mocked fileUtils). @@ -21,8 +21,9 @@ vi.mock('../lib/fileUtils.js', () => ({ // getUserTimezone (via ../lib/timezone.js) reads getSettings() for the local-day // boundary (issue #2681). Pin it to UTC so "today" is the UTC day regardless of // the runner's own system timezone — matching these tests' UTC-today assumptions. +const settingsState = vi.hoisted(() => ({ timezone: 'UTC' })); vi.mock('../services/settings.js', () => ({ - getSettings: () => Promise.resolve({ timezone: 'UTC' }), + getSettings: () => Promise.resolve(settingsState), })); import { getPostProgress, getPostStats } from './meatspacePost.js'; @@ -40,6 +41,11 @@ beforeEach(() => { state.sessions = []; state.training = []; state.memoryItems = []; + settingsState.timezone = 'UTC'; +}); + +afterEach(() => { + vi.useRealTimers(); }); describe('getPostProgress bucketing', () => { @@ -96,6 +102,26 @@ describe('getPostProgress bucketing', () => { expect(day.minutes).toBe(2); }); + it('uses the user-local day for legacy ISO training buckets and windows', async () => { + settingsState.timezone = 'Asia/Tokyo'; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T00:30:00.000Z')); + state.training = [{ + date: '2026-07-16T15:30:00.000Z', + module: 'morse', + drillType: 'morse-copy', + questionCount: 5, + correctCount: 4, + totalMs: 60000, + }]; + + const p = await getPostProgress({ days: 1 }); + expect(p.totals.practiceEntries).toBe(1); // July 17 in Tokyo; raw UTC prefix is July 16. + expect(p.series.byDay).toEqual([ + expect.objectContaining({ date: '2026-07-17', minutes: 1, sessions: 0 }), + ]); + }); + it('builds per-domain and per-drill series keyed correctly', async () => { const d = todayStr(); state.sessions = [ diff --git a/server/services/meatspacePostRecommendations.test.js b/server/services/meatspacePostRecommendations.test.js index 1f8b12b6e4..9ff8f769ae 100644 --- a/server/services/meatspacePostRecommendations.test.js +++ b/server/services/meatspacePostRecommendations.test.js @@ -23,11 +23,12 @@ vi.mock('../lib/fileUtils.js', () => ({ }), })); -// getPostStats (via getPostRecommendations) derives the local day through -// getUserTimezone → getSettings (issue #2681). Pin it to UTC so the day boundary -// is the UTC day regardless of the runner's own system timezone. +// POST recommendation and stats day keys derive from getUserTimezone → getSettings +// (issue #2681). Pin it to UTC by default so the day boundary is deterministic; +// tz-specific tests set the mutable state below. +const settingsState = vi.hoisted(() => ({ timezone: 'UTC' })); vi.mock('../services/settings.js', () => ({ - getSettings: () => Promise.resolve({ timezone: 'UTC' }), + getSettings: () => Promise.resolve(settingsState), })); import { @@ -50,9 +51,14 @@ beforeEach(() => { state.reviewSchedule = { skills: {} }; state.morse = { kochLevel: null, settings: null, rounds: [] }; state.config = {}; + settingsState.timezone = 'UTC'; atomicWrite.mockClear(); }); +afterEach(() => { + vi.useRealTimers(); +}); + // Read back the config object written to post-config.json by the most recent // updatePostConfig call (atomicWrite is the mocked writer). function lastWrittenConfig() { @@ -438,6 +444,14 @@ describe('practicedTodayFromActivity', () => { expect([...done.drillTypes]).toEqual(['morse-copy']); }); + it('normalizes legacy ISO activity through the configured timezone', () => { + const done = practicedTodayFromActivity([ + { date: '2026-07-17T15:30:00.000Z', tasks: [{ type: 'digit-span' }] }, + ], [], '2026-07-18', 'Asia/Tokyo'); + expect([...done.drillTypes]).toEqual(['digit-span']); + expect(done.completedSession).toBe(true); + }); + it('reports nothing practiced when the local day cannot be resolved', () => { const done = practicedTodayFromActivity([{ date: '2026-08-05', tasks: [{ type: 'digit-span' }] }], [], null); expect(done.drillTypes.size).toBe(0); @@ -577,6 +591,26 @@ describe('getPostRecommendations (integration)', () => { expect(recommendations[0].drillType).not.toBe('digit-span'); }); + it('uses the configured local day for legacy session dates', async () => { + settingsState.timezone = 'Asia/Tokyo'; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T00:30:00.000Z')); + state.sessions = [{ + id: 'legacy-local-day', + date: '2026-07-17T15:30:00.000Z', + score: 60, + durationMs: 60000, + tasks: [{ module: 'cognitive', type: 'digit-span', score: 60, accuracy: 0.4, completion: 1 }], + }]; + state.morse = { kochLevel: 3, settings: { kochLevel: 3 }, rounds: [] }; + state.config = { memory: { enabled: false }, topics: { memory: { enabled: false } } }; + + const { recommendations } = await getPostRecommendations(); + const digitSpan = recommendations.filter(r => r.drillType === 'digit-span'); + expect(digitSpan.length).toBeGreaterThan(0); + for (const rec of digitSpan) expect(rec.practicedToday).toBe(true); + }); + it('never returns an empty list on a fresh install', async () => { // A fresh install still has the built-in Elements Song memory item (which // may be due) so the list is never empty; every entry carries a deep link. diff --git a/server/services/meatspacePostTraining.js b/server/services/meatspacePostTraining.js index 7217d288f9..cf7cf8f89c 100644 --- a/server/services/meatspacePostTraining.js +++ b/server/services/meatspacePostTraining.js @@ -6,8 +6,8 @@ */ import { randomUUID } from 'crypto'; -import { userLocalToday } from '../lib/timezone.js'; -import { ymdShift } from '../lib/postStreak.js'; +import { getUserTimezone, todayInTimezone, userLocalToday } from '../lib/timezone.js'; +import { normalizeYmd, ymdShift } from '../lib/postStreak.js'; import { getUnifiedActivityStreak } from './postActivityStreak.js'; import { loadTrainingLog, saveTrainingLog, getAllTrainingEntries } from './postTrainingLogStore.js'; @@ -65,14 +65,19 @@ export async function submitTrainingEntry(entry) { export async function getTrainingStats(days = 30) { const data = await loadTrainingLog(); const allEntries = data.entries; + const timezone = await getUserTimezone(); + const todayStr = todayInTimezone(timezone); let entries = allEntries; if (days > 0) { // Window off the user's local today (DST-safe day math) so the cutoff matches // the local-day strings the training/practice writers now stamp (issue #2681); // a UTC-day cutoff would clip the oldest local day or admit an extra one. - const cutoffStr = ymdShift(await userLocalToday(), -days); - entries = allEntries.filter(e => String(e.date || '').split('T')[0] >= cutoffStr); + const cutoffStr = ymdShift(todayStr, -days); + entries = allEntries.filter(e => { + const date = normalizeYmd(e?.date, timezone); + return date && date >= cutoffStr; + }); } // Group by drill type (windowed) @@ -84,15 +89,16 @@ export async function getTrainingStats(days = 30) { byDrill[key].totalCorrect += e.correctCount || 0; byDrill[key].totalQuestions += e.questionCount || 0; byDrill[key].totalMs += e.totalMs || 0; - byDrill[key].dates.add(String(e.date || '').split('T')[0]); + const date = normalizeYmd(e?.date, timezone); + if (date) byDrill[key].dates.add(date); } // ONE unified streak across sessions + training (shared helper, ALL history). // Pass allEntries (already loaded above) rather than re-fetching via // 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); - const activeDays = new Set(entries.map(e => String(e.date || '').split('T')[0])).size; + const { current: currentStreak, longest: longestStreak } = await getUnifiedActivityStreak(allEntries, todayStr, timezone); + const activeDays = new Set(entries.map(e => normalizeYmd(e?.date, timezone)).filter(Boolean)).size; // Summarize const summary = {}; diff --git a/server/services/meatspacePostTraining.test.js b/server/services/meatspacePostTraining.test.js index 2c91709ff8..006b2f746b 100644 --- a/server/services/meatspacePostTraining.test.js +++ b/server/services/meatspacePostTraining.test.js @@ -214,6 +214,32 @@ describe('getTrainingStats', () => { settingsState.current = { timezone: 'UTC' }; } }); + + it('uses the user-local day for legacy ISO entries in windows and active-day counts', async () => { + settingsState.current = { timezone: 'Asia/Tokyo' }; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T00:30:00.000Z')); + try { + readJSONFile.mockResolvedValue({ + entries: [{ + date: '2026-07-16T15:30:00.000Z', + module: 'morse', + drillType: 'morse-copy', + questionCount: 5, + correctCount: 4, + totalMs: 60000, + }], + }); + + const stats = await getTrainingStats(1); + expect(stats.totalEntries).toBe(1); // July 17 in Tokyo; raw UTC prefix is July 16. + expect(stats.activeDays).toBe(1); + expect(stats.byDrill['morse:morse-copy'].daysActive).toBe(1); + } finally { + vi.useRealTimers(); + settingsState.current = { timezone: 'UTC' }; + } + }); }); describe('getTrainingEntries', () => { diff --git a/server/services/postActivityStreak.js b/server/services/postActivityStreak.js index 214655ce92..99dc1db41a 100644 --- a/server/services/postActivityStreak.js +++ b/server/services/postActivityStreak.js @@ -15,7 +15,7 @@ */ import { getPostSessions } from './meatspacePost.js'; import { computeUnifiedStreak } from '../lib/postStreak.js'; -import { userLocalToday as localToday } from '../lib/timezone.js'; +import { getUserTimezone, todayInTimezone } from '../lib/timezone.js'; /** * ONE unified activity streak across scored sessions AND the training log — the @@ -29,9 +29,11 @@ import { userLocalToday as localToday } from '../lib/timezone.js'; * rather than fetched here so this module doesn't need to import * meatspacePostTraining.js — see the module docblock above. * @param {string} [todayStr] - defaults to the user's local today. + * @param {string} [timezone] - defaults to the user's configured timezone. */ -export async function getUnifiedActivityStreak(training, todayStr) { - const day = todayStr ?? await localToday(); +export async function getUnifiedActivityStreak(training, todayStr, timezone) { + const resolvedTimezone = timezone ?? await getUserTimezone(); + const day = todayStr ?? todayInTimezone(resolvedTimezone); const sessions = await getPostSessions(); - return computeUnifiedStreak(sessions, training, day); + return computeUnifiedStreak(sessions, training, day, resolvedTimezone); } From 984ebaa1ed572c953df0bfa5ff7f845bbde55d4f Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 06:30:09 +0000 Subject: [PATCH 2/3] fix: anchor POST day reads to request instant (#4211) --- server/services/meatspacePost.js | 18 +++++++++----- server/services/meatspacePost.test.js | 30 +++++++++++++++++++++--- server/services/meatspacePostTraining.js | 3 ++- server/services/postActivityStreak.js | 3 ++- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/server/services/meatspacePost.js b/server/services/meatspacePost.js index dfbbddc140..8f28e028de 100644 --- a/server/services/meatspacePost.js +++ b/server/services/meatspacePost.js @@ -645,9 +645,10 @@ export function deriveTaskAvgResponseMs(task) { } export async function getPostStats(days = 30) { + const atDate = new Date(); const sessions = await getPostSessions(); const timezone = await getUserTimezone(); - const todayStr = todayInTimezone(timezone); + const todayStr = todayInTimezone(timezone, atDate); // Streaks are computed over ALL history, independent of the stats window, and // over BOTH scored sessions and the training log so the launcher/dashboard // streak matches the Morse trainer and the Progress page (issue #2091). @@ -784,8 +785,9 @@ function finalizeMetricSeries(map) { * fallback for legacy sessions. */ export async function getPostProgress({ days = 90 } = {}) { + const atDate = new Date(); const timezone = await getUserTimezone(); - const todayStr = todayInTimezone(timezone); + const todayStr = todayInTimezone(timezone, atDate); const window = Number.isFinite(days) && days > 0 ? Math.min(days, 365) : 0; const allSessions = await getPostSessions(); @@ -1298,6 +1300,7 @@ export function isRecDrillRunnable(config, module, type, memoryItemId = null) { * config can actually run. */ export async function getPostRecommendations({ limit = RECOMMENDATION_LIMIT } = {}) { + const atDate = new Date(); const [dueMemoryItems, dueReviews, stats, mulProgress, powersProgress, cogProgress, morse, sessions, config, training, timezone] = await Promise.all([ getDueMemoryItems(), getDueReviews(new Date(), Infinity), @@ -1311,7 +1314,7 @@ export async function getPostRecommendations({ limit = RECOMMENDATION_LIMIT } = getAllTrainingEntries(), getUserTimezone(), ]); - const todayStr = todayInTimezone(timezone); + const todayStr = todayInTimezone(timezone, atDate); // Weakest skill — drop it unless the drill is currently runnable; a memory // weak-skill deep-links to its own tab rather than a composed session. @@ -1564,12 +1567,13 @@ async function getAdaptiveSignal(type) { * @returns {Promise<{stats: Record, floorLevel: number}>} */ async function getMultiplicationLevelStats(windowDays = MASTERY_DEFAULTS.windowDays) { + const atDate = new Date(); const sessions = await getPostSessions(); // Window off the user's local today (DST-safe day math) so the cutoff stays // consistent with the tz-correct session dates submitPostSession now stamps // (issue #2681) — a UTC-day cutoff would skew the window edge by the tz offset. const timezone = await getUserTimezone(); - const todayStr = todayInTimezone(timezone); + const todayStr = todayInTimezone(timezone, atDate); const cutoffStr = windowDays > 0 ? ymdShift(todayStr, -windowDays) : null; const byLevel = {}; @@ -1610,9 +1614,10 @@ async function getMultiplicationLevelStats(windowDays = MASTERY_DEFAULTS.windowD } async function getPowersLevelStats(windowDays = POWERS_MASTERY_DEFAULTS.windowDays) { + const atDate = new Date(); const sessions = await getPostSessions(); const timezone = await getUserTimezone(); - const todayStr = todayInTimezone(timezone); + const todayStr = todayInTimezone(timezone, atDate); const cutoffStr = windowDays > 0 ? ymdShift(todayStr, -windowDays) : null; const byLevel = {}; let floorLevel = 0; @@ -1692,11 +1697,12 @@ export async function getPowersProgress() { * @returns {Promise<{stats: Record, floorLevel: number}>} */ async function getCognitiveLevelStats(type, windowDays = COGNITIVE_MASTERY_DEFAULTS.windowDays) { + const atDate = new Date(); const sessions = await getPostSessions(); // Window off the user's local today (DST-safe) so the cutoff stays consistent // with the tz-correct session dates submitPostSession now stamps (issue #2681). const timezone = await getUserTimezone(); - const todayStr = todayInTimezone(timezone); + const todayStr = todayInTimezone(timezone, atDate); const cutoffStr = windowDays > 0 ? ymdShift(todayStr, -windowDays) : null; const byLevel = {}; diff --git a/server/services/meatspacePost.test.js b/server/services/meatspacePost.test.js index 6c13164cb0..9ba1bf39ba 100644 --- a/server/services/meatspacePost.test.js +++ b/server/services/meatspacePost.test.js @@ -20,9 +20,14 @@ vi.mock('../lib/fileUtils.js', () => ({ // assertion holds deterministically (an unpinned `{}` would fall back to the // runner's system tz and break these suites off a non-UTC CI runner). tz-specific // tests set settingsState.current to a real IANA zone. -const settingsState = vi.hoisted(() => ({ current: { timezone: 'UTC' } })); +const settingsState = vi.hoisted(() => ({ current: { timezone: 'UTC' }, onRead: null })); vi.mock('../services/settings.js', () => ({ - getSettings: () => Promise.resolve(settingsState.current), + getSettings: () => { + const onRead = settingsState.onRead; + settingsState.onRead = null; + onRead?.(); + return Promise.resolve(settingsState.current); + }, })); import { readJSONFile, atomicWrite } from '../lib/fileUtils.js'; @@ -1398,7 +1403,11 @@ describe('getPostStats — byModule averaging, days window cutoff, empty-window describe('getPostStats / submitPostSession — timezone-correct day boundary (issue #2681)', () => { beforeEach(() => { vi.clearAllMocks(); }); - afterEach(() => { vi.useRealTimers(); settingsState.current = { timezone: 'UTC' }; }); + afterEach(() => { + vi.useRealTimers(); + settingsState.current = { timezone: 'UTC' }; + settingsState.onRead = null; + }); function mockSessions(sessions) { readJSONFile.mockImplementation((path, defaultValue) => { @@ -1464,6 +1473,21 @@ describe('getPostStats / submitPostSession — timezone-correct day boundary (is expect(stats.overall).toBe(84); }); + it('anchors the read-side day to the request-start instant when settings resolve after midnight', async () => { + // The settings read crosses LA midnight. The loaded session still belongs to + // the request-start local day; todayInTimezone must use that captured instant + // rather than the clock after the awaited settings read. + freezeAt('2026-07-18T06:59:59.000Z', 'America/Los_Angeles'); + settingsState.onRead = () => vi.setSystemTime(new Date('2026-07-18T07:00:01.000Z')); + mockSessions([ + { date: '2026-07-17', score: 91, tasks: [{ module: 'mental-math', type: 'doubling-chain', score: 91 }] }, + ]); + + const stats = await getPostStats(30); + expect(stats.completedToday).toBe(true); + expect(stats.todayScore).toBe(91); + }); + it('submitPostSession stamps a new session date in the user local timezone, not the server UTC day', async () => { // UTC day July 16 / LA day July 15 — a freshly submitted session must be // dated by the user's local day so completedToday later agrees with it. diff --git a/server/services/meatspacePostTraining.js b/server/services/meatspacePostTraining.js index cf7cf8f89c..e7bed65c2d 100644 --- a/server/services/meatspacePostTraining.js +++ b/server/services/meatspacePostTraining.js @@ -63,10 +63,11 @@ export async function submitTrainingEntry(entry) { * ALL history; only the per-drill breakdown below is windowed. */ export async function getTrainingStats(days = 30) { + const atDate = new Date(); const data = await loadTrainingLog(); const allEntries = data.entries; const timezone = await getUserTimezone(); - const todayStr = todayInTimezone(timezone); + const todayStr = todayInTimezone(timezone, atDate); let entries = allEntries; if (days > 0) { diff --git a/server/services/postActivityStreak.js b/server/services/postActivityStreak.js index 99dc1db41a..1aa80cd785 100644 --- a/server/services/postActivityStreak.js +++ b/server/services/postActivityStreak.js @@ -32,8 +32,9 @@ import { getUserTimezone, todayInTimezone } from '../lib/timezone.js'; * @param {string} [timezone] - defaults to the user's configured timezone. */ export async function getUnifiedActivityStreak(training, todayStr, timezone) { + const atDate = new Date(); const resolvedTimezone = timezone ?? await getUserTimezone(); - const day = todayStr ?? todayInTimezone(resolvedTimezone); + const day = todayStr ?? todayInTimezone(resolvedTimezone, atDate); const sessions = await getPostSessions(); return computeUnifiedStreak(sessions, training, day, resolvedTimezone); } From ebd53fc609b13e8867f0fda24670c98eaedd0f28 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 07:30:52 -0700 Subject: [PATCH 3/3] fix: mock PATHS.data in scaffold/agentTuiSpawning fileUtils mocks postStreak.js now imports activeDays.js (for toUserDayKey), which imports timezone.js, which imports services/settings.js. settings.js reads PATHS.data at module scope, so any test mocking lib/fileUtils.js without PATHS.data throws on load. Both suites reach this chain transitively via lib/validation.js -> subscriptionSavings.js -> postStreak.js. --- server/routes/scaffold.test.js | 7 ++++++- server/services/agentTuiSpawning.test.js | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/server/routes/scaffold.test.js b/server/routes/scaffold.test.js index 15c59a034e..4f19e369ea 100644 --- a/server/routes/scaffold.test.js +++ b/server/routes/scaffold.test.js @@ -6,9 +6,14 @@ import { request } from '../lib/testHelper.js'; // --- Mock the filesystem + subprocess boundary so we can assert ZERO // mutations happen when a request fails validation (issue #2390). --- +// PATHS.data is required here too: scaffold.js pulls in lib/validation.js -> +// subscriptionSavings.js -> postStreak.js -> activeDays.js -> timezone.js -> +// services/settings.js, whose module-scope `join(PATHS.data, 'settings.json')` +// throws on load if this mock omits PATHS (#4211 added the activeDays.js edge). vi.mock('../lib/fileUtils.js', () => ({ ensureDir: vi.fn().mockResolvedValue(undefined), - expandHome: (p) => p + expandHome: (p) => p, + PATHS: { data: '/mock/data' } })); vi.mock('fs/promises', () => ({ diff --git a/server/services/agentTuiSpawning.test.js b/server/services/agentTuiSpawning.test.js index aeecdaccd5..ad03696672 100644 --- a/server/services/agentTuiSpawning.test.js +++ b/server/services/agentTuiSpawning.test.js @@ -155,7 +155,14 @@ vi.mock('../lib/fileUtils.js', async (importOriginal) => ({ // agentSentinel.parseSentinelPayload, etc.); only stub the I/O + PATHS. ...(await importOriginal()), tryReadFile: vi.fn().mockResolvedValue(null), - PATHS: { root: '/tmp/portos-root' } + // `data` is required alongside `root`: this module pulls in taskTypeHooks.js + // -> ... -> lib/validation.js -> subscriptionSavings.js -> postStreak.js -> + // activeDays.js -> timezone.js -> services/settings.js, whose module-scope + // `join(PATHS.data, 'settings.json')` throws on load without it (#4211 + // added the activeDays.js edge). The bare `PATHS: {...}` below fully + // replaces the real object (it doesn't merge), so every member this graph + // needs at import time has to be listed explicitly. + PATHS: { root: '/tmp/portos-root', data: '/tmp/portos-root/data' } })); vi.mock('../lib/providerModels.js', async (importOriginal) => ({