diff --git a/tests/unit/pricedLines.test.js b/tests/unit/pricedLines.test.js index 2e16a31..d32e750 100644 --- a/tests/unit/pricedLines.test.js +++ b/tests/unit/pricedLines.test.js @@ -82,3 +82,33 @@ describe('the empty state + surfacer are independently reversible', () => { expect(page).toMatch(/result\.book_odds == null \|\| result\.fair_odds == null/); }); }); + +describe('Session 80 — freshness + reversible gate (scan page wiring)', () => { + const fs = require('fs'); + const src = fs.readFileSync(require.resolve('../../web/src/app/scan/page.tsx'), 'utf8'); + + it('re-fetches on selection change when the held snapshot is stale', () => { + // The Session-79 gap: the fetch depended on [sport] only, so a long-open + // page showed hour-stale priced lines. Now a stale index re-fetches at use. + expect(src).toMatch(/Date\.now\(\) - pricedFetchedAt > PRICED_STALE_MS/); + expect(src).toMatch(/\}, \[selectedPlayer, stat\]\)/); + }); + + it('also refreshes on window focus for a long-open page', () => { + expect(src).toMatch(/addEventListener\('focus', onFocus\)/); + }); + + it('STALE_MS matches the /api/snapshot cache (no over-fetching)', () => { + expect(src).toMatch(/PRICED_STALE_MS = 30_000/); + }); + + it('the whole nudge is behind ONE reversible gate flag', () => { + expect(src).toMatch(/const PRICED_NUDGE_ENABLED = true/); + // the surfaced set is empty when the gate is off → S6 link-only fallback + expect(src).toMatch(/PRICED_NUDGE_ENABLED && pricedIndex && selectedPlayer/); + }); + + it('clears the index on sport change (never shows the old sport lines)', () => { + expect(src).toMatch(/setPricedIndex\(null\);\s*\n\s*setPricedFetchedAt\(0\);\s*\n\s*refreshPriced\(\);/); + }); +}); diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index 5f153e5..078c5c3 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -119,6 +119,13 @@ const SPORT_ACCENT: Record = { // Sportsbook deep-links — A1 S3: built by lib/bookLinks (organic until the // affiliate config flips a book on). rel is BOOK_LINK_REL on every anchor. +// Session 80 — PRICED-LINE NUDGE gate + freshness. The flag is the reversibility +// lever: false → the nudge disappears and the scanner falls back to S6's +// link-only empty state (Scan A / the working triplet are untouched either way). +// STALE_MS matches /api/snapshot's 30s cache — no point re-fetching more often. +const PRICED_NUDGE_ENABLED = true; +const PRICED_STALE_MS = 30_000; + export default function ScanPage() { const router = useRouter(); const { user, session, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth(); @@ -141,6 +148,9 @@ export default function ScanPage() { // Session 79 — the CURRENT snapshot's priced lines, indexed by player+stat, so // a marketless scan can surface REAL priced lines (never suggested/nearest). const [pricedIndex, setPricedIndex] = useState | null>(null); + // Session 80 — freshness clock: when the held snapshot was last fetched, so a + // long-open scanner re-fetches instead of surfacing hour-stale priced lines. + const [pricedFetchedAt, setPricedFetchedAt] = useState(0); // One-tap re-scan of a surfaced priced line: bump this to re-run runScan AFTER // line/direction state has committed. const [rescanKey, setRescanKey] = useState(0); @@ -159,7 +169,8 @@ export default function ScanPage() { // Session 79 — the REAL priced lines for the EXACT selected player+stat. Empty // for a player/stat the board didn't price — never suggested or interpolated. const pricedForSelection = useMemo( - () => (pricedIndex && selectedPlayer && stat ? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []), + () => (PRICED_NUDGE_ENABLED && pricedIndex && selectedPlayer && stat + ? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []), [pricedIndex, selectedPlayer, stat], ); @@ -192,22 +203,47 @@ export default function ScanPage() { }; }, [sport]); - // Session 79 — index the current snapshot's priced lines for the surfacer. - // /api/snapshot is public + cached 30s and is re-validated server-side at - // scan time, so a surfaced line that goes stale degrades to the honest empty - // state on tap rather than a vanishing triplet. - useEffect(() => { - let cancelled = false; - setPricedIndex(null); + // Session 79/80 — index the current snapshot's priced lines for the surfacer, + // kept FRESH. /api/snapshot is public + 30s-cached and re-validated + // server-side at scan time (so a stale chip that's tapped degrades to the + // honest empty state, never a vanishing triplet). The display is kept current + // by re-fetching when it's older than PRICED_STALE_MS at the moment of use — + // on selection change and on window focus — so a long-open page never shows + // an hour-stale priced line. + const refreshPriced = useCallback(() => { + if (!PRICED_NUDGE_ENABLED) return; fetch(`/api/snapshot/${sport.toLowerCase()}`) .then((r) => (r.ok ? r.json() : null)) .then((data: { grades?: unknown[] } | null) => { - if (!cancelled) setPricedIndex(indexPricedLines((data && data.grades) || [])); + setPricedIndex(indexPricedLines((data && data.grades) || [])); + setPricedFetchedAt(Date.now()); }) - .catch(() => !cancelled && setPricedIndex(new Map())); - return () => { cancelled = true; }; + .catch(() => { setPricedIndex(new Map()); setPricedFetchedAt(Date.now()); }); }, [sport]); + // Sport change: clear (never show the old sport's lines) then fetch fresh. + useEffect(() => { + setPricedIndex(null); + setPricedFetchedAt(0); + refreshPriced(); + }, [refreshPriced]); + + // Selection change: re-fetch only if the held snapshot has gone stale, so the + // chips shown for the new selection come from current data, not a mount copy. + useEffect(() => { + if (!selectedPlayer) return; + if (Date.now() - pricedFetchedAt > PRICED_STALE_MS) refreshPriced(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedPlayer, stat]); + + // Long-open page returning to focus: refresh if stale. + useEffect(() => { + const onFocus = () => { if (Date.now() - pricedFetchedAt > PRICED_STALE_MS) refreshPriced(); }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pricedFetchedAt]); + // Session 19 — fetch tonight's players from the odds proxy. The // odds endpoint returns the canonical list of players who have // props posted, which is exactly what the scan UI should surface