7712f0a442
The "SIGNAL LIVE vs STALE 8h" contradiction was a field mismatch, not a dead pipeline. updated_at is the grade-LOCK time (advances only on a full snapshot, 5×/day — grades never change in-game, so it is intentionally stable). The SYNC badge measured the 20-min intraday cadence (expected_interval_s=1200) against that 5×/day field → structurally guaranteed STALE between slots even when intraday refreshes lines perfectly. - snapshotService: full snapshot now seeds refreshed_at at lock time - intradayRefresh already bumps refreshed_at every ~20 min (unchanged) - /api/snapshot/summary + GET /:sport now expose refreshed_at (was written to Redis but never serialized → no public liveness signal existed) - LiveLayer SYNC badge measures freshness from refreshed_at (fallback updated_at) Exposing refreshed_at also gives a public heartbeat probe: it advances every intraday slot, so pipeline liveness is verifiable without container logs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
5.6 KiB
JavaScript
120 lines
5.6 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* GET /api/snapshot/:sport (Session 45) — the latest pre-graded slate.
|
||
*
|
||
* Public, cache-only read of `snapshot:{sport}:latest` (enriched grades with
|
||
* archetype + gradedAt, plus line deltas). Falls back to the `grades:{sport}`
|
||
* envelope when no snapshot has run yet. NEVER triggers a snapshot (that's the
|
||
* internal cron's job) — so it can't drain the PropLine quota.
|
||
*/
|
||
|
||
const express = require('express');
|
||
const { createRateLimit } = require('../middleware/rateLimit');
|
||
const { cacheGet } = require('../utils/redis');
|
||
const { nameKey } = require('../utils/playerName');
|
||
// S6 (A1 board) — ●●○●● last-10 vs tonight's locked line, computed from the
|
||
// rosterlogs blob the snapshot pipeline already writes. Pure, cache-only.
|
||
const { indexRosterLogs, attachLast10Dots } = require('../services/last10Dots');
|
||
|
||
const router = express.Router();
|
||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||
|
||
// Session 55 — overlay settled outcomes (self-learning loop) onto the grades so
|
||
// a completed prop can render "✅ HIT (2)" / "❌ MISS". Keyed by player+stat+line+side.
|
||
function outcomeIndex(log) {
|
||
const map = {};
|
||
for (const o of Array.isArray(log) ? log : []) {
|
||
const side = String(o.side || 'O').toUpperCase() === 'U' ? 'U' : 'O';
|
||
map[`${nameKey(o.player)}|${String(o.stat).toLowerCase()}|${o.line}|${side}`] = o;
|
||
}
|
||
return map;
|
||
}
|
||
function attachOutcomes(grades, index) {
|
||
if (!index || Object.keys(index).length === 0) return grades;
|
||
return grades.map((g) => {
|
||
const side = String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O';
|
||
const o = index[`${nameKey(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}|${g.line}|${side}`];
|
||
return o ? { ...g, outcome: { result: o.result, actual: o.actual } } : g;
|
||
});
|
||
}
|
||
|
||
// Session 57 (Phase 0) — GET /api/snapshot/summary: the HeartbeatBar's honest
|
||
// data source. Cheap cache-only Redis reads; total graded props in the current
|
||
// snapshots + the latest pipeline run time. Registered BEFORE /:sport or
|
||
// Express captures "summary" as a sport.
|
||
const SUMMARY_SPORTS = ['nba', 'wnba', 'mlb', 'soccer'];
|
||
|
||
router.get('/summary', async (req, res) => {
|
||
try {
|
||
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,
|
||
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
|
||
// EXPECTED cadence, not a flat 5 minutes: normal < 1.5x, amber ≥ 1.5x,
|
||
// STALE red ≥ 3x. Default = the 5h max cron gap; when Phase 2.5's
|
||
// intraday refresh ships, drop the env value and the badge goes live
|
||
// with zero UI changes.
|
||
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, 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: {} });
|
||
}
|
||
});
|
||
|
||
router.get('/:sport', async (req, res) => {
|
||
const sport = String(req.params.sport || '').toLowerCase();
|
||
try {
|
||
const [snap, outcomeLog, rosterBlob] = await Promise.all([
|
||
cacheGet(`snapshot:${sport}:latest`),
|
||
cacheGet(`outcomes:${sport}:log`),
|
||
cacheGet(`rosterlogs:${sport}`),
|
||
]);
|
||
const idx = outcomeIndex(outcomeLog);
|
||
const roster = indexRosterLogs(rosterBlob);
|
||
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, 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}`);
|
||
const grades = env && Array.isArray(env.grades) ? env.grades : [];
|
||
res.set('Cache-Control', 'public, max-age=30');
|
||
return res.json({ sport, updated_at: env && env.updated_at, grades: enrich(grades), deltas: [] });
|
||
} catch (err) {
|
||
console.error('[snapshot]', err.message);
|
||
return res.status(200).json({ sport, grades: [], deltas: [] });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|