Complete S7 priced-line nudge: freshness on a long-open page + reversible gate
REPORT-FIRST correction. This order's premise — "S7 is a shell that doesn't update per selection" — is not what the code does. pricedForSelection is a useMemo on [pricedIndex, selectedPlayer, stat] and setSelectedPlayer/setStat fire on every user pick, so the chips already update per selection, and the prior session's verification of that stands. The genuine gap was FRESHNESS: the snapshot fetch depended on [sport] only, so pricedIndex was fetched once per sport-change and never refreshed. The pricing cron re-prices at five UTC hours, so a scanner left open across a cron boundary surfaced hour-stale priced lines. That is the real defect, and the only one fixed. FRESHNESS. The fetch is now a refreshPriced callback re-run when the held snapshot is older than PRICED_STALE_MS (30s, matching the /api/snapshot cache) at the moment of use — on selection change and on window focus — so a long-open page never shows a stale line. Sport change still clears the index first, so the old sport's lines never flash. STALE-TAP was already safe and is unchanged: the scan submit re-fetches the live snapshot server-side, so a chip that's gone stale between render and tap either lands on a real triplet (still priced) or degrades to the honest empty state (rotated away) — proven in the prior session and re-confirmed here (an off-snapshot line returns no market and shows the empty state). REVERSIBLE GATE. The whole nudge sits behind one PRICED_NUDGE_ENABLED flag: false empties the surfaced set, so the scanner falls back to S6's link-only empty state with the chips gone. Shipping enabled only after the cases are proven this session; the flag is the instant revert lever. DISPLAY-LAYER ONLY. Only scan/page.tsx changed. GradeResultCard, PriceTriplet, gradeAdapter, valueState, both scan routes and the pure pricedLines helper are byte-identical — Scan A and the scan-submit resolution are untouched, and the change is independently revertible. Tests 3765 passed / 303 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -82,3 +82,33 @@ describe('the empty state + surfacer are independently reversible', () => {
|
|||||||
expect(page).toMatch(/result\.book_odds == null \|\| result\.fair_odds == null/);
|
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\(\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+47
-11
@@ -119,6 +119,13 @@ const SPORT_ACCENT: Record<Sport, string> = {
|
|||||||
// Sportsbook deep-links — A1 S3: built by lib/bookLinks (organic until the
|
// 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.
|
// 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() {
|
export default function ScanPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, session, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth();
|
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
|
// Session 79 — the CURRENT snapshot's priced lines, indexed by player+stat, so
|
||||||
// a marketless scan can surface REAL priced lines (never suggested/nearest).
|
// a marketless scan can surface REAL priced lines (never suggested/nearest).
|
||||||
const [pricedIndex, setPricedIndex] = useState<Map<string, PricedLine[]> | null>(null);
|
const [pricedIndex, setPricedIndex] = useState<Map<string, PricedLine[]> | 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
|
// One-tap re-scan of a surfaced priced line: bump this to re-run runScan AFTER
|
||||||
// line/direction state has committed.
|
// line/direction state has committed.
|
||||||
const [rescanKey, setRescanKey] = useState(0);
|
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
|
// 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.
|
// for a player/stat the board didn't price — never suggested or interpolated.
|
||||||
const pricedForSelection = useMemo(
|
const pricedForSelection = useMemo(
|
||||||
() => (pricedIndex && selectedPlayer && stat ? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []),
|
() => (PRICED_NUDGE_ENABLED && pricedIndex && selectedPlayer && stat
|
||||||
|
? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []),
|
||||||
[pricedIndex, selectedPlayer, stat],
|
[pricedIndex, selectedPlayer, stat],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -192,22 +203,47 @@ export default function ScanPage() {
|
|||||||
};
|
};
|
||||||
}, [sport]);
|
}, [sport]);
|
||||||
|
|
||||||
// Session 79 — index the current snapshot's priced lines for the surfacer.
|
// Session 79/80 — index the current snapshot's priced lines for the surfacer,
|
||||||
// /api/snapshot is public + cached 30s and is re-validated server-side at
|
// kept FRESH. /api/snapshot is public + 30s-cached and re-validated
|
||||||
// scan time, so a surfaced line that goes stale degrades to the honest empty
|
// server-side at scan time (so a stale chip that's tapped degrades to the
|
||||||
// state on tap rather than a vanishing triplet.
|
// honest empty state, never a vanishing triplet). The display is kept current
|
||||||
useEffect(() => {
|
// by re-fetching when it's older than PRICED_STALE_MS at the moment of use —
|
||||||
let cancelled = false;
|
// on selection change and on window focus — so a long-open page never shows
|
||||||
setPricedIndex(null);
|
// an hour-stale priced line.
|
||||||
|
const refreshPriced = useCallback(() => {
|
||||||
|
if (!PRICED_NUDGE_ENABLED) return;
|
||||||
fetch(`/api/snapshot/${sport.toLowerCase()}`)
|
fetch(`/api/snapshot/${sport.toLowerCase()}`)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then((data: { grades?: unknown[] } | 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()));
|
.catch(() => { setPricedIndex(new Map()); setPricedFetchedAt(Date.now()); });
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [sport]);
|
}, [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
|
// Session 19 — fetch tonight's players from the odds proxy. The
|
||||||
// odds endpoint returns the canonical list of players who have
|
// odds endpoint returns the canonical list of players who have
|
||||||
// props posted, which is exactly what the scan UI should surface
|
// props posted, which is exactly what the scan UI should surface
|
||||||
|
|||||||
Reference in New Issue
Block a user