diff --git a/src/routes/snapshot.js b/src/routes/snapshot.js index 897d5d2..e44a997 100644 --- a/src/routes/snapshot.js +++ b/src/routes/snapshot.js @@ -50,18 +50,29 @@ router.get('/summary', async (req, res) => { const reads = await Promise.all(SUMMARY_SPORTS.map(async (sp) => { const snap = await cacheGet(`snapshot:${sp}:latest`); if (snap && Array.isArray(snap.grades)) { - return { sport: sp, graded: snap.grades.length, updated_at: snap.updated_at || null }; + return { + sport: sp, + graded: snap.grades.length, + updated_at: snap.updated_at || null, + refreshed_at: snap.refreshed_at || snap.updated_at || null, + }; } const env = await cacheGet(`grades:${sp}`); return { sport: sp, graded: env && Array.isArray(env.grades) ? env.grades.length : 0, updated_at: (env && env.updated_at) || null, + refreshed_at: (env && (env.refreshed_at || env.updated_at)) || null, }; })); const graded = reads.reduce((n, r) => n + r.graded, 0); // ISO timestamps sort lexicographically — the max is the latest run. + // `updated_at` = last grade LOCK (5×/day, intentionally stable). `refreshed_at` + // = last freshness heartbeat (grade lock OR intraday refresh, every ~20 min). + // The SYNC badge measures against refreshed_at; updated_at is exposed so the + // UI can distinguish "grades locked at X" from "lines synced at Y". const updated_at = reads.map((r) => r.updated_at).filter(Boolean).sort().pop() || null; + const refreshed_at = reads.map((r) => r.refreshed_at).filter(Boolean).sort().pop() || updated_at; const sports = {}; for (const r of reads) sports[r.sport] = r.graded; // Session 58 (Task 5) — the SYNC badge thresholds key off the pipeline's @@ -72,7 +83,7 @@ router.get('/summary', async (req, res) => { const expected_interval_s = Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) > 0 ? Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) : 18000; res.set('Cache-Control', 'public, max-age=30'); - return res.json({ graded, updated_at, sports, expected_interval_s }); + return res.json({ graded, updated_at, refreshed_at, sports, expected_interval_s }); } catch (err) { console.error('[snapshot/summary]', err.message); return res.status(200).json({ graded: 0, updated_at: null, sports: {} }); @@ -92,7 +103,7 @@ router.get('/:sport', async (req, res) => { const enrich = (grades) => attachLast10Dots(attachOutcomes(grades, idx), roster, sport); if (snap && Array.isArray(snap.grades)) { res.set('Cache-Control', 'public, max-age=30'); - return res.json({ sport, updated_at: snap.updated_at, grades: enrich(snap.grades), deltas: snap.deltas || [] }); + return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: enrich(snap.grades), deltas: snap.deltas || [] }); } // Fallback: the grades envelope (no deltas yet). const env = await cacheGet(`grades:${sport}`); diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 3dd1bd8..6fb7d7d 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -399,7 +399,13 @@ async function runSnapshot(sport, opts = {}) { // Lock: previous = old latest, latest = new, grades = enriched. if (prev) await deps.cacheSet(`snapshot:${sp}:previous`, prev, SNAP_TTL); - const snapshot = { sport: sp, updated_at: ts, grades: enriched, deltas, gradeCount: enriched.length }; + // `updated_at` = grade-LOCK time (advances only on a full snapshot, 5×/day — + // grades never change in-game, so this is intentionally stable). `refreshed_at` + // = the freshness heartbeat: seeded here at lock time, then bumped every + // intraday refresh (intradayRefreshService). The SYNC badge keys off + // refreshed_at — measuring the 20-min cadence against the grade-lock field is + // what produced the "SIGNAL LIVE vs STALE 8h" contradiction. + const snapshot = { sport: sp, updated_at: ts, refreshed_at: ts, grades: enriched, deltas, gradeCount: enriched.length }; await deps.cacheSet(`snapshot:${sp}:latest`, snapshot, SNAP_TTL); // Session 59 — grades:{sport} must outlive the gap between cron runs (up to // 5h) or team rosters / Explore / leaders go dark mid-day. SNAP_TTL (6h), diff --git a/tests/integration/snapshotSummary.test.js b/tests/integration/snapshotSummary.test.js index 20f1fb2..bfaf814 100644 --- a/tests/integration/snapshotSummary.test.js +++ b/tests/integration/snapshotSummary.test.js @@ -43,6 +43,28 @@ describe('GET /api/snapshot/summary', () => { expect(res.body.sports.nba).toBe(0); // off-season → honest zero }); + test('refreshed_at is the freshness heartbeat, distinct from the grade-lock updated_at', async () => { + // updated_at = grade LOCK (5×/day, stable). refreshed_at = last intraday + // line refresh (~20 min). The SYNC badge measures against refreshed_at — + // measuring the 20-min cadence against the lock field caused "STALE 8h". + store['snapshot:mlb:latest'] = { + updated_at: '2026-07-10T18:00:00.000Z', // graded at 2pm ET + refreshed_at: '2026-07-10T21:40:00.000Z', // lines refreshed at 5:40pm ET + grades: [{ player: 'A' }], + }; + const res = await request(mountApp()).get('/api/snapshot/summary'); + expect(res.body.updated_at).toBe('2026-07-10T18:00:00.000Z'); + expect(res.body.refreshed_at).toBe('2026-07-10T21:40:00.000Z'); + }); + + test('refreshed_at falls back to updated_at when a snapshot predates the field', async () => { + // Old snapshots written before this fix have no refreshed_at — the freshness + // signal must still resolve (never null while a grade lock exists). + store['snapshot:mlb:latest'] = { updated_at: '2026-07-10T18:00:00.000Z', grades: [{ player: 'A' }] }; + const res = await request(mountApp()).get('/api/snapshot/summary'); + expect(res.body.refreshed_at).toBe('2026-07-10T18:00:00.000Z'); + }); + test('falls back to the grades:{sport} envelope when no snapshot exists', async () => { store['grades:mlb'] = { updated_at: '2026-07-10T16:00:00.000Z', grades: [{ player: 'A' }, { player: 'B' }] }; const res = await request(mountApp()).get('/api/snapshot/summary'); diff --git a/web/src/components/vyndr/LiveLayer.tsx b/web/src/components/vyndr/LiveLayer.tsx index e5b164d..1a115f3 100644 --- a/web/src/components/vyndr/LiveLayer.tsx +++ b/web/src/components/vyndr/LiveLayer.tsx @@ -41,7 +41,12 @@ const EKG = 'M0 12 H10 L13 4 L16 20 L19 12 H30 L33 9 L36 15 L39 12 H50'; interface SnapshotSummary { graded: number; + /** Grade-LOCK time — advances only on a full snapshot (5×/day). Intentionally + * stable (grades never change in-game); NOT the freshness signal. */ updated_at: string | null; + /** Freshness heartbeat — grade lock OR intraday line refresh (~every 20 min). + * The SYNC badge keys off THIS, not updated_at. */ + refreshed_at: string | null; /** The pipeline's expected cadence (s) — SYNC thresholds key off this. */ expected_interval_s?: number; } @@ -84,6 +89,7 @@ export function HeartbeatBar() { setSummary({ graded: data.graded, updated_at: data.updated_at ?? null, + refreshed_at: data.refreshed_at ?? data.updated_at ?? null, expected_interval_s: Number(data.expected_interval_s) > 0 ? Number(data.expected_interval_s) : undefined, }); } @@ -97,7 +103,11 @@ export function HeartbeatBar() { }, []); void live.tick; // subscription drives the 1s clock re-render - const syncedAt = summary?.updated_at ? Date.parse(summary.updated_at) : NaN; + // Freshness is measured from the intraday heartbeat (refreshed_at), NOT the + // grade-lock time (updated_at). Measuring the 20-min cadence against the + // 5×/day lock field is what made a live pipeline read "STALE 8h". + const freshAt = summary?.refreshed_at ?? summary?.updated_at ?? null; + const syncedAt = freshAt ? Date.parse(freshAt) : NaN; const elapsed = Number.isFinite(syncedAt) ? Date.now() - syncedAt : null; const expectedMs = (summary?.expected_interval_s ?? DEFAULT_EXPECTED_S) * 1000; const tier: 'normal' | 'amber' | 'stale' =