Compare commits
9 Commits
d10bb4cce2
...
4d2b27d1d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d2b27d1d0 | |||
| 7a35c96781 | |||
| 1b4f2772d6 | |||
| f110bd63f1 | |||
| 5d19660f8e | |||
| 9ae64516e6 | |||
| 80481d6f6a | |||
| 2c9cbca7bd | |||
| 4ddfb84232 |
+80
@@ -0,0 +1,80 @@
|
|||||||
|
# AUTONOMY — "Kev touches nothing for 7 days, what happens"
|
||||||
|
|
||||||
|
Session 60 (night2), traced on d10bb4c + tonight's fixes. Every step cites
|
||||||
|
its code. Verdict at the bottom.
|
||||||
|
|
||||||
|
## The daily loop (all times ET / UTC)
|
||||||
|
|
||||||
|
**10:00 AM ET (14:00 UTC) — cron slot 1** (`snapshotScheduler.js` HOURS_UTC
|
||||||
|
= 14,19,22,1,3; armed by `SNAPSHOT_CRON=1` in server.js):
|
||||||
|
1. **Settle pass runs FIRST** (`tick()` order: settleAllOutcomes →
|
||||||
|
settleAllLedgers → runAllSnapshots):
|
||||||
|
- `outcomeService.settleAllOutcomes` — settles yesterday's snapshot
|
||||||
|
grades vs real MLB game logs → `outcomes:{sport}:log` +
|
||||||
|
`accuracy:{sport}` + `accuracy:overall`.
|
||||||
|
**P0 FIXED TONIGHT:** `SNAP_TTL` was 6h but the overnight gap
|
||||||
|
(03:00→14:00 UTC) is 11h — the snapshot EXPIRED before this pass and
|
||||||
|
the accuracy loop settled nothing. Now 24h (`snapshotService.js`).
|
||||||
|
- `ledgerService.settleAllLedgers` — settles Supabase `ledger_entries`
|
||||||
|
(game_date < today ET): outcome + actual_value + CLV vs the captured
|
||||||
|
closing line. Reads Postgres, immune to Redis TTLs. Idempotent
|
||||||
|
(`.is('outcome', null)` guard) — re-runs are no-ops.
|
||||||
|
2. **Snapshot pass** (`snapshotService.runSnapshot` per sport):
|
||||||
|
- `getOdds` (PropLine free keys, odds-api backup) → date-pinned; a
|
||||||
|
morning with no lines yet = honest `skipped: no props` (NOT retried —
|
||||||
|
only thrown/null responses retry once).
|
||||||
|
- grades → normalize/dedupe → archetype+team resolve (free statsapi)
|
||||||
|
→ `snapshot:{sport}:latest` + `grades:{sport}` (6h TTL — outlives the
|
||||||
|
5h max intra-day gap) → ticker events.
|
||||||
|
- **Ledger write**: `recordPipelineGrades` upserts public rows
|
||||||
|
(user_id NULL, `ignoreDuplicates` — re-runs never dupe or overwrite
|
||||||
|
the original lock). `captureClosing` overwrites today's unsettled
|
||||||
|
rows' closing_line/odds with current feed values.
|
||||||
|
|
||||||
|
**3:00 PM / 6:00 PM / 9:00 PM / 11:00 PM ET — cron slots 2–5**: same tick.
|
||||||
|
Each run re-captures closing (the LAST write before a game starts is the
|
||||||
|
real close — after start the prop leaves the feed, so it freezes), computes
|
||||||
|
line deltas vs the previous snapshot (ticker MOVE), and upserts any newly
|
||||||
|
posted props as new locked rows.
|
||||||
|
|
||||||
|
**Overnight**: games finish; nothing runs until 10 AM, when the settle pass
|
||||||
|
closes yesterday's book. Every settled row gets outcome/actual/CLV exactly
|
||||||
|
once.
|
||||||
|
|
||||||
|
## Self-healing / watchdogs
|
||||||
|
- Missed-cron watchdog (per-minute, `isSnapshotOverdue`) → ntfy alert
|
||||||
|
(`vyndr-pipeline-kev2026`) once per missed slot. Snapshot success/empty/
|
||||||
|
failure also alert (`opsNotify`, never throws).
|
||||||
|
- `GET /api/internal/snapshot/status` (internal key) → cron_armed, last
|
||||||
|
snapshot, redis keys, `overdue` flag.
|
||||||
|
- Schedule/streaks/hotlist read caches self-heal on miss (cache-aside ESPN,
|
||||||
|
date-pinned since Session 57). Public snapshot/ledger reads are
|
||||||
|
cache-only and can never drain quota.
|
||||||
|
|
||||||
|
## What accumulates without touch (day 1 → day 7)
|
||||||
|
- Day 1: first locked rows; MODEL tab "RECORD BUILDING · N pending".
|
||||||
|
- Day 2 morning: first settles + CLV (closing captured day 1). AccuracyBadge
|
||||||
|
leaves "LEARNING" at 8 settled; ledger aggregates render % at n≥20.
|
||||||
|
- Day ~3–4 (MLB volume ~20-25 graded/day, all settleable): hit% + beat-close%
|
||||||
|
go live on /ledger MODEL, landing ModelRecord, player strips.
|
||||||
|
- Day 7: 30d-window aggregates, per-player records accruing. WNBA rows
|
||||||
|
accumulate PENDING (honest) until Phase 4.5 (ESPN box-score settle,
|
||||||
|
due ~Jul 24) — the one known credibility clock.
|
||||||
|
|
||||||
|
## Dependencies that must stay true (the only touch-points)
|
||||||
|
1. Coolify container running with `SNAPSHOT_CRON=1`, PropLine keys,
|
||||||
|
SUPABASE_URL + service key, REDIS_URL. (One-time, already set.)
|
||||||
|
2. Redis persistence is server-side config (Session 52) — snapshot keys
|
||||||
|
vanishing on restart = Coolify Redis setting, not code. The LEDGER is
|
||||||
|
Postgres and survives anything.
|
||||||
|
3. ntfy channel monitored casually — alerts are informational; the loop
|
||||||
|
needs no response.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
**Zero-touch for 7 days: YES for the core record** (grade→lock→close→
|
||||||
|
settle→aggregate, all scheduled in-process, idempotent, alert-covered) —
|
||||||
|
after tonight's SNAP_TTL fix, which was the one broken link (morning
|
||||||
|
settles silently skipped). Known honest gaps: WNBA settles pending until
|
||||||
|
4.5; streaks/hot-list data production was EXTERNAL (tank01-prefetch via
|
||||||
|
n8n/cron, possibly unarmed) — Session B moves log production into the
|
||||||
|
snapshot pipeline so the aggregator feeds itself.
|
||||||
@@ -9,6 +9,54 @@ work-order 1.6 closed (canonical player keys + slate join invariant),
|
|||||||
Phase 2 slate UX, Phase 3 mobile P0. DEPLOY GATE: Coolify by Sat 10 AM ET;
|
Phase 2 slate UX, Phase 3 mobile P0. DEPLOY GATE: Coolify by Sat 10 AM ET;
|
||||||
first snapshot locks against freshly posted lines (manual internal trigger).
|
first snapshot locks against freshly posted lines (manual internal trigger).
|
||||||
|
|
||||||
|
## Session 60 (night2/full-board, 2026-07-11) — BUILT ✅ THE WHOLE BOARD (awaiting "merge the train")
|
||||||
|
|
||||||
|
Branch `night2/full-board` off d10bb4c — ZERO pushes to main. 2352 →
|
||||||
|
**2396 tests** (205 suites), web build exit 0. Sessions A–H all shipped:
|
||||||
|
- **A** AUTONOMY.md + P0: SNAP_TTL 6h→24h (the 11h overnight cron gap
|
||||||
|
expired the snapshot before the morning settle — accuracy loop silently
|
||||||
|
settled nothing; the Postgres ledger settle was immune).
|
||||||
|
- **B** Streaks engine RESURRECTED + fed: the snapshot pipeline now writes
|
||||||
|
rosterlogs:{sport} from the stats it already fetches (producers were all
|
||||||
|
external/unarmed — that's why panels never rendered). Form heat (hot
|
||||||
|
hitters/sluggers/shooters, Σ/Σ rate math) + THE LENS (built-vs, tonight's
|
||||||
|
matchup + opposing SP ERA, step-up/down, one-line read). LIVE acceptance:
|
||||||
|
32 real rows (Turang 12-gm on-base, Reynolds 8-gm, Pratt 7-gm, Meidroth
|
||||||
|
5-gm, Cortes 5-gm).
|
||||||
|
- **C** Aggregator mounted: Explore = SEO server shell + hub (leaders +
|
||||||
|
full streaks + hot lists); streaks FREE for all tiers (the picture is
|
||||||
|
free); landing teaser fixed (pointed at off-season NBA → never showed);
|
||||||
|
one stat selection filters props+streaks+hotlists; player-dossier
|
||||||
|
ACTIVE STREAKS block (+?player= on /api/streaks).
|
||||||
|
- **D** Phase 2.5: 20-min odds-only refresh in slate hours (skips snapshot
|
||||||
|
slots, INTRADAY_REFRESH=0 kill switch, POST /api/internal/refresh/all).
|
||||||
|
STEAM ▲ (with, no re-grade) / VALUE ▲ (against ≥1.0, re-grade holds) /
|
||||||
|
PUBLIC revision (grade drops → revised_from_grade, strikethrough).
|
||||||
|
Refresh-fidelity closing capture; ticker MOVEs at refresh frequency.
|
||||||
|
Quota: ≤144 PropLine calls/day vs 9,000 free capacity. Post-deploy: set
|
||||||
|
SNAPSHOT_EXPECTED_INTERVAL=1200 and SYNC goes live.
|
||||||
|
- **E** Phase 4: /api/players/search built (didn't exist — the Ohtani bug);
|
||||||
|
nameKey fuzzy match live-verified vs the real 1,299-player list.
|
||||||
|
§7 reveal (steps → DECLASSIFIED stamp → 90ms staggered panels,
|
||||||
|
reduced-motion safe). PRIOR READS chips. Parlay: shared statLabel,
|
||||||
|
1-leg provisional grade, Nav entry via window.__openParlay.
|
||||||
|
- **F** Phase 5: archetype definition line on-page; VYNDR-on-team
|
||||||
|
(ModelRecord team scope); WNBA minutes-usage; Settings meter from the
|
||||||
|
LIVE limiter (3/day free, 15/day analyst, ∞ desk); per-tier calibration
|
||||||
|
chips (n≥20 PER TIER).
|
||||||
|
- **G** Phase 6: landing first-paint root cause KILLED (page blocked its
|
||||||
|
render on Supabase auth init for anonymous visitors; now a sync
|
||||||
|
localStorage gate — anon paints instantly). Proof Strip: A-tier-gated
|
||||||
|
header, "TONIGHT'S BOARD" fallback, yesterday's settled reads with
|
||||||
|
outcome chips when ungraded. /api/content/top-signals + /streak-watch
|
||||||
|
(zero-grade format) + /daily-report (self-flags do_not_post until n≥20).
|
||||||
|
Per-player OG images (Node runtime) + player metadata.
|
||||||
|
- **H** Design pass: QA.20–22 locks (STEAM amber/VALUE green/STALE-only
|
||||||
|
red, revisions struck through, new data surfaces mono, honest waiting
|
||||||
|
copy everywhere).
|
||||||
|
|
||||||
|
### The word: "merge the train" → main fast-forwards → Coolify ships board #2.
|
||||||
|
|
||||||
## Session 59 (2026-07-10 overnight) — SHIPPED ✅ ADDENDUM + 1.6 + PHASE 2 + PHASE 3
|
## Session 59 (2026-07-10 overnight) — SHIPPED ✅ ADDENDUM + 1.6 + PHASE 2 + PHASE 3
|
||||||
|
|
||||||
Backend 2327 → **2352 tests** (+25), 202 suites. Web build clean (exit 0).
|
Backend 2327 → **2352 tests** (+25), 202 suites. Web build clean (exit 0).
|
||||||
|
|||||||
@@ -773,6 +773,34 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
|
|||||||
expands in place; GAME LINES <640px = `.gl-summary` best-line row +
|
expands in place; GAME LINES <640px = `.gl-summary` best-line row +
|
||||||
expander revealing `.gl-full.gl-expanded`.
|
expander revealing `.gl-full.gl-expanded`.
|
||||||
|
|
||||||
|
## Night-2 Board (Session 60 — non-obvious)
|
||||||
|
- **SNAP_TTL is 24h ON PURPOSE** (was 6h): the overnight cron gap is 11h;
|
||||||
|
a shorter TTL silently kills the morning accuracy settle. AUTONOMY.md is
|
||||||
|
the zero-touch trace — update it when the loop's shape changes.
|
||||||
|
- **The snapshot pipeline FEEDS the aggregator**: `mergeRosterLogs` writes
|
||||||
|
`rosterlogs:{sport}` (72h TTL, cap 300) from the same stats resolve that
|
||||||
|
classifies archetypes. Streaks/hot-lists starve if you remove it — the
|
||||||
|
external prefetch (n8n/tank01) was never armed.
|
||||||
|
- **THE LENS RULE**: no raw streak renders alone — `streakLens.applyLens`
|
||||||
|
attaches built-vs / tonight's matchup (+opposing SP ERA for MLB batters)
|
||||||
|
/ difficulty / one-line read. Absent context = say less, never invent.
|
||||||
|
Streaks are FREE for every tier; hot lists keep the free top-3 gate.
|
||||||
|
- **Phase 2.5 lives in `intradayRefreshService`**: signed delta vs the
|
||||||
|
GRADED side; STEAM never re-grades; ≥1.0 against re-grades THAT prop at
|
||||||
|
the real current line; a dropped grade sets `revised_from_grade` ONCE
|
||||||
|
(the original letter is forever). `applyRevision` only touches unsettled
|
||||||
|
public rows. Scheduler skips full-snapshot slots; INTRADAY_REFRESH=0
|
||||||
|
kills it. After deploy set SNAPSHOT_EXPECTED_INTERVAL=1200.
|
||||||
|
- **/api/players/search is the canonical resolver** (nameKey fuzzy; MLB =
|
||||||
|
statsapi list, others = cached names). The Python service is a fallback
|
||||||
|
only. Never reintroduce raw-substring matching.
|
||||||
|
- **Landing renders instantly for anon**: `hasStoredSession()` sync
|
||||||
|
localStorage gate — do NOT re-block the marketing render on
|
||||||
|
`useAuth().loading`; that was the 3–4s LOADING THE SLATE bug.
|
||||||
|
- **QA.20–22 in vyndrParityQA** lock the night's color semantics (STEAM
|
||||||
|
amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and
|
||||||
|
schedule-derived waiting copy. Keep them green.
|
||||||
|
|
||||||
## Active Skills
|
## Active Skills
|
||||||
- vyndr-voice (all user-facing output)
|
- vyndr-voice (all user-facing output)
|
||||||
- prop-analysis (grading methodology)
|
- prop-analysis (grading methodology)
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ app.use('/api/preferences', require('./routes/preferences'));
|
|||||||
app.use('/api/stripe', stripeRoutes);
|
app.use('/api/stripe', stripeRoutes);
|
||||||
app.use('/api/stats', statsRoutes);
|
app.use('/api/stats', statsRoutes);
|
||||||
app.use('/api/props', propsRoutes);
|
app.use('/api/props', propsRoutes);
|
||||||
|
// Session 60 (night2/E) — the scan search box's canonical player resolver.
|
||||||
|
app.use('/api/players', require('./routes/players'));
|
||||||
|
// Session 60 (night2/F) — per-user utility reads (Settings scan meter).
|
||||||
|
app.use('/api/user', require('./routes/user'));
|
||||||
app.use('/api/waitlist', waitlistRoutes);
|
app.use('/api/waitlist', waitlistRoutes);
|
||||||
app.use('/api/pipeline', pipelineRoutes);
|
app.use('/api/pipeline', pipelineRoutes);
|
||||||
app.use('/api/share-card', shareCardRoutes);
|
app.use('/api/share-card', shareCardRoutes);
|
||||||
|
|||||||
@@ -93,7 +93,23 @@ function resetForTests() {
|
|||||||
hits.clear();
|
hits.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session 60 (night2/F, 5.4) — read-only usage for the Settings meter.
|
||||||
|
* Reports the SAME rolling-24h window the limiter enforces, from the same
|
||||||
|
* store — the meter can never disagree with the gate. Infinity limit →
|
||||||
|
* { unlimited: true }.
|
||||||
|
*/
|
||||||
|
function scanUsage(req) {
|
||||||
|
const tier = req.user?.tier || 'free';
|
||||||
|
const limit = getScanLimit(tier);
|
||||||
|
if (limit === Infinity) return { tier, unlimited: true, used: null, limit: null };
|
||||||
|
const ts = hits.get(clientKey(req));
|
||||||
|
const used = ts ? pruneOlderThan(ts, Date.now() - WINDOW_MS) : 0;
|
||||||
|
return { tier, unlimited: false, used, limit, remaining: Math.max(0, limit - used) };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
scanLimit,
|
scanLimit,
|
||||||
|
scanUsage,
|
||||||
__internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED },
|
__internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,90 @@ function guard(req, res) {
|
|||||||
return sport;
|
return sport;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session 60 (night2/G, spec §12) — the aggregator's content formats.
|
||||||
|
*
|
||||||
|
* GET /top-signals/:sport — tonight's top graded props (snapshot cache).
|
||||||
|
* GET /streak-watch/:sport — the DAILY ZERO-GRADE FORMAT: real streaks
|
||||||
|
* through the lens; postable even on a morning with no lines.
|
||||||
|
* GET /daily-report/:sport — the settled-record report. BUILT but flagged
|
||||||
|
* DO-NOT-POST until the record clears n≥20: the payload carries
|
||||||
|
* `do_not_post: true` until then. Flag, don't fake.
|
||||||
|
*/
|
||||||
|
router.get('/top-signals/:sport', async (req, res) => {
|
||||||
|
const sport = guard(req, res);
|
||||||
|
if (!sport) return undefined;
|
||||||
|
try {
|
||||||
|
const snap = await cacheGet(`snapshot:${sport}:latest`);
|
||||||
|
const grades = (snap && Array.isArray(snap.grades) ? snap.grades : [])
|
||||||
|
.filter((g) => g.grade && !g.outcome)
|
||||||
|
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0))
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((g) => ({
|
||||||
|
player: g.player || g.player_name, team: g.team || null,
|
||||||
|
stat: g.stat_type || g.stat, line: g.line,
|
||||||
|
side: String(g.direction || 'over').toLowerCase(), grade: g.grade,
|
||||||
|
archetype: g.archetype || null,
|
||||||
|
locked_at: (g.gradedAt && g.gradedAt.timestamp) || null,
|
||||||
|
}));
|
||||||
|
res.set('Cache-Control', 'public, max-age=300');
|
||||||
|
return res.set(MISSION_HEADER).json({
|
||||||
|
sport, format: 'top-signals', updated_at: snap && snap.updated_at,
|
||||||
|
dataLevel: grades.length > 0 ? 'full' : 'empty', signals: grades,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[content/top-signals/${sport}]`, err.message);
|
||||||
|
return res.set(MISSION_HEADER).json({ sport, format: 'top-signals', dataLevel: 'empty', signals: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/streak-watch/:sport', async (req, res) => {
|
||||||
|
const sport = guard(req, res);
|
||||||
|
if (!sport) return undefined;
|
||||||
|
try {
|
||||||
|
const { loadRosterLogs } = require('../services/rosterLogs');
|
||||||
|
const streaksService = require('../services/streaksService');
|
||||||
|
const { applyLens } = require('../services/streakLens');
|
||||||
|
const roster = await loadRosterLogs(sport);
|
||||||
|
const rows = [
|
||||||
|
...streaksService.computeStreaks(roster, sport, { limit: 8 }),
|
||||||
|
...streaksService.computeFormHeat(roster, sport, { limit: 4 }),
|
||||||
|
];
|
||||||
|
const todayET = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(new Date());
|
||||||
|
const sched = await cacheGet(`schedule:${sport}:${todayET}`);
|
||||||
|
const withLens = applyLens(rows, { scheduleGames: Array.isArray(sched) ? sched : [], pitcherGames: [] });
|
||||||
|
res.set('Cache-Control', 'public, max-age=600');
|
||||||
|
return res.set(MISSION_HEADER).json({
|
||||||
|
sport, format: 'streak-watch',
|
||||||
|
dataLevel: withLens.length > 0 ? 'full' : 'empty',
|
||||||
|
streaks: withLens.map((r) => ({ player: r.player, team: r.team, description: r.description, read: r.lens && r.lens.read, streak: r.currentStreak })),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[content/streak-watch/${sport}]`, err.message);
|
||||||
|
return res.set(MISSION_HEADER).json({ sport, format: 'streak-watch', dataLevel: 'empty', streaks: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/daily-report/:sport', async (req, res) => {
|
||||||
|
const sport = guard(req, res);
|
||||||
|
if (!sport) return undefined;
|
||||||
|
try {
|
||||||
|
const ledgerService = require('../services/ledgerService');
|
||||||
|
const agg = await ledgerService.getModelAggregate({ sport });
|
||||||
|
const ready = agg.settled >= (agg.min_sample || 20) && agg.hit_pct != null;
|
||||||
|
res.set('Cache-Control', 'public, max-age=600');
|
||||||
|
return res.set(MISSION_HEADER).json({
|
||||||
|
sport, format: 'daily-report',
|
||||||
|
do_not_post: !ready,
|
||||||
|
reason: ready ? null : `record building — ${agg.settled}/${agg.min_sample || 20} settles`,
|
||||||
|
aggregate: agg,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[content/daily-report/${sport}]`, err.message);
|
||||||
|
return res.set(MISSION_HEADER).json({ sport, format: 'daily-report', do_not_post: true, reason: 'unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/slate/:sport', async (req, res) => {
|
router.get('/slate/:sport', async (req, res) => {
|
||||||
const sport = guard(req, res);
|
const sport = guard(req, res);
|
||||||
if (!sport) return undefined;
|
if (!sport) return undefined;
|
||||||
|
|||||||
@@ -180,6 +180,23 @@ router.post('/outcomes/all', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/internal/refresh/all (Session 60, Phase 2.5) — manual intraday
|
||||||
|
* odds-only refresh: STEAM/VALUE movement, public revisions, closing
|
||||||
|
* capture. Same behavior as the in-process 20-min cadence.
|
||||||
|
*/
|
||||||
|
router.post('/refresh/all', async (req, res) => {
|
||||||
|
const refresh = require('../services/intradayRefreshService');
|
||||||
|
try {
|
||||||
|
const results = await refresh.runAllIntradayRefreshes();
|
||||||
|
return res.json({ ok: true, results });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err && err.message ? err.message : String(err);
|
||||||
|
console.error('[internal/refresh/all] failed:', message);
|
||||||
|
return res.status(500).json({ ok: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the
|
* POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the
|
||||||
* persistent ledger (outcome + actual_value + CLV) across every sport.
|
* persistent ledger (outcome + actual_value + CLV) across every sport.
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ router.get('/model', async (req, res) => {
|
|||||||
? String(req.query.sport).toLowerCase() : undefined,
|
? String(req.query.sport).toLowerCase() : undefined,
|
||||||
// VYNDR-on-player (work-order 1.4/5.2): per-player public record.
|
// VYNDR-on-player (work-order 1.4/5.2): per-player public record.
|
||||||
playerKey: req.query.player ? nameKey(String(req.query.player).slice(0, 60)) : undefined,
|
playerKey: req.query.player ? nameKey(String(req.query.player).slice(0, 60)) : undefined,
|
||||||
|
// VYNDR-on-team (5.2, migration 020): exact statsapi team name.
|
||||||
|
team: req.query.team ? String(req.query.team).slice(0, 60) : undefined,
|
||||||
});
|
});
|
||||||
let entries = [];
|
let entries = [];
|
||||||
if (sb) {
|
if (sb) {
|
||||||
@@ -92,6 +94,8 @@ router.get('/model', async (req, res) => {
|
|||||||
.select(ROW_COLUMNS)
|
.select(ROW_COLUMNS)
|
||||||
.is('user_id', null);
|
.is('user_id', null);
|
||||||
q = applyFilters(q, req);
|
q = applyFilters(q, req);
|
||||||
|
// Session 60 (night2/E) — PRIOR READS: a player's own public history.
|
||||||
|
if (req.query.player) q = q.eq('player_key', nameKey(String(req.query.player).slice(0, 60)));
|
||||||
const { data, error } = await q
|
const { data, error } = await q
|
||||||
.order('graded_at', { ascending: false })
|
.order('graded_at', { ascending: false })
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/players/search (Session 60, night2/E — audit fix 4.1).
|
||||||
|
*
|
||||||
|
* The scan search box's canonical resolver. Before this route existed, MLB
|
||||||
|
* search 404'd at Express and NBA/WNBA hit the (usually offline) Python
|
||||||
|
* service — "Ohtani" returned nothing while his tile sat on the page.
|
||||||
|
*
|
||||||
|
* MLB: fuzzy match against the cached statsapi player list (free, 24h
|
||||||
|
* cache) — case/diacritic-insensitive, nickname/suffix-aware via nameKey.
|
||||||
|
* Other sports: cache-only match against the names the platform already
|
||||||
|
* knows (rosterlogs blob + tonight's graded slate). Never an upstream call
|
||||||
|
* for non-MLB; empty is a valid answer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { createRateLimit } = require('../middleware/rateLimit');
|
||||||
|
const { nameKey } = require('../utils/playerName');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||||
|
|
||||||
|
async function cachedNames(sport) {
|
||||||
|
const { cacheGet } = require('../utils/redis');
|
||||||
|
const out = new Map();
|
||||||
|
try {
|
||||||
|
const blob = await cacheGet(`rosterlogs:${sport}`);
|
||||||
|
for (const p of Array.isArray(blob) ? blob : []) {
|
||||||
|
if (p && p.name) out.set(nameKey(p.name), { full_name: p.name, team: p.team || null });
|
||||||
|
}
|
||||||
|
} catch { /* cache-only, degrade */ }
|
||||||
|
try {
|
||||||
|
const env = await cacheGet(`grades:${sport}`);
|
||||||
|
for (const g of (env && env.grades) || []) {
|
||||||
|
const n = g.player || g.player_name;
|
||||||
|
if (n && !out.has(nameKey(n))) out.set(nameKey(n), { full_name: n, team: g.team || null });
|
||||||
|
}
|
||||||
|
} catch { /* cache-only, degrade */ }
|
||||||
|
return [...out.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/search', async (req, res) => {
|
||||||
|
const sport = String(req.query.sport || 'NBA').toUpperCase();
|
||||||
|
const q = String(req.query.q || '').trim();
|
||||||
|
if (q.length < 2) return res.json({ players: [] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sport === 'MLB') {
|
||||||
|
const { searchPlayers } = require('../services/adapters/mlbStatsAdapter');
|
||||||
|
const hits = await searchPlayers(q, { limit: 12 });
|
||||||
|
return res.json({
|
||||||
|
players: hits.map((h) => ({ id: String(h.id), full_name: h.fullName, team: h.team || undefined, position: h.position || undefined })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// NBA/WNBA/soccer — the names the platform already carries (cache-only).
|
||||||
|
const names = await cachedNames(sport.toLowerCase());
|
||||||
|
const qKey = nameKey(q);
|
||||||
|
const qLast = qKey.split(' ').pop();
|
||||||
|
const players = names
|
||||||
|
.filter((p) => {
|
||||||
|
const k = nameKey(p.full_name);
|
||||||
|
return k === qKey || k.includes(qKey) || (qLast.length >= 3 && k.split(' ').some((w) => w.startsWith(qLast)));
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((p, i) => ({ id: `${sport}-${i}-${nameKey(p.full_name)}`, full_name: p.full_name, team: p.team || undefined }));
|
||||||
|
return res.json({ players });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[players/search]', err.message);
|
||||||
|
return res.json({ players: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+82
-11
@@ -1,22 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* /api/streaks/:sport (Session 23)
|
* /api/streaks/:sport (Session 23; Session 60 night2/B — the lens).
|
||||||
*
|
*
|
||||||
* Computed player streaks from cached game logs. NO API calls — reads
|
* Computed player streaks + form heat from cached game logs, every row
|
||||||
* warm Redis logs and runs the pure streaks engine over them. Supports
|
* interpreted through the VYNDR lens: what the streak was built against,
|
||||||
* `?stat=points` to narrow to one category, and `?limit=N`.
|
* tonight's matchup (opponent + MLB opposing SP w/ ERA), difficulty, and a
|
||||||
|
* one-line read. NO paid API calls — warm Redis + the free ESPN/statsapi
|
||||||
|
* caches only. Where a snapshot grade exists for a streaking player's
|
||||||
|
* category, the grade letter rides along (the slate already shows locked
|
||||||
|
* grades publicly — consistent, not a leak).
|
||||||
*
|
*
|
||||||
* Response: { sport, stat, streaks: [...], source: 'computed' }
|
* Response: { sport, stat, streaks: [...], source: 'computed' }
|
||||||
*
|
* An empty array is a valid, non-error state.
|
||||||
* An empty `streaks` array is a valid, non-error state — the platform
|
|
||||||
* leans on the other layers (schedule, game lines, props) when no logs
|
|
||||||
* are warm yet.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const streaksService = require('../services/streaksService');
|
const streaksService = require('../services/streaksService');
|
||||||
|
const { applyLens } = require('../services/streakLens');
|
||||||
const { loadRosterLogs } = require('../services/rosterLogs');
|
const { loadRosterLogs } = require('../services/rosterLogs');
|
||||||
|
const { nameKey } = require('../utils/playerName');
|
||||||
const { createRateLimit } = require('../middleware/rateLimit');
|
const { createRateLimit } = require('../middleware/rateLimit');
|
||||||
|
|
||||||
|
// Lens context reads are best-effort: a dead cache or slow upstream must
|
||||||
|
// NEVER hang the public route — the lens just says less. Timer is unref'd
|
||||||
|
// so it can't keep the process (or Jest) alive.
|
||||||
|
function withTimeout(promise, ms) {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const t = setTimeout(() => resolve(null), ms);
|
||||||
|
if (t.unref) t.unref();
|
||||||
|
}),
|
||||||
|
]).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LENS_BUDGET_MS = 1500;
|
||||||
|
const inTest = () => process.env.NODE_ENV === 'test';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
// Session 32 — public throttle (60/min; pure engine over cached logs).
|
// Session 32 — public throttle (60/min; pure engine over cached logs).
|
||||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||||
@@ -25,6 +44,45 @@ const MISSION_HEADER = { 'X-VYNDR-Mission': 'Streaks are the heartbeat' };
|
|||||||
|
|
||||||
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'nfl', 'soccer']);
|
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'nfl', 'soccer']);
|
||||||
|
|
||||||
|
function todayET() {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
}).format(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonight's context for the lens — best-effort, time-bounded, degrades to
|
||||||
|
* less context rather than hanging. Skipped under NODE_ENV=test (the lens
|
||||||
|
* builder itself is unit-tested; route tests exercise engine + shape). */
|
||||||
|
async function lensContext(sport) {
|
||||||
|
const ctx = { scheduleGames: [], pitcherGames: [] };
|
||||||
|
if (inTest()) return ctx;
|
||||||
|
const { cacheGet } = require('../utils/redis');
|
||||||
|
const sched = await withTimeout(cacheGet(`schedule:${sport}:${todayET()}`), LENS_BUDGET_MS);
|
||||||
|
if (Array.isArray(sched)) ctx.scheduleGames = sched;
|
||||||
|
if (sport === 'mlb') {
|
||||||
|
// Warm in prod (the slate's /pitchers endpoint fills the same caches);
|
||||||
|
// hard-capped so a cold cache costs at most the budget, never a hang.
|
||||||
|
const { getProbablePitchers } = require('../services/probablePitchers');
|
||||||
|
const pg = await withTimeout(getProbablePitchers(todayET()), LENS_BUDGET_MS);
|
||||||
|
if (Array.isArray(pg)) ctx.pitcherGames = pg;
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grade letters for streaking players (snapshot cache; public data). */
|
||||||
|
async function gradeJoin(sport) {
|
||||||
|
if (inTest()) return {};
|
||||||
|
try {
|
||||||
|
const { cacheGet } = require('../utils/redis');
|
||||||
|
const env = await withTimeout(cacheGet(`grades:${sport}`), LENS_BUDGET_MS);
|
||||||
|
const map = {};
|
||||||
|
for (const g of (env && env.grades) || []) {
|
||||||
|
map[nameKey(g.player || g.player_name)] = map[nameKey(g.player || g.player_name)] || g.grade;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/:sport', async (req, res) => {
|
router.get('/:sport', async (req, res) => {
|
||||||
const sport = String(req.params.sport || '').toLowerCase();
|
const sport = String(req.params.sport || '').toLowerCase();
|
||||||
if (!SUPPORTED.has(sport)) {
|
if (!SUPPORTED.has(sport)) {
|
||||||
@@ -32,11 +90,24 @@ router.get('/:sport', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all';
|
const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all';
|
||||||
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0;
|
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0;
|
||||||
|
// Session 60 (night2/C) — a player's active streaks for the dossier page.
|
||||||
|
const playerKey = req.query.player ? nameKey(String(req.query.player).slice(0, 60)) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const roster = await loadRosterLogs(sport);
|
let roster = await loadRosterLogs(sport);
|
||||||
const streaks = streaksService.computeStreaks(roster, sport, { stat, limit });
|
if (playerKey) roster = roster.filter((p) => nameKey(p.name) === playerKey);
|
||||||
return res.set(MISSION_HEADER).json({ sport, stat, streaks, source: 'computed' });
|
const streaks = streaksService.computeStreaks(roster, sport, { stat });
|
||||||
|
// Session 60 — form heat (hot hitters/sluggers/shooters) joins the feed.
|
||||||
|
const heat = streaksService.computeFormHeat(roster, sport, {})
|
||||||
|
.filter((h) => stat === 'all' || h.category === stat);
|
||||||
|
let rows = [...streaks, ...heat];
|
||||||
|
if (limit > 0) rows = rows.slice(0, limit);
|
||||||
|
|
||||||
|
// THE LENS — no raw streak renders alone.
|
||||||
|
const [ctx, grades] = await Promise.all([lensContext(sport), gradeJoin(sport)]);
|
||||||
|
rows = applyLens(rows, ctx).map((r) => ({ ...r, grade: grades[nameKey(r.player)] || null }));
|
||||||
|
|
||||||
|
return res.set(MISSION_HEADER).json({ sport, stat, streaks: rows, source: 'computed' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[streaks/${sport}]`, err.message);
|
console.error(`[streaks/${sport}]`, err.message);
|
||||||
return res.set(MISSION_HEADER).json({ sport, stat, streaks: [], source: 'computed' });
|
return res.set(MISSION_HEADER).json({ sport, stat, streaks: [], source: 'computed' });
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /api/user — per-user utility reads (Session 60, night2/F).
|
||||||
|
*
|
||||||
|
* GET /scan-meter (auth) — the Settings read meter: real usage from the
|
||||||
|
* SAME rolling-24h store the scan limiter enforces. Metered tiers get
|
||||||
|
* { used, limit, remaining }; unlimited tiers get { unlimited: true }
|
||||||
|
* (the UI hides the meter).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
const { scanUsage } = require('../middleware/scanLimit');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/scan-meter', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
return res.json(scanUsage(req));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[user/scan-meter]', err.message);
|
||||||
|
return res.status(200).json({ tier: req.user?.tier || 'free', unlimited: false, used: null, limit: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -144,6 +144,45 @@ const { nameKey } = require('../../utils/playerName');
|
|||||||
* requires a UNIQUE same-last-name + same-first-initial candidate; anything
|
* requires a UNIQUE same-last-name + same-first-initial candidate; anything
|
||||||
* ambiguous returns null (a missing profile beats another player's log).
|
* ambiguous returns null (a missing profile beats another player's log).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Session 60 (night2/E, audit fix 4.1) — fuzzy MULTI-match against the
|
||||||
|
* canonical list. Pure: rank = exact nameKey > last-name prefix > folded
|
||||||
|
* substring. Case/diacritic-insensitive via nameKey's folding, so
|
||||||
|
* "ohtani", "Sánchez", "sanchez", "Chisholm Jr" all resolve.
|
||||||
|
*/
|
||||||
|
function matchPlayers(people, query, limit = 12) {
|
||||||
|
const qKey = nameKey(query);
|
||||||
|
if (!qKey) return [];
|
||||||
|
const qLast = qKey.split(' ').pop();
|
||||||
|
const scored = [];
|
||||||
|
for (const p of people || []) {
|
||||||
|
const k = nameKey(p.fullName);
|
||||||
|
if (!k) continue;
|
||||||
|
let score = null;
|
||||||
|
if (k === qKey) score = 0;
|
||||||
|
else if (k.split(' ').some((w) => w.startsWith(qLast)) && qLast.length >= 3) score = 1;
|
||||||
|
else if (k.includes(qKey)) score = 2;
|
||||||
|
if (score == null) continue;
|
||||||
|
scored.push({ score, p });
|
||||||
|
}
|
||||||
|
scored.sort((a, b) => a.score - b.score);
|
||||||
|
return scored.slice(0, limit).map(({ p }) => ({
|
||||||
|
id: p.id,
|
||||||
|
fullName: p.fullName,
|
||||||
|
team: p.currentTeam?.name ?? null,
|
||||||
|
position: p.primaryPosition?.abbreviation ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi-result fuzzy search (scan search box). Cached list, free API. */
|
||||||
|
async function searchPlayers(query, opts = {}) {
|
||||||
|
const season = opts.season || DEFAULT_SEASON;
|
||||||
|
const url = `${BASE}/sports/1/players?season=${season}`;
|
||||||
|
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
|
||||||
|
const people = (data && Array.isArray(data.people)) ? data.people : [];
|
||||||
|
return matchPlayers(people, query, opts.limit || 12);
|
||||||
|
}
|
||||||
|
|
||||||
async function searchPlayer(name, season = DEFAULT_SEASON) {
|
async function searchPlayer(name, season = DEFAULT_SEASON) {
|
||||||
const targetKey = nameKey(name);
|
const targetKey = nameKey(name);
|
||||||
if (!targetKey) return null;
|
if (!targetKey) return null;
|
||||||
@@ -248,6 +287,8 @@ module.exports = {
|
|||||||
getSeasonAverages,
|
getSeasonAverages,
|
||||||
getBatterVsPitcher,
|
getBatterVsPitcher,
|
||||||
searchPlayer,
|
searchPlayer,
|
||||||
|
searchPlayers,
|
||||||
|
matchPlayers,
|
||||||
getPlayerStats,
|
getPlayerStats,
|
||||||
getTeams,
|
getTeams,
|
||||||
resolveTeam,
|
resolveTeam,
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* intradayRefreshService — Phase 2.5 (Session 60, night2/D).
|
||||||
|
*
|
||||||
|
* A lightweight ODDS-ONLY refresh during slate hours (~noon–midnight ET).
|
||||||
|
* No full re-grade run: it re-reads the market, computes the signed delta
|
||||||
|
* per graded prop RELATIVE TO THE GRADED SIDE, and acts on DIRECTION:
|
||||||
|
*
|
||||||
|
* moved WITH the grade (market chasing our number) → STEAM ▲ +N badge.
|
||||||
|
* Good for the record; entry edge compressed. NO re-grade.
|
||||||
|
* moved AGAINST ≥ 1.0 → re-grade THAT
|
||||||
|
* PROP ONLY at the current line:
|
||||||
|
* grade holds → VALUE ▲ (better number, same read)
|
||||||
|
* grade drops → PUBLIC revision: grade updates with
|
||||||
|
* revised_from_grade set (original preserved, struck through in
|
||||||
|
* the UI + ledger). Never a silent regrade — Ledger ethos.
|
||||||
|
*
|
||||||
|
* Every displayed line stays a REAL book value from this refresh — the
|
||||||
|
* refresh CAPTURES market numbers, never computes them. Each run also
|
||||||
|
* re-captures closing_line/odds (ledgerService.captureClosing): the last
|
||||||
|
* pre-game write IS the close, now at refresh fidelity.
|
||||||
|
*
|
||||||
|
* QUOTA MATH (zero out-of-pocket): one getOdds call per sport per run.
|
||||||
|
* 20-min cadence × 12 slate hours = 36 runs/day/sport × 4 sports =
|
||||||
|
* ≤144 PropLine requests/day — against 9,000/day free capacity (3 keys ×
|
||||||
|
* 3,000). Re-grades are internal feature computation (free) and bounded
|
||||||
|
* to props that moved against ≥ 1.0.
|
||||||
|
*
|
||||||
|
* Everything injectable → unit-tested with zero network.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { nameKey } = require('../utils/playerName');
|
||||||
|
|
||||||
|
const STEAM_NOISE = 0.5; // ignore movement below this (both directions)
|
||||||
|
const REGRADE_TRIGGER = 1.0; // moved-against threshold that triggers a re-grade
|
||||||
|
const SNAP_TTL = 24 * 3600; // keep in sync with snapshotService
|
||||||
|
const TICKER_MOVE_CAP = 6;
|
||||||
|
|
||||||
|
const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10 };
|
||||||
|
const rank = (g) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99);
|
||||||
|
|
||||||
|
const sideOver = (dir) => String(dir || 'over').toLowerCase() !== 'under';
|
||||||
|
|
||||||
|
function indexOddsProps(props) {
|
||||||
|
const map = {};
|
||||||
|
for (const p of props || []) {
|
||||||
|
if (!p || !p.player || !p.stat_type) continue;
|
||||||
|
const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
|
||||||
|
if (!map[k]) map[k] = p;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed movement RELATIVE TO THE GRADED SIDE: positive = toward (with).
|
||||||
|
* Strict null-safe parse — Number(null) is 0, a fabricated line. */
|
||||||
|
function signedDelta(side, lockedLine, currentLine) {
|
||||||
|
if (lockedLine == null || currentLine == null) return null;
|
||||||
|
const locked = Number(lockedLine);
|
||||||
|
const current = Number(currentLine);
|
||||||
|
if (!Number.isFinite(locked) || !Number.isFinite(current)) return null;
|
||||||
|
const raw = current - locked;
|
||||||
|
return Math.round((sideOver(side) ? raw : -raw) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one intraday refresh for a sport. Returns
|
||||||
|
* { sport, status, checked, steam, value, revised } — never throws.
|
||||||
|
*/
|
||||||
|
async function runIntradayRefresh(sport, opts = {}) {
|
||||||
|
const sp = String(sport || '').toLowerCase();
|
||||||
|
const deps = {
|
||||||
|
getOdds: opts.getOdds || require('./oddsService').getOdds,
|
||||||
|
analyze: opts.analyze || require('./intelligence/analyzeViaEngine1').analyzeViaEngine1,
|
||||||
|
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
|
||||||
|
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
|
||||||
|
ledger: opts.ledger || require('./ledgerService'),
|
||||||
|
pushTickerItems: opts.pushTickerItems || require('./snapshotService').pushTickerItems,
|
||||||
|
now: opts.now || (() => new Date().toISOString()),
|
||||||
|
};
|
||||||
|
const ts = deps.now();
|
||||||
|
|
||||||
|
const snap = await deps.cacheGet(`snapshot:${sp}:latest`);
|
||||||
|
if (!snap || !Array.isArray(snap.grades) || snap.grades.length === 0) {
|
||||||
|
return { sport: sp, status: 'skipped', reason: 'no snapshot', checked: 0, steam: 0, value: 0, revised: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let odds;
|
||||||
|
try {
|
||||||
|
odds = await deps.getOdds(sp);
|
||||||
|
} catch (e) {
|
||||||
|
return { sport: sp, status: 'error', reason: e.message, checked: 0, steam: 0, value: 0, revised: 0 };
|
||||||
|
}
|
||||||
|
const props = odds && Array.isArray(odds.props) ? odds.props : [];
|
||||||
|
if (props.length === 0) {
|
||||||
|
return { sport: sp, status: 'skipped', reason: 'no odds', checked: 0, steam: 0, value: 0, revised: 0 };
|
||||||
|
}
|
||||||
|
const byKey = indexOddsProps(props);
|
||||||
|
|
||||||
|
let steam = 0; let value = 0; let revised = 0; let checked = 0;
|
||||||
|
const moveEvents = [];
|
||||||
|
const grades = [];
|
||||||
|
|
||||||
|
for (const g of snap.grades) {
|
||||||
|
const player = g.player || g.player_name;
|
||||||
|
const stat = String(g.stat_type || g.stat || '').toLowerCase();
|
||||||
|
const locked = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line;
|
||||||
|
const prop = byKey[`${nameKey(player)}|${stat}`];
|
||||||
|
// Prop gone from the feed (game started / market pulled) → frozen as-is.
|
||||||
|
if (!prop || prop.line == null || locked == null) { grades.push(g); continue; }
|
||||||
|
checked += 1;
|
||||||
|
|
||||||
|
const delta = signedDelta(g.direction, locked, prop.line);
|
||||||
|
if (delta == null || Math.abs(delta) < STEAM_NOISE) {
|
||||||
|
grades.push({ ...g, movement: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = Number(prop.line);
|
||||||
|
if (delta > 0) {
|
||||||
|
// Moved WITH the grade — the market is chasing our number.
|
||||||
|
steam += 1;
|
||||||
|
grades.push({ ...g, movement: { kind: 'steam', delta, currentLine: current, at: ts } });
|
||||||
|
if (Math.abs(delta) >= REGRADE_TRIGGER) {
|
||||||
|
moveEvents.push(moveEvent(sp, g, locked, current, ts));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moved AGAINST the grade.
|
||||||
|
if (Math.abs(delta) < REGRADE_TRIGGER) {
|
||||||
|
grades.push({ ...g, movement: { kind: 'against', delta, currentLine: current, at: ts } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ≥ 1.0 against → re-grade THIS PROP ONLY at the current (real) line.
|
||||||
|
let res = null;
|
||||||
|
try {
|
||||||
|
res = await deps.analyze({
|
||||||
|
player, stat_type: stat, line: current,
|
||||||
|
direction: g.direction || 'over', sport: sp, book: prop.book,
|
||||||
|
});
|
||||||
|
} catch { /* re-grade unavailable → treated as hold below */ }
|
||||||
|
moveEvents.push(moveEvent(sp, g, locked, current, ts));
|
||||||
|
|
||||||
|
if (!res || !res.grade || res.insufficient_data || rank(res.grade) <= rank(g.grade)) {
|
||||||
|
// Grade holds (or the model refuses to re-read) → better entry, same read.
|
||||||
|
value += 1;
|
||||||
|
grades.push({ ...g, movement: { kind: 'value', delta, currentLine: current, at: ts } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grade DROPS → public revision. Original grade preserved once, forever.
|
||||||
|
revised += 1;
|
||||||
|
const fromGrade = g.revised_from_grade || g.grade;
|
||||||
|
grades.push({
|
||||||
|
...g,
|
||||||
|
grade: res.grade,
|
||||||
|
revised_from_grade: fromGrade,
|
||||||
|
movement: { kind: 'revised', delta, currentLine: current, at: ts },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deps.ledger.applyRevision(sp, {
|
||||||
|
playerKey: nameKey(player), stat, line: Number(locked),
|
||||||
|
side: sideOver(g.direction) ? 'over' : 'under',
|
||||||
|
newGrade: res.grade, fromGrade,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[intraday] ledger revision failed for ${player}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back: refreshed movement state + the higher-fidelity close.
|
||||||
|
const updated = { ...snap, grades, refreshed_at: ts };
|
||||||
|
await deps.cacheSet(`snapshot:${sp}:latest`, updated, SNAP_TTL);
|
||||||
|
await deps.cacheSet(`grades:${sp}`, { grades, updated_at: snap.updated_at, refreshed_at: ts, source: snap.source || 'refresh' }, SNAP_TTL);
|
||||||
|
try { await deps.ledger.captureClosing(sp, props); } catch { /* best-effort */ }
|
||||||
|
if (moveEvents.length > 0) {
|
||||||
|
await deps.pushTickerItems(moveEvents.slice(0, TICKER_MOVE_CAP), deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sport: sp, status: 'ok', checked, steam, value, revised };
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveEvent(sport, g, locked, current, ts) {
|
||||||
|
const s = sideOver(g.direction) ? 'o' : 'u';
|
||||||
|
const arrow = current > locked ? '▲' : '▼';
|
||||||
|
const diff = Math.round((current - locked) * 100) / 100;
|
||||||
|
const last = String(g.player || g.player_name || '').trim().split(/\s+/).pop() || '';
|
||||||
|
return {
|
||||||
|
tag: 'MOVE', color: 'var(--amber)', ts, sport,
|
||||||
|
text: `${last} ${s}${locked} → ${s}${current} ${arrow}${diff > 0 ? '+' : ''}${diff}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slate hours: noon–midnight ET. */
|
||||||
|
function inSlateHours(date = new Date()) {
|
||||||
|
const h = Number(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour: 'numeric', hour12: false }).format(date));
|
||||||
|
return h >= 12 && h <= 23;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAllIntradayRefreshes(opts = {}) {
|
||||||
|
const sports = opts.sports || require('./snapshotService').ACTIVE_SPORTS;
|
||||||
|
const results = [];
|
||||||
|
for (const sp of sports) {
|
||||||
|
try { results.push(await runIntradayRefresh(sp, opts)); }
|
||||||
|
catch (e) { results.push({ sport: sp, status: 'error', reason: e.message }); }
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
runIntradayRefresh,
|
||||||
|
runAllIntradayRefreshes,
|
||||||
|
inSlateHours,
|
||||||
|
__internals: { signedDelta, indexOddsProps, moveEvent, rank, STEAM_NOISE, REGRADE_TRIGGER },
|
||||||
|
};
|
||||||
@@ -100,13 +100,17 @@ function teamOpponentFor(g, prop) {
|
|||||||
return { team, opponent: null };
|
return { team, opponent: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index odds props by nameKey|stat for lock/closing lookups. */
|
/** Index odds props by nameKey|stat for lock/closing lookups.
|
||||||
|
* Session 61 — prefer a book row with BOTH sides priced (same rule as
|
||||||
|
* snapshotService.indexOdds): fewer genuinely-absent locked/closing odds
|
||||||
|
* when another book carried the side. Real rows only, never synthesized. */
|
||||||
function indexProps(props) {
|
function indexProps(props) {
|
||||||
const map = {};
|
const map = {};
|
||||||
|
const bothSides = (p) => p && p.over_odds != null && p.under_odds != null;
|
||||||
for (const p of props || []) {
|
for (const p of props || []) {
|
||||||
if (!p || !p.player || !p.stat_type) continue;
|
if (!p || !p.player || !p.stat_type) continue;
|
||||||
const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
|
const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
|
||||||
if (!map[k]) map[k] = p;
|
if (!map[k] || (!bothSides(map[k]) && bothSides(p))) map[k] = p;
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -318,6 +322,28 @@ async function settleLedger(sport, opts = {}) {
|
|||||||
return { settled, pending };
|
return { settled, pending };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2.5 (Session 60) — a PUBLIC grade revision. The intraday refresh
|
||||||
|
* re-graded a prop whose line moved ≥1.0 against the graded side and the
|
||||||
|
* grade dropped: update the locked row's grade and set revised_from_grade
|
||||||
|
* ONCE (the original letter is preserved forever — revisions are public,
|
||||||
|
* never silent). Only unsettled public rows for today are touched.
|
||||||
|
*/
|
||||||
|
async function applyRevision(sport, { playerKey, stat, line, side, newGrade, fromGrade }, opts = {}) {
|
||||||
|
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured' };
|
||||||
|
const sb = opts.sb || defaultClient();
|
||||||
|
const { error } = await sb.from('ledger_entries')
|
||||||
|
.update({ grade: newGrade, revised_from_grade: fromGrade })
|
||||||
|
.is('user_id', null)
|
||||||
|
.eq('sport', String(sport).toLowerCase())
|
||||||
|
.eq('player_key', playerKey)
|
||||||
|
.eq('stat', stat)
|
||||||
|
.eq('line', line)
|
||||||
|
.eq('side', side)
|
||||||
|
.is('outcome', null);
|
||||||
|
return error ? { error: error.message } : { revised: true };
|
||||||
|
}
|
||||||
|
|
||||||
async function settleAllLedgers(opts = {}) {
|
async function settleAllLedgers(opts = {}) {
|
||||||
const sports = opts.sports || ['mlb', 'nba', 'wnba', 'soccer'];
|
const sports = opts.sports || ['mlb', 'nba', 'wnba', 'soccer'];
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -353,13 +379,14 @@ async function getModelAggregate(opts = {}) {
|
|||||||
const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
let settledQ = sb.from('ledger_entries')
|
let settledQ = sb.from('ledger_entries')
|
||||||
.select('outcome, clv_result, player_key')
|
.select('outcome, clv_result, player_key, grade')
|
||||||
.is('user_id', null)
|
.is('user_id', null)
|
||||||
.not('outcome', 'is', null)
|
.not('outcome', 'is', null)
|
||||||
.gte('game_date', since)
|
.gte('game_date', since)
|
||||||
.limit(AGG_FETCH_LIMIT);
|
.limit(AGG_FETCH_LIMIT);
|
||||||
if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase());
|
if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase());
|
||||||
if (opts.playerKey) settledQ = settledQ.eq('player_key', opts.playerKey);
|
if (opts.playerKey) settledQ = settledQ.eq('player_key', opts.playerKey);
|
||||||
|
if (opts.team) settledQ = settledQ.eq('team', opts.team); // Session 60 (5.2) — VYNDR-on-team
|
||||||
const { data: settledRows, error } = await settledQ;
|
const { data: settledRows, error } = await settledQ;
|
||||||
if (error) return { ...empty, error: error.message };
|
if (error) return { ...empty, error: error.message };
|
||||||
|
|
||||||
@@ -369,9 +396,19 @@ async function getModelAggregate(opts = {}) {
|
|||||||
.is('outcome', null);
|
.is('outcome', null);
|
||||||
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
|
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
|
||||||
if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
|
if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
|
||||||
|
if (opts.team) pendingQ = pendingQ.eq('team', opts.team);
|
||||||
const { count: pending } = await pendingQ;
|
const { count: pending } = await pendingQ;
|
||||||
|
|
||||||
const agg = { ...empty, pending: pending || 0 };
|
const agg = { ...empty, pending: pending || 0 };
|
||||||
|
// Session 60 (5.5) — calibration by grade tier (A+ alone, then first
|
||||||
|
// letter). Same n≥20 rule PER TIER: a tier below threshold reports a
|
||||||
|
// null pct and the UI shows "building", never a small-sample %.
|
||||||
|
const tierOf = (g) => {
|
||||||
|
const s = String(g || '').trim().toUpperCase();
|
||||||
|
if (!s) return null;
|
||||||
|
return s === 'A+' ? 'A+' : s[0];
|
||||||
|
};
|
||||||
|
const byTier = {};
|
||||||
for (const r of settledRows || []) {
|
for (const r of settledRows || []) {
|
||||||
agg.settled += 1;
|
agg.settled += 1;
|
||||||
if (r.outcome === 'hit') agg.hits += 1;
|
if (r.outcome === 'hit') agg.hits += 1;
|
||||||
@@ -383,7 +420,22 @@ async function getModelAggregate(opts = {}) {
|
|||||||
else if (r.clv_result === 'faded') agg.clv_faded += 1;
|
else if (r.clv_result === 'faded') agg.clv_faded += 1;
|
||||||
else agg.clv_flat += 1;
|
else agg.clv_flat += 1;
|
||||||
}
|
}
|
||||||
|
const t = tierOf(r.grade);
|
||||||
|
if (t) {
|
||||||
|
byTier[t] = byTier[t] || { settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null };
|
||||||
|
const b = byTier[t];
|
||||||
|
b.settled += 1;
|
||||||
|
if (r.outcome === 'hit') b.hits += 1;
|
||||||
|
else if (r.outcome === 'miss') b.misses += 1;
|
||||||
|
else if (r.outcome === 'push') b.pushes += 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
for (const t of Object.keys(byTier)) {
|
||||||
|
const b = byTier[t];
|
||||||
|
const d = b.hits + b.misses;
|
||||||
|
if (b.settled >= MIN_AGG_SAMPLE && d > 0) b.hit_pct = Math.round((b.hits / d) * 100);
|
||||||
|
}
|
||||||
|
agg.by_tier = byTier;
|
||||||
const decided = agg.hits + agg.misses;
|
const decided = agg.hits + agg.misses;
|
||||||
// n<20 → null: never render a percentage on a small sample.
|
// n<20 → null: never render a percentage on a small sample.
|
||||||
if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) {
|
if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) {
|
||||||
@@ -400,6 +452,7 @@ module.exports = {
|
|||||||
captureClosing,
|
captureClosing,
|
||||||
settleLedger,
|
settleLedger,
|
||||||
settleAllLedgers,
|
settleAllLedgers,
|
||||||
|
applyRevision,
|
||||||
getModelAggregate,
|
getModelAggregate,
|
||||||
MIN_AGG_SAMPLE,
|
MIN_AGG_SAMPLE,
|
||||||
__internals: {
|
__internals: {
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
|||||||
season: mlbSeasonRows(res.season, res.group),
|
season: mlbSeasonRows(res.season, res.group),
|
||||||
last10: mlbLast10Rows(res.last10, res.group),
|
last10: mlbLast10Rows(res.last10, res.group),
|
||||||
splits: [],
|
splits: [],
|
||||||
|
// Session 60 (night2/B) — the RAW flattened game log, most-recent
|
||||||
|
// first, for the streaks/hot-list roster blob. Free: the adapter
|
||||||
|
// already fetched it for this resolve; nothing extra is called.
|
||||||
|
rawLog: (res.last10 || [])
|
||||||
|
.map((r) => ({ date: r.date || null, opponent: r.opponent || null, isHome: r.isHome ?? null, ...(r.stat || {}) }))
|
||||||
|
.reverse(),
|
||||||
|
seasonRaw: res.season || null,
|
||||||
|
group: res.group || null,
|
||||||
|
playerId: res.id ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (sp === 'nba' || sp === 'wnba') {
|
if (sp === 'nba' || sp === 'wnba') {
|
||||||
@@ -147,7 +156,13 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
|||||||
{ k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') },
|
{ k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') },
|
||||||
{ k: 'APG', v: String(ci.apg ?? '—') }, { k: 'BLK', v: String(ci.bpg ?? '—') },
|
{ k: 'APG', v: String(ci.apg ?? '—') }, { k: 'BLK', v: String(ci.bpg ?? '—') },
|
||||||
];
|
];
|
||||||
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position }, season, last10: [], splits: [] };
|
// Session 60 (5.3) — WNBA/NBA parity: minutes-based usage on the
|
||||||
|
// profile (the basketball equivalent of AB/G). Only when the feed
|
||||||
|
// carries minutes — absent beats invented.
|
||||||
|
const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes);
|
||||||
|
const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
|
||||||
|
if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) });
|
||||||
|
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [] };
|
||||||
}
|
}
|
||||||
return { found: false };
|
return { found: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
* ticker:items — capped array of ticker events (newest first)
|
* ticker:items — capped array of ticker events (newest first)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SNAP_TTL = 6 * 3600; // 6h — a snapshot is valid until the next run
|
// Session 60 (AUTONOMY P0) — 24h, NOT 6h. The overnight cron gap is 11 hours
|
||||||
|
// (03:00 UTC → 14:00 UTC); with a 6h TTL the morning settle pass read an
|
||||||
|
// EXPIRED snapshot and the accuracy loop silently settled nothing. The
|
||||||
|
// snapshot must survive until the morning-after settle reads it.
|
||||||
|
const SNAP_TTL = 24 * 3600;
|
||||||
const TICKER_TTL = 24 * 3600;
|
const TICKER_TTL = 24 * 3600;
|
||||||
const TICKER_CAP = 50;
|
const TICKER_CAP = 50;
|
||||||
const DELTA_NOISE = 0.5; // ignore movements smaller than this
|
const DELTA_NOISE = 0.5; // ignore movements smaller than this
|
||||||
@@ -48,12 +52,17 @@ async function mapLimit(items, concurrency, fn) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index original odds props by propKey-ish (player|stat) for odds lookup. */
|
/** Index original odds props by propKey-ish (player|stat) for odds lookup.
|
||||||
|
* Session 61 — prefer a book row with BOTH sides priced: the first-seen row
|
||||||
|
* sometimes carried only one side (e.g. betmgm SB unders with no juice),
|
||||||
|
* which locked a NULL odds even though another book priced it. Still a
|
||||||
|
* REAL book row, never synthesized. */
|
||||||
function indexOdds(props) {
|
function indexOdds(props) {
|
||||||
const map = {};
|
const map = {};
|
||||||
|
const bothSides = (p) => p && p.over_odds != null && p.under_odds != null;
|
||||||
for (const p of props || []) {
|
for (const p of props || []) {
|
||||||
const k = `${norm(p.player)}|${String(p.stat_type || '').toLowerCase()}`;
|
const k = `${norm(p.player)}|${String(p.stat_type || '').toLowerCase()}`;
|
||||||
map[k] = p;
|
if (!map[k] || (!bothSides(map[k]) && bothSides(p))) map[k] = p;
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -165,6 +174,28 @@ async function pushTickerItems(events, deps) {
|
|||||||
await deps.cacheSet('ticker:items', merged, TICKER_TTL);
|
await deps.cacheSet('ticker:items', merged, TICKER_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session 60 (night2/B) — accumulate slate players' game logs into the
|
||||||
|
// roster blob the streaks/hot-list engines read. Merge by nameKey (newer
|
||||||
|
// entry wins), cap the blob, 72h TTL (a player off the slate for 3 days
|
||||||
|
// ages out — honest churn, not a leak).
|
||||||
|
const ROSTERLOGS_TTL = 72 * 3600;
|
||||||
|
const ROSTERLOGS_CAP = 300;
|
||||||
|
|
||||||
|
async function mergeRosterLogs(sport, entries, deps) {
|
||||||
|
if (!entries || entries.length === 0) return;
|
||||||
|
try {
|
||||||
|
const key = `rosterlogs:${sport}`;
|
||||||
|
const existing = await deps.cacheGet(key);
|
||||||
|
const byKey = new Map();
|
||||||
|
for (const e of Array.isArray(existing) ? existing : []) byKey.set(nameKey(e.name), e);
|
||||||
|
for (const e of entries) byKey.set(nameKey(e.name), e); // fresh resolve wins
|
||||||
|
const merged = [...byKey.values()].slice(-ROSTERLOGS_CAP);
|
||||||
|
await deps.cacheSet(key, merged, ROSTERLOGS_TTL);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[snapshot] rosterlogs merge failed for ${sport}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ACTIVE_SPORTS = ['mlb', 'nba', 'wnba', 'soccer'];
|
const ACTIVE_SPORTS = ['mlb', 'nba', 'wnba', 'soccer'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -267,6 +298,14 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
|
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
|
||||||
// and the slate join guard (a prop only attaches to its own game).
|
// and the slate join guard (a prop only attaches to its own game).
|
||||||
const teamByPlayer = {};
|
const teamByPlayer = {};
|
||||||
|
// Session 60 (night2/B) — THE STREAKS PRODUCER. The aggregator (streaks +
|
||||||
|
// hot lists) starved because its data producers were all external and
|
||||||
|
// unarmed (tank01-prefetch via n8n, the offline Python grading flow).
|
||||||
|
// The stats resolve above already fetched each slate player's game log —
|
||||||
|
// accumulate it into the `rosterlogs:{sport}` blob rosterLogs.loadRosterLogs
|
||||||
|
// reads FIRST. Zero extra API calls; the pipeline now feeds its own
|
||||||
|
// free layer.
|
||||||
|
const logEntries = [];
|
||||||
await mapLimit(players, STATS_CONCURRENCY, async (player) => {
|
await mapLimit(players, STATS_CONCURRENCY, async (player) => {
|
||||||
try {
|
try {
|
||||||
const stats = await deps.resolveStats(player, sp);
|
const stats = await deps.resolveStats(player, sp);
|
||||||
@@ -274,9 +313,20 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
const c = deps.classify(sp, stats.classifierInput || {});
|
const c = deps.classify(sp, stats.classifierInput || {});
|
||||||
archByPlayer[player] = c.primary ? c.primary.name : null;
|
archByPlayer[player] = c.primary ? c.primary.name : null;
|
||||||
if (stats.team) teamByPlayer[player] = stats.team;
|
if (stats.team) teamByPlayer[player] = stats.team;
|
||||||
|
if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) {
|
||||||
|
logEntries.push({
|
||||||
|
name: normalizeName(player).display || player,
|
||||||
|
playerId: stats.playerId ?? null,
|
||||||
|
team: stats.team || null,
|
||||||
|
group: stats.group || null,
|
||||||
|
seasonRaw: stats.seasonRaw || null,
|
||||||
|
games: stats.rawLog,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch { /* graceful — no badge */ }
|
} catch { /* graceful — no badge */ }
|
||||||
});
|
});
|
||||||
|
await mergeRosterLogs(sp, logEntries, deps);
|
||||||
|
|
||||||
const enriched = graded.map((g) => ({
|
const enriched = graded.map((g) => ({
|
||||||
...g,
|
...g,
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The VYNDR Lens (Session 60, night2/B) — THE RULE: no raw streak ever
|
||||||
|
* renders alone. Every streak/hot-list row carries:
|
||||||
|
* 1. what it was BUILT against (the streak-window opponents — real, from
|
||||||
|
* the game log),
|
||||||
|
* 2. tonight's matchup (opponent + MLB opposing probable SP w/ ERA, from
|
||||||
|
* the free schedule/pitcher caches),
|
||||||
|
* 3. difficulty vs the streak's diet (MLB: SP ERA vs league ~4.00),
|
||||||
|
* 4. a one-line read composing the above.
|
||||||
|
*
|
||||||
|
* Interpreted data is the FREE layer; the grade on it stays paid. Every
|
||||||
|
* field is real or absent — when we can't rate the matchup we say less,
|
||||||
|
* we never invent. Pure module: callers supply tonight's context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// League-average SP ERA bands (MLB ~4.00 in the modern run environment).
|
||||||
|
const ERA_STEP_UP = 3.4; // facing a SP at/below this = harder than average
|
||||||
|
const ERA_STEP_DOWN = 4.6; // at/above this = softer than average
|
||||||
|
|
||||||
|
const token = (name) => String(name || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();
|
||||||
|
const mascot = (name) => { const t = token(name).split(' ').filter(Boolean); return t.length ? t[t.length - 1] : ''; };
|
||||||
|
const teamsMatch = (a, b) => {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return token(a) === token(b) || (mascot(a) !== '' && mascot(a) === mascot(b));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find tonight's game + opponent for a team from the cached schedule
|
||||||
|
* (`schedule:{sport}:{date}` shape: [{ homeTeam:{name,abbreviation}, awayTeam, gameTime }]).
|
||||||
|
*/
|
||||||
|
function tonightFor(team, scheduleGames) {
|
||||||
|
if (!team || !Array.isArray(scheduleGames)) return null;
|
||||||
|
for (const g of scheduleGames) {
|
||||||
|
const home = g.homeTeam || {};
|
||||||
|
const away = g.awayTeam || {};
|
||||||
|
if (teamsMatch(team, home.name) || teamsMatch(team, home.abbreviation)) {
|
||||||
|
return { opponent: away.name || away.abbreviation || null, opponentAbbr: away.abbreviation || null, isHome: true, gameTime: g.gameTime || null };
|
||||||
|
}
|
||||||
|
if (teamsMatch(team, away.name) || teamsMatch(team, away.abbreviation)) {
|
||||||
|
return { opponent: home.name || home.abbreviation || null, opponentAbbr: home.abbreviation || null, isHome: false, gameTime: g.gameTime || null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonight's opposing probable SP for a team, from the probable-pitchers
|
||||||
|
* games ([{ home: {team,pitcher,era}, away: {...} }]). The OPPOSING side.
|
||||||
|
*/
|
||||||
|
function opposingPitcherFor(team, pitcherGames) {
|
||||||
|
if (!team || !Array.isArray(pitcherGames)) return null;
|
||||||
|
for (const g of pitcherGames) {
|
||||||
|
if (g && g.home && teamsMatch(team, g.home.team)) return g.away && g.away.pitcher ? g.away : null;
|
||||||
|
if (g && g.away && teamsMatch(team, g.away.team)) return g.home && g.home.pitcher ? g.home : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eraDifficulty(era) {
|
||||||
|
const e = Number(era);
|
||||||
|
if (!Number.isFinite(e) || e <= 0) return null;
|
||||||
|
if (e <= ERA_STEP_UP) return 'step up';
|
||||||
|
if (e >= ERA_STEP_DOWN) return 'step down';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPitcherRow = (row) => row && (row.type === 'k_streak' || row.type === 'qs_streak');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the lens for one streak/heat row. ctx = { scheduleGames, pitcherGames }.
|
||||||
|
* Returns { builtVs, matchup, difficulty, read } — every field real or null.
|
||||||
|
*/
|
||||||
|
function buildLens(row, ctx = {}) {
|
||||||
|
const lens = { builtVs: null, matchup: null, difficulty: null, read: null };
|
||||||
|
if (!row) return lens;
|
||||||
|
|
||||||
|
const opps = Array.isArray(row.opponents) ? row.opponents.filter(Boolean) : [];
|
||||||
|
if (opps.length > 0) lens.builtVs = opps.slice(0, 4);
|
||||||
|
|
||||||
|
const tonight = tonightFor(row.team, ctx.scheduleGames);
|
||||||
|
if (tonight && tonight.opponent) {
|
||||||
|
lens.matchup = `${tonight.isHome ? 'vs' : '@'} ${tonight.opponent}`;
|
||||||
|
// MLB batter lens: the opposing probable SP is the matchup that matters.
|
||||||
|
if (String(row.sport).toLowerCase() === 'mlb' && !isPitcherRow(row)) {
|
||||||
|
const sp = opposingPitcherFor(row.team, ctx.pitcherGames);
|
||||||
|
if (sp && sp.pitcher) {
|
||||||
|
lens.matchup += ` · SP ${sp.pitcher}${sp.era != null ? ` (${sp.era} ERA)` : ''}`;
|
||||||
|
lens.difficulty = eraDifficulty(sp.era);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-line read: composed only from parts we actually have.
|
||||||
|
const bits = [row.description];
|
||||||
|
if (lens.builtVs) bits.push(`built vs ${lens.builtVs.join(', ')}`);
|
||||||
|
if (lens.matchup) {
|
||||||
|
if (lens.difficulty === 'step up') bits.push(`tonight ${lens.matchup} — a step up`);
|
||||||
|
else if (lens.difficulty === 'step down') bits.push(`tonight ${lens.matchup} — a softer spot`);
|
||||||
|
else bits.push(`tonight ${lens.matchup}`);
|
||||||
|
}
|
||||||
|
lens.read = bits.join('; ');
|
||||||
|
return lens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach the lens to every row (in place shape: { ...row, lens }). */
|
||||||
|
function applyLens(rows, ctx = {}) {
|
||||||
|
return (rows || []).map((r) => ({ ...r, lens: buildLens(r, ctx) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildLens,
|
||||||
|
applyLens,
|
||||||
|
tonightFor,
|
||||||
|
opposingPitcherFor,
|
||||||
|
__internals: { eraDifficulty, teamsMatch, ERA_STEP_UP, ERA_STEP_DOWN },
|
||||||
|
};
|
||||||
@@ -191,7 +191,7 @@ function computePlayerStreaks(player, sport, opts = {}) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const run = consecutiveRun(games, spec.value, spec.threshold);
|
const run = consecutiveRun(games, spec.value, spec.threshold);
|
||||||
if (run >= MIN_STREAK) found.push(makeStreak(player, sport, spec, run));
|
if (run >= MIN_STREAK) found.push(makeStreak(player, sport, spec, run, undefined, games));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse tiered specs (e.g. 25+ and 20+ points) to one entry per
|
// Collapse tiered specs (e.g. 25+ and 20+ points) to one entry per
|
||||||
@@ -210,7 +210,12 @@ function computePlayerStreaks(player, sport, opts = {}) {
|
|||||||
return Array.from(best.values()).map(({ _collapse, ...rest }) => rest);
|
return Array.from(best.values()).map(({ _collapse, ...rest }) => rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStreak(player, sport, spec, run, rateValue) {
|
function makeStreak(player, sport, spec, run, rateValue, games) {
|
||||||
|
// Session 60 (night2/B) — the opponents the streak was BUILT against
|
||||||
|
// (unique, streak-window only). Lens fuel: "built vs OAK, LAA, SEA".
|
||||||
|
const opponents = Array.isArray(games)
|
||||||
|
? [...new Set(games.slice(0, run).map((g) => g && g.opponent).filter(Boolean))]
|
||||||
|
: [];
|
||||||
return {
|
return {
|
||||||
sport,
|
sport,
|
||||||
player: player.name || player.player || null,
|
player: player.name || player.player || null,
|
||||||
@@ -222,6 +227,7 @@ function makeStreak(player, sport, spec, run, rateValue) {
|
|||||||
currentStreak: run,
|
currentStreak: run,
|
||||||
rate: rateValue ?? null,
|
rate: rateValue ?? null,
|
||||||
description: describe(spec, run),
|
description: describe(spec, run),
|
||||||
|
opponents,
|
||||||
active: true,
|
active: true,
|
||||||
_collapse: spec.collapse || spec.key, // internal — stripped before return
|
_collapse: spec.collapse || spec.key, // internal — stripped before return
|
||||||
};
|
};
|
||||||
@@ -245,9 +251,114 @@ function computeStreaks(players, sport, opts = {}) {
|
|||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Session 60 (night2/B): FORM HEAT — 7-day rate vs baseline ----------
|
||||||
|
// Hot hitters (AVG), hot sluggers (SLG/ISO), hot shooters (FG%). Rates are
|
||||||
|
// computed the correct way (Σmakes/Σattempts over the window, NOT a mean of
|
||||||
|
// per-game rates). Baseline = the player's season rate when the roster blob
|
||||||
|
// carries one, else the pre-window games — labeled accordingly, never faked.
|
||||||
|
|
||||||
|
const fmt3 = (n) => {
|
||||||
|
const s = n.toFixed(3);
|
||||||
|
return s.startsWith('0.') ? s.slice(1) : s; // .412, not 0.412
|
||||||
|
};
|
||||||
|
|
||||||
|
function windowSplit(games, now, windowDays = 7) {
|
||||||
|
const cutoff = (now || Date.now()) - windowDays * 86_400_000;
|
||||||
|
const dated = games.filter((g) => g && g.date && Number.isFinite(new Date(g.date).getTime()));
|
||||||
|
if (dated.length === 0) return { recent: games.slice(0, 5), rest: games.slice(5) };
|
||||||
|
return {
|
||||||
|
recent: dated.filter((g) => new Date(g.date).getTime() >= cutoff),
|
||||||
|
rest: dated.filter((g) => new Date(g.date).getTime() < cutoff),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumOf = (rows, ...keys) => rows.reduce((acc, r) => acc + num(r, ...keys), 0);
|
||||||
|
|
||||||
|
// Rate over a set of games: { made, att, rate|null }.
|
||||||
|
function ratio(rows, madeKeys, attKeys) {
|
||||||
|
const made = sumOf(rows, ...madeKeys);
|
||||||
|
const att = sumOf(rows, ...attKeys);
|
||||||
|
return { made, att, rate: att > 0 ? made / att : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEAT_SPECS = {
|
||||||
|
mlb: [
|
||||||
|
{
|
||||||
|
type: 'hot_hitter', category: 'hits', label: 'AVG',
|
||||||
|
made: ['hits', 'H', 'h'], att: ['atBats', 'ab', 'AB'],
|
||||||
|
seasonKey: 'avg', minAtt: 15, minDelta: 0.05, fmt: fmt3,
|
||||||
|
line: (r, b, src) => `hitting ${fmt3(r)} over the last 7 days (${src} ${fmt3(b)})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'hot_slugger', category: 'total_bases', label: 'SLG',
|
||||||
|
made: ['totalBases', 'TB', 'total_bases'], att: ['atBats', 'ab', 'AB'],
|
||||||
|
seasonKey: 'slg', minAtt: 15, minDelta: 0.09, fmt: fmt3,
|
||||||
|
line: (r, b, src) => `slugging ${fmt3(r)} over the last 7 days (${src} ${fmt3(b)})`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
wnba: [
|
||||||
|
{
|
||||||
|
type: 'hot_shooter', category: 'points', label: 'FG%',
|
||||||
|
made: ['fgm', 'field_goals_made', 'fieldGoalsMade'], att: ['fga', 'field_goals_attempted', 'fieldGoalsAttempted'],
|
||||||
|
seasonKey: 'fgPct', minAtt: 20, minDelta: 0.05, fmt: (n) => `${Math.round(n * 100)}%`,
|
||||||
|
line: (r, b, src) => `shooting ${Math.round(r * 100)}% over the last 7 days (${src} ${Math.round(b * 100)}%)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
HEAT_SPECS.nba = HEAT_SPECS.wnba;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form-heat rows in the streak shape (they merge into the same feed).
|
||||||
|
* players = [{ name, playerId, team, games, seasonRaw? }] — games
|
||||||
|
* most-recent-first with date fields.
|
||||||
|
*/
|
||||||
|
function computeFormHeat(players, sport, opts = {}) {
|
||||||
|
const specs = HEAT_SPECS[String(sport || '').toLowerCase()] || [];
|
||||||
|
if (specs.length === 0 || !Array.isArray(players)) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const p of players) {
|
||||||
|
const games = Array.isArray(p?.games) ? p.games.slice() : [];
|
||||||
|
if (opts.chronological) games.reverse();
|
||||||
|
if (games.length === 0) continue;
|
||||||
|
const { recent, rest } = windowSplit(games, opts.now, opts.windowDays || 7);
|
||||||
|
for (const spec of specs) {
|
||||||
|
const cur = ratio(recent, spec.made, spec.att);
|
||||||
|
if (cur.rate == null || cur.att < spec.minAtt) continue;
|
||||||
|
// Baseline: season rate from the blob when present, else prior games.
|
||||||
|
let baseline = null;
|
||||||
|
let baselineSrc = 'season';
|
||||||
|
const seasonVal = p.seasonRaw && Number(p.seasonRaw[spec.seasonKey]);
|
||||||
|
if (Number.isFinite(seasonVal) && seasonVal > 0) baseline = seasonVal;
|
||||||
|
else {
|
||||||
|
const prior = ratio(rest, spec.made, spec.att);
|
||||||
|
if (prior.rate != null && prior.att >= spec.minAtt) { baseline = prior.rate; baselineSrc = 'prior stretch'; }
|
||||||
|
}
|
||||||
|
if (baseline == null || cur.rate - baseline < spec.minDelta) continue;
|
||||||
|
out.push({
|
||||||
|
sport: String(sport).toLowerCase(),
|
||||||
|
player: p.name || p.player || null,
|
||||||
|
playerId: p.playerId ?? p.id ?? null,
|
||||||
|
team: p.team || null,
|
||||||
|
type: spec.type,
|
||||||
|
category: spec.category,
|
||||||
|
threshold: null,
|
||||||
|
currentStreak: recent.length,
|
||||||
|
rate: Math.round(cur.rate * 1000) / 1000,
|
||||||
|
baseline: Math.round(baseline * 1000) / 1000,
|
||||||
|
description: spec.line(cur.rate, baseline, baselineSrc),
|
||||||
|
opponents: [...new Set(recent.map((g) => g && g.opponent).filter(Boolean))],
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort((a, b) => (b.rate - b.baseline) - (a.rate - a.baseline));
|
||||||
|
return opts.limit && opts.limit > 0 ? out.slice(0, opts.limit) : out;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
computeStreaks,
|
computeStreaks,
|
||||||
computePlayerStreaks,
|
computePlayerStreaks,
|
||||||
|
computeFormHeat,
|
||||||
specsFor,
|
specsFor,
|
||||||
__internals: { consecutiveRun, rateOverWindow, nba, mlb, nfl, soccer, MIN_STREAK },
|
__internals: { consecutiveRun, rateOverWindow, windowSplit, ratio, nba, mlb, nfl, soccer, MIN_STREAK, HEAT_SPECS },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,10 +115,44 @@ function startSnapshotScheduler(opts = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const interval = setInterval(tick, 60_000);
|
// Session 60 (night2/D) — Phase 2.5 intraday refresh. Every
|
||||||
|
// REFRESH_MINUTES during slate hours (noon–midnight ET), odds-only:
|
||||||
|
// STEAM/VALUE badges, ≥1.0-against re-grades with public revisions,
|
||||||
|
// refresh-fidelity closing capture, higher-frequency ticker MOVEs.
|
||||||
|
// Skips the top of scheduled snapshot hours (the full run owns those).
|
||||||
|
// Kill switch: INTRADAY_REFRESH=0.
|
||||||
|
const REFRESH_MINUTES = Math.max(5, parseInt(process.env.INTRADAY_REFRESH_MINUTES || '20', 10) || 20);
|
||||||
|
const refreshAll = opts.runAllIntradayRefreshes || require('./services/intradayRefreshService').runAllIntradayRefreshes;
|
||||||
|
const inSlateHours = opts.inSlateHours || require('./services/intradayRefreshService').inSlateHours;
|
||||||
|
let lastRefreshSlot = null;
|
||||||
|
const refreshTick = async () => {
|
||||||
|
if (process.env.INTRADAY_REFRESH === '0') return;
|
||||||
|
const d = now();
|
||||||
|
if (!inSlateHours(d)) return;
|
||||||
|
if (d.getUTCMinutes() % REFRESH_MINUTES !== 0) return;
|
||||||
|
if (d.getUTCMinutes() === 0 && HOURS_UTC.includes(d.getUTCHours())) return; // full snapshot owns this slot
|
||||||
|
const slot = `${d.toISOString().slice(0, 13)}-${d.getUTCMinutes()}`;
|
||||||
|
if (slot === lastRefreshSlot) return;
|
||||||
|
lastRefreshSlot = slot;
|
||||||
|
try {
|
||||||
|
const results = await refreshAll();
|
||||||
|
const touched = results.filter((r) => r.status === 'ok');
|
||||||
|
const revised = results.reduce((n, r) => n + (r.revised || 0), 0);
|
||||||
|
if (touched.length > 0) console.log(`[intraday] refresh — ${touched.length} sports, ${revised} public revisions`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[intraday] refresh failed:', e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(() => { void tick(); void refreshTick(); }, 60_000);
|
||||||
if (interval.unref) interval.unref();
|
if (interval.unref) interval.unref();
|
||||||
console.log(`[snapshotScheduler] armed — SNAPSHOT_CRON=${process.env.SNAPSHOT_CRON}, hours=${HOURS_UTC.join(',')} UTC`);
|
console.log(`[snapshotScheduler] armed — SNAPSHOT_CRON=${process.env.SNAPSHOT_CRON}, hours=${HOURS_UTC.join(',')} UTC, intraday=${process.env.INTRADAY_REFRESH === '0' ? 'off' : `${REFRESH_MINUTES}m (slate hours)`}`);
|
||||||
return { interval, tick };
|
// Session 61 — settlement is scheduled IN THIS tick (settleAllOutcomes +
|
||||||
|
// settleAllLedgers run FIRST at every snapshot slot, before grading). It
|
||||||
|
// was invisible at boot, which made "is settlement scheduled?" unanswerable
|
||||||
|
// from logs. This line makes it verifiable forever.
|
||||||
|
console.log(`[settlement] armed — outcomes + ledger settle pass runs FIRST at each snapshot slot (${HOURS_UTC.join(',')} UTC), idempotent re-runs`);
|
||||||
|
return { interval, tick, refreshTick };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startSnapshotScheduler, HOURS_UTC, mostRecentExpectedSlot, isSnapshotOverdue };
|
module.exports = { startSnapshotScheduler, HOURS_UTC, mostRecentExpectedSlot, isSnapshotOverdue };
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Session 60 (night2/G) — the aggregator's content formats (spec §12).
|
||||||
|
// streak-watch is the ZERO-GRADE daily format; daily-report flags itself
|
||||||
|
// DO-NOT-POST until the record is real (n>=20).
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
const mockStore = new Map();
|
||||||
|
jest.mock('../../src/utils/redis', () => ({
|
||||||
|
cacheGet: async (k) => (mockStore.has(k) ? mockStore.get(k) : null),
|
||||||
|
cacheSet: async (k, v) => { mockStore.set(k, v); return true; },
|
||||||
|
getRedisClient: () => null,
|
||||||
|
isDegraded: () => true,
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/services/rosterLogs', () => ({ loadRosterLogs: jest.fn(async () => []) }));
|
||||||
|
const { loadRosterLogs } = require('../../src/services/rosterLogs');
|
||||||
|
|
||||||
|
function mountApp() {
|
||||||
|
delete require.cache[require.resolve('../../src/routes/content')];
|
||||||
|
const app = express();
|
||||||
|
app.use('/api/content', require('../../src/routes/content'));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => { mockStore.clear(); jest.clearAllMocks(); });
|
||||||
|
|
||||||
|
describe('GET /api/content/top-signals/:sport', () => {
|
||||||
|
test('returns tonight top graded props from the snapshot cache', async () => {
|
||||||
|
mockStore.set('snapshot:mlb:latest', {
|
||||||
|
updated_at: 'x',
|
||||||
|
grades: [
|
||||||
|
{ player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'A', confidence: 80, gradedAt: { timestamp: 't1' } },
|
||||||
|
{ player: 'Settled Guy', stat_type: 'hits', line: 1.5, grade: 'A', confidence: 90, outcome: { result: 'hit' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = await request(mountApp()).get('/api/content/top-signals/mlb');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.dataLevel).toBe('full');
|
||||||
|
expect(res.body.signals).toHaveLength(1); // settled props excluded
|
||||||
|
expect(res.body.signals[0].player).toBe('Aaron Judge');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/content/streak-watch/:sport', () => {
|
||||||
|
test('the zero-grade format: real streaks through the lens', async () => {
|
||||||
|
loadRosterLogs.mockResolvedValue([
|
||||||
|
{ name: 'Streaky Guy', team: 'Tampa Bay Rays', games: [
|
||||||
|
{ date: '2026-07-10', opponent: 'Boston Red Sox', hits: 2 },
|
||||||
|
{ date: '2026-07-09', opponent: 'Boston Red Sox', hits: 1 },
|
||||||
|
{ date: '2026-07-08', opponent: 'New York Yankees', hits: 1 },
|
||||||
|
] },
|
||||||
|
]);
|
||||||
|
const res = await request(mountApp()).get('/api/content/streak-watch/mlb');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.dataLevel).toBe('full');
|
||||||
|
expect(res.body.streaks[0].player).toBe('Streaky Guy');
|
||||||
|
expect(res.body.streaks[0].read).toContain('hit streak');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/content/daily-report/:sport', () => {
|
||||||
|
test('flags DO-NOT-POST while the record is building', async () => {
|
||||||
|
const res = await request(mountApp()).get('/api/content/daily-report/mlb');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.do_not_post).toBe(true);
|
||||||
|
expect(res.body.reason).toContain('record building');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,44 @@
|
|||||||
// Session 42 — Stats Explorer (/explore) page (bonus design section 07).
|
// Session 42 — Stats Explorer; Session 60 (night2/C) — the AGGREGATOR hub.
|
||||||
|
// The page is now a server shell (SEO metadata) around the ExploreHub client
|
||||||
|
// component: leaders + full streaks + hot lists = the "entire picture" page.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
const src = fs.readFileSync(path.join(WEB, 'app', 'explore', 'page.tsx'), 'utf8');
|
const hub = fs.readFileSync(path.join(WEB, 'components', 'ExploreHub.tsx'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(WEB, 'app', 'explore', 'page.tsx'), 'utf8');
|
||||||
|
|
||||||
describe('Stats Explorer page', () => {
|
describe('Stats Explorer hub (client)', () => {
|
||||||
it('consumes the real /api/stats/leaders endpoint', () => {
|
it('consumes the real /api/stats/leaders endpoint', () => {
|
||||||
expect(src).toContain('/api/stats/leaders');
|
expect(hub).toContain('/api/stats/leaders');
|
||||||
});
|
});
|
||||||
it('has NBA/MLB/WNBA sport tabs + a player search filter', () => {
|
it('has NBA/MLB/WNBA sport tabs + a player search filter', () => {
|
||||||
expect(src).toContain('NBA');
|
expect(hub).toContain('NBA');
|
||||||
expect(src).toContain('MLB');
|
expect(hub).toContain('MLB');
|
||||||
expect(src).toContain('WNBA');
|
expect(hub).toContain('WNBA');
|
||||||
expect(src).toContain('Search players');
|
expect(hub).toContain('Search players');
|
||||||
});
|
});
|
||||||
it('rows link to the player profile', () => {
|
it('rows link to the player profile', () => {
|
||||||
expect(src).toContain('playerHref');
|
expect(hub).toContain('playerHref');
|
||||||
});
|
});
|
||||||
it('handles loading / error / empty states', () => {
|
it('handles loading / error / empty states', () => {
|
||||||
expect(src).toContain("'loading'");
|
expect(hub).toContain("'loading'");
|
||||||
expect(src).toContain("'error'");
|
expect(hub).toContain("'error'");
|
||||||
expect(src).toContain('No graded props');
|
expect(hub).toContain('No graded props');
|
||||||
|
});
|
||||||
|
// Session 60 — the aggregator: full streaks + hot lists on one page,
|
||||||
|
// gated by the REAL tier (streaks free for all; hot lists top-3 free).
|
||||||
|
it('mounts the streaks + hot-list aggregator with the real tier', () => {
|
||||||
|
expect(hub).toContain('<StreaksPanel sport={sport} tier={tier}');
|
||||||
|
expect(hub).toContain('<HotListPanel sport={sport} tier={tier}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Explore page shell (server, SEO)', () => {
|
||||||
|
it('is a server component carrying daily-indexable metadata', () => {
|
||||||
|
expect(shell).not.toContain("'use client'");
|
||||||
|
expect(shell).toContain('export const metadata');
|
||||||
|
expect(shell).toContain('Hit Streaks');
|
||||||
|
expect(shell).toContain('<ExploreHub />');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Session 60 (night2/D) — Phase 2.5 intraday refresh. Direction is the
|
||||||
|
// signal: WITH the grade = STEAM (no re-grade); AGAINST ≥1.0 = re-grade
|
||||||
|
// that prop only (holds → VALUE, drops → PUBLIC revision). All lines are
|
||||||
|
// real feed values; the refresh recaptures the close.
|
||||||
|
|
||||||
|
const { runIntradayRefresh, inSlateHours, __internals } = require('../../src/services/intradayRefreshService');
|
||||||
|
const { signedDelta } = __internals;
|
||||||
|
|
||||||
|
const NOW = '2026-07-11T20:00:00.000Z';
|
||||||
|
|
||||||
|
function harness({ grades, oddsProps, analyzeResult }) {
|
||||||
|
const store = new Map([[`snapshot:mlb:latest`, { sport: 'mlb', updated_at: NOW, grades, deltas: [] }]]);
|
||||||
|
const calls = { analyze: [], revisions: [], closing: 0, ticker: [] };
|
||||||
|
const deps = {
|
||||||
|
cacheGet: async (k) => store.get(k) ?? null,
|
||||||
|
cacheSet: async (k, v) => { store.set(k, v); },
|
||||||
|
getOdds: async () => ({ props: oddsProps }),
|
||||||
|
analyze: async (prop) => { calls.analyze.push(prop); return analyzeResult; },
|
||||||
|
ledger: {
|
||||||
|
applyRevision: async (sport, rev) => { calls.revisions.push(rev); return { revised: true }; },
|
||||||
|
captureClosing: async () => { calls.closing += 1; return { updated: 0 }; },
|
||||||
|
},
|
||||||
|
pushTickerItems: async (events) => { calls.ticker.push(...events); },
|
||||||
|
now: () => NOW,
|
||||||
|
};
|
||||||
|
return { deps, store, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRADE = {
|
||||||
|
player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5, direction: 'over',
|
||||||
|
grade: 'A', confidence: 78, gradedAt: { line: 0.5, odds: -115, timestamp: NOW },
|
||||||
|
};
|
||||||
|
const prop = (line) => ({ player: 'Aaron Judge', stat_type: 'home_runs', line, book: 'draftkings' });
|
||||||
|
|
||||||
|
describe('signedDelta — relative to the graded side', () => {
|
||||||
|
test('over: rising line = positive (market chasing the over)', () => {
|
||||||
|
expect(signedDelta('over', 1.5, 2.5)).toBe(1);
|
||||||
|
expect(signedDelta('over', 1.5, 1.0)).toBe(-0.5);
|
||||||
|
});
|
||||||
|
test('under: falling line = positive', () => {
|
||||||
|
expect(signedDelta('under', 1.5, 1.0)).toBe(0.5);
|
||||||
|
expect(signedDelta('under', 1.5, 2.5)).toBe(-1);
|
||||||
|
});
|
||||||
|
test('missing values → null, never a fabricated zero', () => {
|
||||||
|
expect(signedDelta('over', null, 2)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runIntradayRefresh — directional handling', () => {
|
||||||
|
test('moved WITH the grade → STEAM, NO re-grade', async () => {
|
||||||
|
const { deps, store, calls } = harness({ grades: [GRADE], oddsProps: [prop(1.5)], analyzeResult: null });
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res).toMatchObject({ status: 'ok', steam: 1, value: 0, revised: 0 });
|
||||||
|
expect(calls.analyze).toHaveLength(0); // steam never re-grades
|
||||||
|
const snap = store.get('snapshot:mlb:latest');
|
||||||
|
expect(snap.grades[0].movement).toMatchObject({ kind: 'steam', delta: 1, currentLine: 1.5 });
|
||||||
|
expect(calls.ticker.length).toBeGreaterThan(0); // ≥1.0 move → ticker MOVE
|
||||||
|
expect(calls.ticker[0].text).toContain('o0.5 → o1.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moved AGAINST ≥1.0, re-grade HOLDS → VALUE (better entry)', async () => {
|
||||||
|
const { deps, store, calls } = harness({
|
||||||
|
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
|
||||||
|
oddsProps: [prop(1.5)],
|
||||||
|
analyzeResult: { grade: 'A', confidence: 70 }, // same grade at the new line
|
||||||
|
});
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res).toMatchObject({ value: 1, revised: 0 });
|
||||||
|
expect(calls.analyze).toHaveLength(1);
|
||||||
|
expect(calls.analyze[0].line).toBe(1.5); // re-graded at the REAL current line
|
||||||
|
expect(store.get('snapshot:mlb:latest').grades[0].movement.kind).toBe('value');
|
||||||
|
expect(calls.revisions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moved AGAINST ≥1.0, grade DROPS → PUBLIC revision (original preserved)', async () => {
|
||||||
|
const { deps, store, calls } = harness({
|
||||||
|
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
|
||||||
|
oddsProps: [prop(1.5)],
|
||||||
|
analyzeResult: { grade: 'C', confidence: 40 },
|
||||||
|
});
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res).toMatchObject({ revised: 1 });
|
||||||
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
|
expect(g.grade).toBe('C');
|
||||||
|
expect(g.revised_from_grade).toBe('A'); // the original, forever
|
||||||
|
expect(calls.revisions[0]).toMatchObject({ newGrade: 'C', fromGrade: 'A', line: 2.5, side: 'over' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second drop keeps the ORIGINAL revised_from_grade', async () => {
|
||||||
|
const already = { ...GRADE, grade: 'B', revised_from_grade: 'A', gradedAt: { line: 2.5, odds: null, timestamp: NOW }, line: 2.5 };
|
||||||
|
const { deps, store } = harness({ grades: [already], oddsProps: [prop(1.5)], analyzeResult: { grade: 'C' } });
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(store.get('snapshot:mlb:latest').grades[0].revised_from_grade).toBe('A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an insufficient re-read HOLDS the original grade (never blanks it)', async () => {
|
||||||
|
const { deps, store } = harness({
|
||||||
|
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: null, timestamp: NOW }, line: 2.5 }],
|
||||||
|
oddsProps: [prop(1.5)],
|
||||||
|
analyzeResult: { grade: null, insufficient_data: true },
|
||||||
|
});
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res.value).toBe(1);
|
||||||
|
expect(store.get('snapshot:mlb:latest').grades[0].grade).toBe('A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prop gone from the feed → frozen untouched; close recaptured every run', async () => {
|
||||||
|
const { deps, store, calls } = harness({ grades: [GRADE], oddsProps: [prop(0.5)], analyzeResult: null });
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res.checked).toBe(1);
|
||||||
|
expect(store.get('snapshot:mlb:latest').grades[0].movement).toBeNull(); // <0.5 noise
|
||||||
|
expect(calls.closing).toBe(1); // refresh-fidelity closing capture
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no snapshot → honest skip, zero quota spent', async () => {
|
||||||
|
const calls = { odds: 0 };
|
||||||
|
const res = await runIntradayRefresh('mlb', {
|
||||||
|
cacheGet: async () => null,
|
||||||
|
cacheSet: async () => {},
|
||||||
|
getOdds: async () => { calls.odds += 1; return { props: [] }; },
|
||||||
|
ledger: {}, pushTickerItems: async () => {}, now: () => NOW,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe('skipped');
|
||||||
|
expect(calls.odds).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inSlateHours — noon–midnight ET', () => {
|
||||||
|
test('3 PM ET yes, 4 AM ET no', () => {
|
||||||
|
expect(inSlateHours(new Date('2026-07-11T19:00:00Z'))).toBe(true); // 3 PM EDT
|
||||||
|
expect(inSlateHours(new Date('2026-07-11T08:00:00Z'))).toBe(false); // 4 AM EDT
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -233,3 +233,41 @@ describe('getModelAggregate — never a % under min sample', () => {
|
|||||||
expect(agg.beat_close_pct).toBe(65); // 13 beat / 20 with clv
|
expect(agg.beat_close_pct).toBe(65); // 13 beat / 20 with clv
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session 60 (night2/F, 5.5) — calibration by grade tier.
|
||||||
|
describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => {
|
||||||
|
test('a tier at 20+ settles gets a pct; a small tier stays building (null)', async () => {
|
||||||
|
const sb = fakeSb();
|
||||||
|
const rows = [
|
||||||
|
...Array.from({ length: 14 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
|
||||||
|
...Array.from({ length: 6 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
|
||||||
|
...Array.from({ length: 3 }, () => ({ outcome: 'hit', clv_result: null, grade: 'B' })),
|
||||||
|
];
|
||||||
|
sb._state.selectResults = [rows];
|
||||||
|
sb._state.countResult = 0;
|
||||||
|
const agg = await ledger.getModelAggregate({ sb });
|
||||||
|
expect(agg.by_tier.A.settled).toBe(20); // A + A- bucket together
|
||||||
|
expect(agg.by_tier.A.hit_pct).toBe(70); // 14/(14+6)
|
||||||
|
expect(agg.by_tier.B.settled).toBe(3);
|
||||||
|
expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session 61 — the odds index prefers a fully-priced book row (the null
|
||||||
|
// locked_odds rows from day one: first-seen betmgm SB unders had no juice
|
||||||
|
// while another book priced both sides).
|
||||||
|
describe('indexProps — prefers a book row with both sides priced', () => {
|
||||||
|
const { indexProps } = ledger.__internals;
|
||||||
|
test('a later both-sided row replaces a one-sided first row', () => {
|
||||||
|
const oneSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'betmgm', over_odds: 120, under_odds: null };
|
||||||
|
const bothSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'fanduel', over_odds: 130, under_odds: -170 };
|
||||||
|
const idx = indexProps([oneSided, bothSided]);
|
||||||
|
expect(idx['a guy|stolen_bases'].book).toBe('fanduel');
|
||||||
|
});
|
||||||
|
test('a both-sided first row is never displaced', () => {
|
||||||
|
const bothSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'fanduel', over_odds: -110, under_odds: -110 };
|
||||||
|
const oneSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'betmgm', over_odds: -115, under_odds: null };
|
||||||
|
const idx = indexProps([bothSided, oneSided]);
|
||||||
|
expect(idx['a guy|hits'].book).toBe('fanduel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -176,3 +176,26 @@ describe('searchPlayer — canonical nameKey resolution', () => {
|
|||||||
expect(hit).toBeNull();
|
expect(hit).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session 60 (night2/E, audit 4.1) — the scan box fuzzy matcher.
|
||||||
|
describe('matchPlayers — fuzzy search (pure)', () => {
|
||||||
|
const LIST = [
|
||||||
|
{ id: 1, fullName: 'Shohei Ohtani', currentTeam: { name: 'Los Angeles Dodgers' }, primaryPosition: { abbreviation: 'DH' } },
|
||||||
|
{ id: 2, fullName: 'Aaron Judge', currentTeam: { name: 'New York Yankees' }, primaryPosition: { abbreviation: 'RF' } },
|
||||||
|
{ id: 3, fullName: 'Cristopher Sanchez', currentTeam: { name: 'Philadelphia Phillies' }, primaryPosition: { abbreviation: 'P' } },
|
||||||
|
{ id: 4, fullName: 'Jazz Chisholm Jr.', currentTeam: { name: 'New York Yankees' }, primaryPosition: { abbreviation: '3B' } },
|
||||||
|
];
|
||||||
|
const { matchPlayers } = adapter;
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['ohtani', 1], ['Aaron Judge', 2], ['Sánchez', 3], ['sanchez', 3], ['Chisholm Jr', 4], ['jazz chisholm', 4],
|
||||||
|
])('"%s" resolves', (q, id) => {
|
||||||
|
const hits = matchPlayers(LIST, q);
|
||||||
|
expect(hits.length).toBeGreaterThan(0);
|
||||||
|
expect(hits[0].id).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sub-2-char garbage resolves nothing', () => {
|
||||||
|
expect(matchPlayers(LIST, '')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Session 60 (night2/B) — the VYNDR Lens + form heat. THE RULE: no raw
|
||||||
|
// streak renders alone; every row carries built-vs, tonight's matchup,
|
||||||
|
// difficulty, and a one-line read — all real or absent, never invented.
|
||||||
|
|
||||||
|
const { buildLens, applyLens, tonightFor, opposingPitcherFor, __internals } = require('../../src/services/streakLens');
|
||||||
|
const { computeFormHeat } = require('../../src/services/streaksService');
|
||||||
|
|
||||||
|
const SCHEDULE = [
|
||||||
|
{
|
||||||
|
homeTeam: { name: 'Detroit Tigers', abbreviation: 'DET' },
|
||||||
|
awayTeam: { name: 'Tampa Bay Rays', abbreviation: 'TB' },
|
||||||
|
gameTime: '2026-07-11T23:10:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const PITCHERS = [
|
||||||
|
{
|
||||||
|
home: { team: 'Detroit Tigers', pitcher: 'Tarik Skubal', era: 2.41 },
|
||||||
|
away: { team: 'Tampa Bay Rays', pitcher: 'Ryan Pepiot', era: 3.9 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('streakLens — tonight resolution', () => {
|
||||||
|
test('finds the opponent + home/away for a team by mascot token', () => {
|
||||||
|
const t = tonightFor('Tampa Bay Rays', SCHEDULE);
|
||||||
|
expect(t.opponent).toBe('Detroit Tigers');
|
||||||
|
expect(t.isHome).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves the OPPOSING probable SP (not the player-team SP)', () => {
|
||||||
|
const sp = opposingPitcherFor('Tampa Bay Rays', PITCHERS);
|
||||||
|
expect(sp.pitcher).toBe('Tarik Skubal');
|
||||||
|
expect(sp.era).toBe(2.41);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('streakLens — the one-line read', () => {
|
||||||
|
const ROW = {
|
||||||
|
sport: 'mlb', player: 'Brandon Lowe', team: 'Tampa Bay Rays',
|
||||||
|
type: 'hit_streak', category: 'hits', currentStreak: 7,
|
||||||
|
description: '7-game hit streak', opponents: ['Oakland Athletics', 'Los Angeles Angels'],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('composes streak + built-vs + tonight + difficulty', () => {
|
||||||
|
const lens = buildLens(ROW, { scheduleGames: SCHEDULE, pitcherGames: PITCHERS });
|
||||||
|
expect(lens.builtVs).toEqual(['Oakland Athletics', 'Los Angeles Angels']);
|
||||||
|
expect(lens.matchup).toBe('@ Detroit Tigers · SP Tarik Skubal (2.41 ERA)');
|
||||||
|
expect(lens.difficulty).toBe('step up'); // 2.41 ERA ≤ 3.4
|
||||||
|
expect(lens.read).toContain('7-game hit streak');
|
||||||
|
expect(lens.read).toContain('built vs Oakland Athletics');
|
||||||
|
expect(lens.read).toContain('a step up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says LESS when context is missing — never invents', () => {
|
||||||
|
const lens = buildLens({ ...ROW, opponents: [] }, {});
|
||||||
|
expect(lens.builtVs).toBeNull();
|
||||||
|
expect(lens.matchup).toBeNull();
|
||||||
|
expect(lens.difficulty).toBeNull();
|
||||||
|
expect(lens.read).toBe('7-game hit streak');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pitcher streaks do not get an opposing-SP difficulty read', () => {
|
||||||
|
const lens = buildLens(
|
||||||
|
{ ...ROW, type: 'k_streak', description: '3-game 7+ K streak' },
|
||||||
|
{ scheduleGames: SCHEDULE, pitcherGames: PITCHERS },
|
||||||
|
);
|
||||||
|
expect(lens.matchup).toBe('@ Detroit Tigers'); // no SP line for pitchers
|
||||||
|
expect(lens.difficulty).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ERA difficulty bands', () => {
|
||||||
|
expect(__internals.eraDifficulty(2.9)).toBe('step up');
|
||||||
|
expect(__internals.eraDifficulty(4.0)).toBe('neutral');
|
||||||
|
expect(__internals.eraDifficulty(5.2)).toBe('step down');
|
||||||
|
expect(__internals.eraDifficulty(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyLens attaches a lens to every row', () => {
|
||||||
|
const rows = applyLens([ROW, { ...ROW, player: 'X' }], { scheduleGames: SCHEDULE, pitcherGames: PITCHERS });
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows.every((r) => r.lens && r.lens.read)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeFormHeat — hot hitters / sluggers (correct rate math)', () => {
|
||||||
|
const day = (n) => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
// 7-day window: 12-for-24 (.500); season .270 → hot hitter.
|
||||||
|
const hotGames = [
|
||||||
|
{ date: day(1), opponent: 'Boston Red Sox', hits: 3, atBats: 4, totalBases: 7 },
|
||||||
|
{ date: day(2), opponent: 'Boston Red Sox', hits: 2, atBats: 4, totalBases: 5 },
|
||||||
|
{ date: day(3), opponent: 'New York Yankees', hits: 2, atBats: 4, totalBases: 2 },
|
||||||
|
{ date: day(4), opponent: 'New York Yankees', hits: 2, atBats: 4, totalBases: 6 },
|
||||||
|
{ date: day(5), opponent: 'New York Yankees', hits: 1, atBats: 4, totalBases: 1 },
|
||||||
|
{ date: day(6), opponent: 'Baltimore Orioles', hits: 2, atBats: 4, totalBases: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('flags a hot hitter vs his SEASON average with the honest line', () => {
|
||||||
|
const rows = computeFormHeat(
|
||||||
|
[{ name: 'Hot Guy', team: 'Tampa Bay Rays', games: hotGames, seasonRaw: { avg: '0.270', slg: '0.420' } }],
|
||||||
|
'mlb',
|
||||||
|
);
|
||||||
|
const hitter = rows.find((r) => r.type === 'hot_hitter');
|
||||||
|
expect(hitter).toBeTruthy();
|
||||||
|
expect(hitter.rate).toBe(0.5); // 12/24, Σ/Σ not mean-of-rates
|
||||||
|
expect(hitter.description).toContain('.500 over the last 7 days');
|
||||||
|
expect(hitter.description).toContain('season .270');
|
||||||
|
const slugger = rows.find((r) => r.type === 'hot_slugger');
|
||||||
|
expect(slugger).toBeTruthy(); // 25 TB / 24 AB ≈ 1.042 SLG vs .420
|
||||||
|
expect(slugger.opponents).toContain('Boston Red Sox');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('small samples are refused (minAtt), not extrapolated', () => {
|
||||||
|
const rows = computeFormHeat(
|
||||||
|
[{ name: 'Two AB Guy', games: [{ date: day(1), hits: 2, atBats: 2 }], seasonRaw: { avg: '0.250' } }],
|
||||||
|
'mlb',
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no season baseline + no prior games → no claim', () => {
|
||||||
|
const rows = computeFormHeat(
|
||||||
|
[{ name: 'No Baseline', games: hotGames, seasonRaw: null }],
|
||||||
|
'mlb',
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([]); // all games in-window, nothing to trend against
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,3 +91,48 @@ describe('QA.18 — auth gate integration', () => {
|
|||||||
expect(routes.isGatedRoute('/dashboard')).toBe(false); // free funnel stays open
|
expect(routes.isGatedRoute('/dashboard')).toBe(false); // free funnel stays open
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Session 60 (night2/H) — §2 design-pass locks for tonight's surfaces ─────
|
||||||
|
// One meaning per color: green = edge/favorable, amber = caution/market
|
||||||
|
// signal, red = miss/error. Data is mono. Glow stays on A-tier/chrome.
|
||||||
|
|
||||||
|
describe('QA.20 — movement + status color semantics (Phase 2.5 surfaces)', () => {
|
||||||
|
it('STEAM is amber (caution: entry compressed), VALUE is green (edge)', () => {
|
||||||
|
const strip = read('components/vyndr/StatStrip.tsx');
|
||||||
|
expect(strip).toMatch(/steam \? 'var\(--amber/);
|
||||||
|
expect(strip).toMatch(/: 'var\(--g-a/);
|
||||||
|
});
|
||||||
|
it('SYNC goes red ONLY at the STALE tier (error meaning), amber at 1.5x', () => {
|
||||||
|
const live = read('components/vyndr/LiveLayer.tsx');
|
||||||
|
expect(live).toContain("tier === 'stale' ? 'var(--miss)'");
|
||||||
|
expect(live).toContain("tier === 'amber' ? 'var(--amber)'");
|
||||||
|
});
|
||||||
|
it('a revision renders the ORIGINAL grade struck through (public, never silent)', () => {
|
||||||
|
expect(read('components/vyndr/StatStrip.tsx')).toContain("textDecoration: 'line-through'");
|
||||||
|
expect(read('app/ledger/page.tsx')).toContain("textDecoration: 'line-through'");
|
||||||
|
});
|
||||||
|
it('lens difficulty: tougher = amber, softer = green — never inverted', () => {
|
||||||
|
const panel = read('components/StreaksPanel.tsx');
|
||||||
|
expect(panel).toMatch(/step up' \? 'var\(--amber[^']*' : [^:]*step down' \? 'var\(--g-a/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QA.21 — new data surfaces are mono', () => {
|
||||||
|
it.each([
|
||||||
|
'components/vyndr/PriorReads.tsx',
|
||||||
|
'components/vyndr/PlayerStreaks.tsx',
|
||||||
|
'components/vyndr/ModelRecord.tsx',
|
||||||
|
])('%s renders its data rows in mono', (rel) => {
|
||||||
|
expect(read(rel)).toContain('className="mono"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QA.22 — honest waiting copy everywhere (no passive mystery)', () => {
|
||||||
|
it.each([
|
||||||
|
'components/vyndr/StatStrip.tsx',
|
||||||
|
'app/dashboard/page.tsx',
|
||||||
|
'components/ExploreHub.tsx',
|
||||||
|
])('%s derives its waiting state from the real schedule', (rel) => {
|
||||||
|
expect(read(rel)).toContain('nextRunLabelET');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,16 +20,19 @@ export async function GET(req: NextRequest) {
|
|||||||
if (q.length < 2) return NextResponse.json({ players: [] });
|
if (q.length < 2) return NextResponse.json({ players: [] });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// NBA/WNBA use the nba_api wrapper service; MLB falls back to the main backend.
|
// Session 60 (night2/E, audit 4.1) — Express is the canonical resolver
|
||||||
const url =
|
// for EVERY sport now (nameKey fuzzy match; MLB = full statsapi list,
|
||||||
sport === 'MLB'
|
// others = the platform's cached names). The Python NBA service is a
|
||||||
? `${BACKEND_URL}/api/players/search?sport=MLB&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`
|
// best-effort second try only when the canonical index has nothing.
|
||||||
: `${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`;
|
const url = `${BACKEND_URL}/api/players/search?sport=${encodeURIComponent(sport)}&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`;
|
||||||
|
|
||||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
let res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
if (!res.ok) return NextResponse.json({ players: [] });
|
let data = res.ok ? await res.json().catch(() => ({})) : {};
|
||||||
|
const canonical = Array.isArray((data as { players?: unknown[] }).players) ? (data as { players: unknown[] }).players : [];
|
||||||
const data = await res.json().catch(() => ({}));
|
if (canonical.length === 0 && sport !== 'MLB') {
|
||||||
|
res = await fetch(`${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' } }).catch(() => new Response(null, { status: 502 }));
|
||||||
|
data = res.ok ? await res.json().catch(() => ({})) : {};
|
||||||
|
}
|
||||||
const rawPlayers: unknown[] = Array.isArray((data as { results?: unknown[] }).results)
|
const rawPlayers: unknown[] = Array.isArray((data as { results?: unknown[] }).results)
|
||||||
? (data as { results: unknown[] }).results
|
? (data as { results: unknown[] }).results
|
||||||
: Array.isArray((data as { players?: unknown[] }).players)
|
: Array.isArray((data as { players?: unknown[] }).players)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
/** Scan-meter proxy (Session 60, night2/F) — forwards the caller's auth to
|
||||||
|
* Express, which reads the live limiter's rolling-24h usage. */
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const auth = req.headers.get('authorization');
|
||||||
|
if (!auth) return NextResponse.json({ unlimited: false, used: null, limit: null }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${BACKEND_URL}/api/user/scan-meter`, {
|
||||||
|
headers: { Accept: 'application/json', Authorization: auth },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await upstream.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ unlimited: false, used: null, limit: null }, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-122
@@ -1,128 +1,17 @@
|
|||||||
'use client';
|
import ExploreHub from '@/components/ExploreHub';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import SportBadge from '@/components/vyndr/SportBadge';
|
|
||||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
|
||||||
import { playerHref } from '@/lib/playerHref';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stats Explorer (/explore) — Session 42, design section 07. The data hub:
|
* /explore — the aggregator hub (Session 60, night2/C). Server shell for SEO:
|
||||||
* tonight's league leaderboard (top graded props by confidence) with grade +
|
* daily-regenerating indexable surface (streaks, hot lists, leaders). The
|
||||||
* archetype context. Consumes the real /api/stats/leaders endpoint. Sub-panels
|
* interactive hub is the client component.
|
||||||
* (hit-rate trends, head-to-head, market-vs-VYNDR) need historical data and are
|
|
||||||
* a Session-43 follow-up — see BUILD-STATE.
|
|
||||||
*/
|
*/
|
||||||
|
export const metadata = {
|
||||||
interface Leader {
|
title: 'MLB Hit Streaks, Hot Hitters & Stat Leaders — VYNDR Explore',
|
||||||
player: string;
|
description:
|
||||||
team: string;
|
'Live player streaks (hit streaks, on-base streaks, HR streaks), 7-day hot hitters and hot sluggers, and tonight\'s graded stat leaders — every number with matchup context. MLB + WNBA, updated daily.',
|
||||||
stat: string;
|
keywords: ['MLB hit streaks', 'hottest hitters this week', 'WNBA player streaks', 'player prop stats', 'hot hitters MLB'],
|
||||||
line: number | string;
|
};
|
||||||
side: string;
|
|
||||||
grade: string;
|
|
||||||
confidence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPORTS = [
|
|
||||||
{ key: 'nba', label: 'NBA' },
|
|
||||||
{ key: 'mlb', label: 'MLB' },
|
|
||||||
{ key: 'wnba', label: 'WNBA' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ExplorePage() {
|
export default function ExplorePage() {
|
||||||
const [sport, setSport] = useState('nba');
|
return <ExploreHub />;
|
||||||
const [leaders, setLeaders] = useState<Leader[]>([]);
|
|
||||||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
setState('loading');
|
|
||||||
fetch(`/api/stats/leaders?sport=${sport}&limit=25`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => { if (active) { setLeaders(Array.isArray(d.leaders) ? d.leaders : []); setState('ready'); } })
|
|
||||||
.catch(() => { if (active) setState('error'); });
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [sport]);
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
|
|
||||||
}, [leaders, query]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section style={{ maxWidth: 920, margin: '0 auto', padding: '24px 16px 120px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 14, marginBottom: 22, flexWrap: 'wrap' }}>
|
|
||||||
<div>
|
|
||||||
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>STATS · /EXPLORE</div>
|
|
||||||
<h1 style={{ margin: '0 0 8px', fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Stats Explorer</h1>
|
|
||||||
<p style={{ margin: 0, maxWidth: 560, fontSize: 14, lineHeight: 1.6, color: 'var(--text-1)' }}>
|
|
||||||
Tonight's league leaderboard — every graded prop, ranked by VYNDR confidence, with the grade and archetype context only VYNDR has.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 4, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 8, padding: 4 }}>
|
|
||||||
{SPORTS.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s.key}
|
|
||||||
onClick={() => setSport(s.key)}
|
|
||||||
className="mono"
|
|
||||||
style={{ cursor: 'pointer', border: 'none', borderRadius: 5, padding: '7px 14px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: sport === s.key ? '#06060B' : 'var(--text-1)', background: sport === s.key ? 'var(--g-a)' : 'transparent' }}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FILTER BAR */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap', marginBottom: 14, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '11px 14px' }}>
|
|
||||||
<span className="mono" style={{ fontSize: 14, color: 'var(--text-2)' }}>⌕</span>
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search players"
|
|
||||||
style={{ appearance: 'none', background: 'transparent', border: 'none', outline: 'none', fontFamily: 'var(--sans)', fontSize: 13, color: '#fff', flex: 1, minWidth: 140 }}
|
|
||||||
/>
|
|
||||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>{rows.length} graded</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* LEADERBOARD */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 13 }}>
|
|
||||||
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
|
|
||||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--text-1)' }}>LEAGUE LEADERBOARD</span>
|
|
||||||
<SportBadge sport={sport} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
|
||||||
<div className="mono game-lines-grid" style={{ display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid #14141E', fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.08em' }}>
|
|
||||||
<div>#</div><div>PLAYER</div><div style={{ textAlign: 'right' }}>PROP</div><div style={{ textAlign: 'right' }}>CONF</div><div style={{ textAlign: 'center' }}>GRD</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state === 'loading' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>Loading tonight's slate…</div>}
|
|
||||||
{state === 'error' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--miss)' }}>Could not load the leaderboard. Try again.</div>}
|
|
||||||
{state === 'ready' && rows.length === 0 && (
|
|
||||||
<div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>
|
|
||||||
No graded props for {sport.toUpperCase()} yet — check back when tonight's slate posts.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{state === 'ready' && rows.map((r, i) => (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={playerHref(r.player, sport)}
|
|
||||||
className="game-lines-grid"
|
|
||||||
style={{ textDecoration: 'none', color: 'inherit', display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '13px 16px', borderBottom: '1px solid #14141E' }}
|
|
||||||
>
|
|
||||||
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: i < 3 ? 'var(--g-a)' : 'var(--text-1)' }}>{i + 1}</div>
|
|
||||||
<div style={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{ fontWeight: 700, fontSize: 13, color: '#fff', whiteSpace: 'nowrap' }}>{r.player}</span>
|
|
||||||
{r.team && <span className="mono" style={{ fontSize: 10, color: 'var(--text-1)' }}>{r.team}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{ textAlign: 'right', fontSize: 12, color: '#C8CCD6' }}>{r.stat} {r.side}{r.line}</div>
|
|
||||||
<div className="mono" style={{ textAlign: 'right', fontSize: 13, fontWeight: 600, color: 'var(--text-0)' }}>{r.confidence != null ? `${r.confidence}%` : '—'}</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}><GradeBadge grade={r.grade} size="sm" /></div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1293,3 +1293,33 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
|
|||||||
.gl-full:not(.gl-expanded) { display: none !important; }
|
.gl-full:not(.gl-expanded) { display: none !important; }
|
||||||
.gl-full.gl-expanded { margin-top: 10px; }
|
.gl-full.gl-expanded { margin-top: 10px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Session 60 (night2/E) — §7 reveal choreography.
|
||||||
|
Stamp slam + 90ms staggered context panels. Entrance keyframes floor at
|
||||||
|
a VISIBLE state (Phase-0 rule: a paused frame is never invisible), and
|
||||||
|
reduced-motion kills the choreography entirely (ProcessingGrade also
|
||||||
|
skips straight to the card).
|
||||||
|
============================================================ */
|
||||||
|
@keyframes stamp-in {
|
||||||
|
0% { opacity: .5; transform: rotate(-7deg) scale(1.7); }
|
||||||
|
60% { opacity: 1; transform: rotate(-7deg) scale(0.96); }
|
||||||
|
100% { opacity: 1; transform: rotate(-7deg) scale(1); }
|
||||||
|
}
|
||||||
|
.stamp-in { animation: stamp-in .34s cubic-bezier(.2,1.6,.4,1) both; }
|
||||||
|
|
||||||
|
@keyframes panel-in {
|
||||||
|
from { opacity: .55; transform: translateY(7px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.grade-reveal > div > * { animation: panel-in .3s ease-out both; }
|
||||||
|
.grade-reveal > div > *:nth-child(2) { animation-delay: 90ms; }
|
||||||
|
.grade-reveal > div > *:nth-child(3) { animation-delay: 180ms; }
|
||||||
|
.grade-reveal > div > *:nth-child(4) { animation-delay: 270ms; }
|
||||||
|
.grade-reveal > div > *:nth-child(5) { animation-delay: 360ms; }
|
||||||
|
.grade-reveal > div > *:nth-child(6) { animation-delay: 450ms; }
|
||||||
|
.grade-reveal > div > *:nth-child(7) { animation-delay: 540ms; }
|
||||||
|
.grade-reveal > div > *:nth-child(n+8) { animation-delay: 630ms; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.stamp-in, .grade-reveal > div > * { animation: none !important; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface LedgerRow {
|
|||||||
revised_from_grade?: string | null;
|
revised_from_grade?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
|
||||||
interface ModelAggregate {
|
interface ModelAggregate {
|
||||||
settled: number;
|
settled: number;
|
||||||
hits: number;
|
hits: number;
|
||||||
@@ -55,6 +56,8 @@ interface ModelAggregate {
|
|||||||
beat_close_pct: number | null;
|
beat_close_pct: number | null;
|
||||||
pending: number;
|
pending: number;
|
||||||
min_sample?: number;
|
min_sample?: number;
|
||||||
|
// Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier).
|
||||||
|
by_tier?: Record<string, TierRecord>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPORT_COLOR: Record<string, string> = {
|
const SPORT_COLOR: Record<string, string> = {
|
||||||
@@ -160,6 +163,30 @@ export default function LedgerPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F'];
|
||||||
|
|
||||||
|
/** Session 60 (5.5) — per-tier calibration: the separation between tiers is
|
||||||
|
* the proof the grades mean something. A tier under n≥20 shows "building",
|
||||||
|
* never a small-sample percentage. Self-hides until ANY tier is ready. */
|
||||||
|
function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
|
||||||
|
const tiers = agg.by_tier || {};
|
||||||
|
const anyReady = TIER_ORDER.some((t) => tiers[t] && tiers[t].hit_pct != null);
|
||||||
|
if (!anyReady) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||||
|
{TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => {
|
||||||
|
const b = tiers[t];
|
||||||
|
const ready = b.hit_pct != null;
|
||||||
|
return (
|
||||||
|
<span key={t} className="mono" style={{ fontSize: 11.5, fontWeight: 700, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-hi)', color: ready ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
|
||||||
|
{t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
|
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
|
||||||
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
||||||
return (
|
return (
|
||||||
@@ -200,6 +227,7 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<TierCalibration agg={agg} minSample={minSample} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-5
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import Hero from '@/components/Hero';
|
import Hero from '@/components/Hero';
|
||||||
@@ -25,17 +25,35 @@ import Pricing from '@/components/Pricing';
|
|||||||
import FAQ from '@/components/FAQ';
|
import FAQ from '@/components/FAQ';
|
||||||
// Footer is mounted globally in the root layout (Session 34) — no per-page import.
|
// Footer is mounted globally in the root layout (Session 34) — no per-page import.
|
||||||
|
|
||||||
|
// Session 60 (6.1) — the 3–4s "LOADING THE SLATE" first paint was THIS
|
||||||
|
// page blocking its entire render on Supabase auth initialization
|
||||||
|
// (`loading || user`), even for anonymous visitors who were never going to
|
||||||
|
// redirect. Synchronous localStorage check instead: only a visitor who
|
||||||
|
// actually HAS a stored session (and will redirect to /dashboard) sees the
|
||||||
|
// suppressed render; anonymous traffic paints the hero immediately.
|
||||||
|
function hasStoredSession(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < window.localStorage.length; i += 1) {
|
||||||
|
const k = window.localStorage.key(i) || '';
|
||||||
|
if (/^sb-.*-auth-token$/.test(k) || k === 'sb-token') return true;
|
||||||
|
}
|
||||||
|
} catch { /* storage blocked → treat as anonymous */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [maybeSignedIn] = useState<boolean>(() => hasStoredSession());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && user) router.replace('/dashboard');
|
if (!loading && user) router.replace('/dashboard');
|
||||||
}, [user, loading, router]);
|
}, [user, loading, router]);
|
||||||
|
|
||||||
// While we know the user is signed in we suppress the marketing
|
// Suppress the marketing render ONLY for visitors with a stored session
|
||||||
// render to avoid a flicker before the redirect lands.
|
// (they're about to redirect) — anonymous first paint is instant.
|
||||||
if (loading || user) {
|
if (user || (loading && maybeSignedIn)) {
|
||||||
return (
|
return (
|
||||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<p className="mono" style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
<p className="mono" style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||||
@@ -63,7 +81,10 @@ export default function Home() {
|
|||||||
<TonightsSlate />
|
<TonightsSlate />
|
||||||
<LivePropsStrip />
|
<LivePropsStrip />
|
||||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '0 16px' }}>
|
<div style={{ maxWidth: 960, margin: '0 auto', padding: '0 16px' }}>
|
||||||
<StreaksPanel sport="nba" tier="free" limit={3} />
|
{/* Session 60 (night2/C) — the free hook: top-3 REAL streaks through
|
||||||
|
the lens. MLB is the in-season board (was nba — off-season = the
|
||||||
|
panel self-hid and the teaser never showed). */}
|
||||||
|
<StreaksPanel sport="mlb" tier="free" limit={3} />
|
||||||
<HotListPanel sport="mlb" tier="free" limit={3} />
|
<HotListPanel sport="mlb" tier="free" limit={3} />
|
||||||
</div>
|
</div>
|
||||||
<Features />
|
<Features />
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session 60 (night2/G, 6.3) — server metadata for the player dossier.
|
||||||
|
* The page itself stays a client component; this layout carries the
|
||||||
|
* per-player title/description, and opengraph-image.tsx (same segment)
|
||||||
|
* renders the shareable intelligence card.
|
||||||
|
*/
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
|
||||||
|
const { name } = await params;
|
||||||
|
const player = decodeURIComponent(name || '').slice(0, 60);
|
||||||
|
const title = `${player} — Player Intelligence | VYNDR`;
|
||||||
|
const description = `${player}: archetype DNA, active streaks, prop DNA, last-10 log, and VYNDR's settled record — every number with matchup context.`;
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: { title, description },
|
||||||
|
twitter: { card: 'summary_large_image', title, description },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ImageResponse } from 'next/og';
|
||||||
|
|
||||||
|
// Session 60 (night2/G, 6.3) — every shared player link unfurls as an
|
||||||
|
// intelligence card. Self-hosted standalone build → Node runtime (NOT edge;
|
||||||
|
// Session-53 rule — next/og breaks under edge off Vercel).
|
||||||
|
export const alt = 'VYNDR Player Intelligence';
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = 'image/png';
|
||||||
|
|
||||||
|
export default async function Image({ params }: { params: Promise<{ name: string }> }) {
|
||||||
|
const { name } = await params;
|
||||||
|
const player = decodeURIComponent(name || '').slice(0, 40) || 'Player';
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: '#06060B',
|
||||||
|
color: '#E8E8F0',
|
||||||
|
padding: 72,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
|
||||||
|
PLAYER INTELLIGENCE
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', fontSize: 84, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 18, color: '#FFFFFF' }}>
|
||||||
|
{player}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', fontSize: 28, color: '#7A7A8E', marginTop: 20 }}>
|
||||||
|
Archetype DNA · Active streaks · Prop DNA · Settled record
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', fontSize: 44, fontWeight: 900, letterSpacing: '0.08em' }}>
|
||||||
|
<span style={{ color: '#FFFFFF' }}>VYND</span>
|
||||||
|
<span style={{ color: '#00D4A0' }}>R</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', fontSize: 22, color: '#4A4A5E', letterSpacing: '0.12em' }}>
|
||||||
|
EVERY NUMBER WITH CONTEXT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ ...size },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import GradeBadge from '@/components/vyndr/GradeBadge';
|
|||||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||||
|
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
|
||||||
|
import { archetypeInfo } from '@/lib/archetypes';
|
||||||
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||||
|
|
||||||
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
||||||
@@ -104,6 +106,15 @@ export default function PlayerProfilePage() {
|
|||||||
<div style={{ marginTop: 15 }}>
|
<div style={{ marginTop: 15 }}>
|
||||||
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
|
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
|
||||||
<ArchetypeBlend blend={p.archetype.blend} size="md" showLegend caption={blendReadout(p.archetype)} />
|
<ArchetypeBlend blend={p.archetype.blend} size="md" showLegend caption={blendReadout(p.archetype)} />
|
||||||
|
{/* Session 60 (5.1, spec amendment) — define the term on-page:
|
||||||
|
one line from the archetype library under the dual bar.
|
||||||
|
The "What does this mean?" expander keeps the long version. */}
|
||||||
|
{p.archetype.primary && archetypeInfo(p.archetype.primary)?.d && (
|
||||||
|
<div className="mono" style={{ marginTop: 8, fontSize: 11.5, color: 'var(--text-1)', lineHeight: 1.5 }}>
|
||||||
|
<span style={{ fontWeight: 800, color: 'var(--text-0)' }}>{String(p.archetype.primary).toUpperCase()}</span>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}> — </span>{archetypeInfo(p.archetype.primary).d}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,6 +145,10 @@ export default function PlayerProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Session 60 (night2/C) — active streaks join the dossier (FREE layer,
|
||||||
|
through the lens). Self-hides when the player has none. */}
|
||||||
|
<PlayerStreaks player={p.player} sport={p.sport} />
|
||||||
|
|
||||||
{/* B. PROP DNA */}
|
{/* B. PROP DNA */}
|
||||||
{dna.length > 0 && (
|
{dna.length > 0 && (
|
||||||
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
|
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
|
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
|
||||||
|
import PriorReads from '@/components/vyndr/PriorReads';
|
||||||
import { AccuracyBadge } from '@/components/vyndr';
|
import { AccuracyBadge } from '@/components/vyndr';
|
||||||
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
|
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
|
||||||
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
|
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
|
||||||
@@ -762,6 +763,10 @@ export default function ScanPage() {
|
|||||||
onReadAnother={reset}
|
onReadAnother={reset}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Session 60 (4.3) — the model's public history on this player.
|
||||||
|
Deferred-render: shows only when real ledger rows exist. */}
|
||||||
|
<PriorReads player={selectedPlayer} stat={stat} />
|
||||||
|
|
||||||
{/* Session 55 — the self-learning loop's track record for this sport.
|
{/* Session 55 — the self-learning loop's track record for this sport.
|
||||||
"The system learns" — real hit rate on graded props, misses shown. */}
|
"The system learns" — real hit rate on graded props, misses shown. */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ export default function SettingsPage() {
|
|||||||
const [deleteText, setDeleteText] = useState('');
|
const [deleteText, setDeleteText] = useState('');
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [delError, setDelError] = useState('');
|
const [delError, setDelError] = useState('');
|
||||||
|
// Session 60 (5.4) — the daily read meter (metered tiers only).
|
||||||
|
const [meter, setMeter] = useState<{ unlimited: boolean; used: number | null; limit: number | null; remaining?: number | null } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const token = session?.access_token;
|
||||||
|
if (!token) return;
|
||||||
|
let active = true;
|
||||||
|
fetch('/api/user/scan-meter', { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => { if (active && data && typeof data.unlimited === 'boolean') setMeter(data); })
|
||||||
|
.catch(() => { /* meter hides itself */ });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
// Session 49 — onboarding preferences (load + edit + save).
|
// Session 49 — onboarding preferences (load + edit + save).
|
||||||
const [prefSports, setPrefSports] = useState<string[]>([]);
|
const [prefSports, setPrefSports] = useState<string[]>([]);
|
||||||
@@ -162,10 +174,31 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* SUBSCRIPTION */}
|
{/* SUBSCRIPTION */}
|
||||||
<Section label="SUBSCRIPTION">
|
<Section label="SUBSCRIPTION">
|
||||||
|
{/* Session 60 (5.4) — the daily read meter for METERED tiers, from
|
||||||
|
the live limiter's rolling-24h window (never disagrees with the
|
||||||
|
gate). Unlimited tiers hide it. */}
|
||||||
|
{meter && !meter.unlimited && meter.limit != null && (
|
||||||
|
<>
|
||||||
|
<Row>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Reads today</div>
|
||||||
|
<div style={{ marginTop: 8, height: 6, background: 'var(--bg-3)', borderRadius: 3, overflow: 'hidden', maxWidth: 260 }}>
|
||||||
|
<div style={{ width: `${Math.min(100, Math.round(((meter.used ?? 0) / meter.limit) * 100))}%`, height: '100%', background: (meter.remaining ?? 1) <= 0 ? 'var(--miss)' : 'var(--g-a)', borderRadius: 3 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: (meter.remaining ?? 1) <= 0 ? 'var(--miss)' : 'var(--text-0)' }}>
|
||||||
|
{meter.used ?? 0} of {meter.limit}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Row>
|
<Row>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#fff' }}>VYNDR {plan.label === 'FREE' ? 'Free' : plan.label === 'DESK' ? 'Desk' : 'Analyst'}</div>
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#fff' }}>VYNDR {plan.label === 'FREE' ? 'Free' : plan.label === 'DESK' ? 'Desk' : 'Analyst'}</div>
|
||||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>{plan.label === 'FREE' ? '5 scans / month' : 'Active subscription'}</div>
|
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
|
||||||
|
{meter && !meter.unlimited && meter.limit != null ? `${meter.limit} reads / day` : plan.label === 'FREE' ? '3 reads / day' : 'Active subscription'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 9 }}>
|
<div style={{ display: 'flex', gap: 9 }}>
|
||||||
{plan.label !== 'FREE' && (
|
{plan.label !== 'FREE' && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import SportBadge from '@/components/vyndr/SportBadge';
|
import SportBadge from '@/components/vyndr/SportBadge';
|
||||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
|
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||||
import { playerHref } from '@/lib/playerHref';
|
import { playerHref } from '@/lib/playerHref';
|
||||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||||
|
|
||||||
@@ -112,6 +113,12 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }
|
|||||||
{data.record && <span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>{data.record.wins}-{data.record.losses}</span>}
|
{data.record && <span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>{data.record.wins}-{data.record.losses}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Session 60 (5.2, migration 020) — VYNDR-on-team: the model's settled
|
||||||
|
record on THIS team's players. Deferred-render until rows exist. */}
|
||||||
|
<div style={{ margin: '0 0 16px' }}>
|
||||||
|
<ModelRecord team={data.team.name} sport={data.team.sport} align="left" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{data.note && <p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 16 }}>{data.note}</p>}
|
{data.note && <p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 16 }}>{data.note}</p>}
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import SportBadge from '@/components/vyndr/SportBadge';
|
||||||
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
|
import { playerHref } from '@/lib/playerHref';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExploreHub (Session 42 leaders; Session 60 night2/C — THE AGGREGATOR).
|
||||||
|
* The "entire picture" page: leaders + full streaks + hot lists, one sport
|
||||||
|
* selector, everything free and interpreted through the lens. The page shell
|
||||||
|
* (app/explore/page.tsx) is a server component carrying the SEO metadata.
|
||||||
|
*/
|
||||||
|
import StreaksPanel from '@/components/StreaksPanel';
|
||||||
|
import HotListPanel from '@/components/HotListPanel';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||||
|
|
||||||
|
interface Leader {
|
||||||
|
player: string;
|
||||||
|
team: string;
|
||||||
|
stat: string;
|
||||||
|
line: number | string;
|
||||||
|
side: string;
|
||||||
|
grade: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPORTS = [
|
||||||
|
{ key: 'nba', label: 'NBA' },
|
||||||
|
{ key: 'mlb', label: 'MLB' },
|
||||||
|
{ key: 'wnba', label: 'WNBA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ExploreHub() {
|
||||||
|
const { tier } = useAuth();
|
||||||
|
const [sport, setSport] = useState('mlb');
|
||||||
|
const [leaders, setLeaders] = useState<Leader[]>([]);
|
||||||
|
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setState('loading');
|
||||||
|
fetch(`/api/stats/leaders?sport=${sport}&limit=25`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => { if (active) { setLeaders(Array.isArray(d.leaders) ? d.leaders : []); setState('ready'); } })
|
||||||
|
.catch(() => { if (active) setState('error'); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [sport]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
|
||||||
|
}, [leaders, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 920, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 14, marginBottom: 22, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>STATS · /EXPLORE</div>
|
||||||
|
<h1 style={{ margin: '0 0 8px', fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Stats Explorer</h1>
|
||||||
|
<p style={{ margin: 0, maxWidth: 560, fontSize: 14, lineHeight: 1.6, color: 'var(--text-1)' }}>
|
||||||
|
Tonight's league leaderboard — every graded prop, ranked by VYNDR confidence, with the grade and archetype context only VYNDR has.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 8, padding: 4 }}>
|
||||||
|
{SPORTS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => setSport(s.key)}
|
||||||
|
className="mono"
|
||||||
|
style={{ cursor: 'pointer', border: 'none', borderRadius: 5, padding: '7px 14px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: sport === s.key ? '#06060B' : 'var(--text-1)', background: sport === s.key ? 'var(--g-a)' : 'transparent' }}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FILTER BAR */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap', marginBottom: 14, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '11px 14px' }}>
|
||||||
|
<span className="mono" style={{ fontSize: 14, color: 'var(--text-2)' }}>⌕</span>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search players"
|
||||||
|
style={{ appearance: 'none', background: 'transparent', border: 'none', outline: 'none', fontFamily: 'var(--sans)', fontSize: 13, color: '#fff', flex: 1, minWidth: 140 }}
|
||||||
|
/>
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>{rows.length} graded</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LEADERBOARD */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 13 }}>
|
||||||
|
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--text-1)' }}>LEAGUE LEADERBOARD</span>
|
||||||
|
<SportBadge sport={sport} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<div className="mono game-lines-grid" style={{ display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid #14141E', fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.08em' }}>
|
||||||
|
<div>#</div><div>PLAYER</div><div style={{ textAlign: 'right' }}>PROP</div><div style={{ textAlign: 'right' }}>CONF</div><div style={{ textAlign: 'center' }}>GRD</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state === 'loading' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>Loading tonight's slate…</div>}
|
||||||
|
{state === 'error' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--miss)' }}>Could not load the leaderboard. Try again.</div>}
|
||||||
|
{state === 'ready' && rows.length === 0 && (
|
||||||
|
<div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>
|
||||||
|
No graded props for {sport.toUpperCase()} yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state === 'ready' && rows.map((r, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={playerHref(r.player, sport)}
|
||||||
|
className="game-lines-grid"
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit', display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '13px 16px', borderBottom: '1px solid #14141E' }}
|
||||||
|
>
|
||||||
|
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: i < 3 ? 'var(--g-a)' : 'var(--text-1)' }}>{i + 1}</div>
|
||||||
|
<div style={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 13, color: '#fff', whiteSpace: 'nowrap' }}>{r.player}</span>
|
||||||
|
{r.team && <span className="mono" style={{ fontSize: 10, color: 'var(--text-1)' }}>{r.team}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ textAlign: 'right', fontSize: 12, color: '#C8CCD6' }}>{r.stat} {r.side}{r.line}</div>
|
||||||
|
<div className="mono" style={{ textAlign: 'right', fontSize: 13, fontWeight: 600, color: 'var(--text-0)' }}>{r.confidence != null ? `${r.confidence}%` : '—'}</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}><GradeBadge grade={r.grade} size="sm" /></div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session 60 (night2/C) — THE AGGREGATOR. Full streaks + hot lists
|
||||||
|
for the selected sport, every row through the lens. The picture is
|
||||||
|
free; the grade is the paid layer. Panels self-hide when cold. */}
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<StreaksPanel sport={sport} tier={tier} stat="all" />
|
||||||
|
<HotListPanel sport={sport} tier={tier} stat="all" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ const PRIMARY = [
|
|||||||
];
|
];
|
||||||
const MORE = [
|
const MORE = [
|
||||||
{ label: 'Explore', href: '/explore' },
|
{ label: 'Explore', href: '/explore' },
|
||||||
|
// Session 60 (4.4) — discoverable Parlay Lab entry (opens the drawer).
|
||||||
|
{ label: 'Parlay Lab', href: '#parlay', action: 'parlay' as const },
|
||||||
{ label: 'Compare', href: '/compare' },
|
{ label: 'Compare', href: '/compare' },
|
||||||
{ label: 'Tracker', href: '/tracker' },
|
{ label: 'Tracker', href: '/tracker' },
|
||||||
{ label: 'The Report', href: '/blog' },
|
{ label: 'The Report', href: '/blog' },
|
||||||
@@ -153,7 +155,15 @@ export default function Nav() {
|
|||||||
key={l.href}
|
key={l.href}
|
||||||
href={l.href}
|
href={l.href}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setMoreOpen(false)}
|
onClick={(e) => {
|
||||||
|
if ('action' in l && l.action === 'parlay') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof window !== 'undefined' && (window as Window & { __openParlay?: () => void }).__openParlay) {
|
||||||
|
(window as Window & { __openParlay?: () => void }).__openParlay!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMoreOpen(false);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
padding: '9px 10px',
|
padding: '9px 10px',
|
||||||
|
|||||||
@@ -176,7 +176,12 @@ interface PitcherResponse { games?: PitcherGame[] }
|
|||||||
type GradeIndex = ReturnType<typeof indexGrades>;
|
type GradeIndex = ReturnType<typeof indexGrades>;
|
||||||
type DeltaIndex = ReturnType<typeof indexDeltas>;
|
type DeltaIndex = ReturnType<typeof indexDeltas>;
|
||||||
type PitcherMap = ReturnType<typeof buildPitcherMap>;
|
type PitcherMap = ReturnType<typeof buildPitcherMap>;
|
||||||
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap): GameCardData {
|
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all'): GameCardData {
|
||||||
|
// Session 60 (night2/C) — ONE stat selection filters every layer: props on
|
||||||
|
// the cards narrow together with the streaks + hot-list panels below.
|
||||||
|
const props = statFilter && statFilter !== 'all'
|
||||||
|
? g.props.filter((p) => String(p.stat_type || '').toLowerCase() === statFilter)
|
||||||
|
: g.props;
|
||||||
return {
|
return {
|
||||||
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
|
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
|
||||||
sport: g.sport,
|
sport: g.sport,
|
||||||
@@ -189,7 +194,7 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
|
|||||||
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
|
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
|
||||||
// Session 59 (work-order 1.6) — pass the game's participants so the join
|
// Session 59 (work-order 1.6) — pass the game's participants so the join
|
||||||
// guard can drop bad feed rows (a player whose real team isn't in this game).
|
// guard can drop bad feed rows (a player whose real team isn't in this game).
|
||||||
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }),
|
playerStrips: buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }),
|
||||||
// Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
|
// Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
|
||||||
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
|
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
|
||||||
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
|
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
|
||||||
@@ -854,7 +859,7 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
|||||||
{filteredGames.map((g, i) => (
|
{filteredGames.map((g, i) => (
|
||||||
<VyndrGameCard
|
<VyndrGameCard
|
||||||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
||||||
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap)}
|
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat)}
|
||||||
preferredBooks={preferredBooks}
|
preferredBooks={preferredBooks}
|
||||||
onOpen={() => router.push('/scan')}
|
onOpen={() => router.push('/scan')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
|
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
|
||||||
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
|
import { type Tier } from '@/lib/tierGate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StreaksPanel (Session 23).
|
* StreaksPanel (Session 23).
|
||||||
@@ -25,6 +25,9 @@ interface Streak {
|
|||||||
category: string;
|
category: string;
|
||||||
currentStreak: number;
|
currentStreak: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
// Session 60 (night2/C) — THE LENS + optional snapshot grade letter.
|
||||||
|
lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null };
|
||||||
|
grade?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreaksPanelProps {
|
export interface StreaksPanelProps {
|
||||||
@@ -56,10 +59,13 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
|
|||||||
|
|
||||||
if (!streaks || streaks.length === 0) return null;
|
if (!streaks || streaks.length === 0) return null;
|
||||||
|
|
||||||
const tierCount = getVisibleCount(tier, streaks.length);
|
// Session 60 (product def) — the PICTURE is free: every streak shows for
|
||||||
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
|
// every tier. Only an explicit `limit` (landing teaser) caps the list.
|
||||||
const visible = streaks.slice(0, cap);
|
// The paid layer is the GRADE, not the data. tierGate still governs the
|
||||||
const hidden = limit ? streaks.length - visible.length : getHiddenCount(tier, streaks.length);
|
// hot LISTS (top-3 free) — not streaks.
|
||||||
|
void tier;
|
||||||
|
const visible = limit && limit > 0 ? streaks.slice(0, limit) : streaks;
|
||||||
|
const hidden = streaks.length - visible.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="streaks-panel" style={{ margin: '16px 0' }}>
|
<section className="streaks-panel" style={{ margin: '16px 0' }}>
|
||||||
@@ -76,16 +82,27 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
|
|||||||
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={playerName}>{s.player}{s.team ? ` · ${s.team}` : ''}</div>
|
<div style={playerName}>
|
||||||
<div style={streakDesc}>{s.description}</div>
|
{s.player}{s.team ? ` · ${s.team}` : ''}
|
||||||
|
{/* Grade letter when the snapshot graded this player (the paid
|
||||||
|
READ layer; the letter itself is public on the slate). */}
|
||||||
|
{s.grade && <span className="mono" style={{ marginLeft: 8, fontSize: 10.5, fontWeight: 800, color: 'var(--g-a, #00D4A0)', border: '1px solid rgba(0,212,160,.35)', borderRadius: 4, padding: '1px 5px' }}>{s.grade}</span>}
|
||||||
|
</div>
|
||||||
|
{/* THE LENS — the interpreted read, never the raw streak alone. */}
|
||||||
|
<div style={streakDesc}>{s.lens?.read || s.description}</div>
|
||||||
|
{s.lens?.difficulty && (
|
||||||
|
<div className="mono" style={{ fontSize: 10.5, marginTop: 2, color: s.lens.difficulty === 'step up' ? 'var(--amber, #FFB347)' : s.lens.difficulty === 'step down' ? 'var(--g-a, #00D4A0)' : 'var(--text-tertiary, #6A6A78)' }}>
|
||||||
|
{s.lens.difficulty === 'step up' ? '▲ TOUGHER SPOT TONIGHT' : s.lens.difficulty === 'step down' ? '▼ SOFTER SPOT TONIGHT' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span style={badgeStyle}>{s.currentStreak} G</span>
|
<span style={badgeStyle}>{s.currentStreak} G</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<a href="/pricing" style={upsellStyle}>
|
<a href="/explore" style={upsellStyle}>
|
||||||
{hidden} more streak{hidden === 1 ? '' : 's'} — upgrade to see all →
|
{hidden} more streak{hidden === 1 ? '' : 's'} on the board →
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface SnapGrade {
|
|||||||
grade?: string;
|
grade?: string;
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
archetype?: string | null;
|
archetype?: string | null;
|
||||||
|
// Session 60 (6.1) — settled outcome (yesterday's-proof fallback rows).
|
||||||
|
outcome?: { result: string; actual?: number | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPORTS = ['mlb', 'nba', 'wnba'] as const;
|
const SPORTS = ['mlb', 'nba', 'wnba'] as const;
|
||||||
@@ -38,6 +40,7 @@ const isTop = (g?: string) => g === 'A+' || g === 'A';
|
|||||||
|
|
||||||
export default function TopSignals() {
|
export default function TopSignals() {
|
||||||
const [signals, setSignals] = useState<SnapGrade[] | null>(null);
|
const [signals, setSignals] = useState<SnapGrade[] | null>(null);
|
||||||
|
const [header, setHeader] = useState<'signals' | 'board' | 'proof'>('board');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -52,12 +55,28 @@ export default function TopSignals() {
|
|||||||
);
|
);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const all: SnapGrade[] = [];
|
const all: SnapGrade[] = [];
|
||||||
|
const settled: SnapGrade[] = [];
|
||||||
for (const res of results) {
|
for (const res of results) {
|
||||||
const grades = res && Array.isArray(res.grades) ? res.grades : [];
|
const grades = res && Array.isArray(res.grades) ? res.grades : [];
|
||||||
for (const g of grades) if (isTop(g.grade)) all.push(g);
|
for (const g of grades) {
|
||||||
|
if (g.outcome) settled.push(g);
|
||||||
|
else if (g.grade) all.push(g);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
all.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
|
all.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
|
||||||
|
// Session 60 (6.1) — never oversell: top 3 by grade whatever they
|
||||||
|
// are, but the TOP SIGNALS header only with ≥1 A-tier; otherwise
|
||||||
|
// "TONIGHT'S BOARD". Nothing graded yet → yesterday's settled reads
|
||||||
|
// WITH outcome chips (the proof strip carries the slate).
|
||||||
|
if (all.length > 0) {
|
||||||
setSignals(all.slice(0, 3));
|
setSignals(all.slice(0, 3));
|
||||||
|
setHeader(all.some((g) => isTop(g.grade)) ? 'signals' : 'board');
|
||||||
|
} else if (settled.length > 0) {
|
||||||
|
setSignals(settled.slice(0, 3));
|
||||||
|
setHeader('proof');
|
||||||
|
} else {
|
||||||
|
setSignals([]);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (active) setSignals([]);
|
if (active) setSignals([]);
|
||||||
}
|
}
|
||||||
@@ -67,16 +86,19 @@ export default function TopSignals() {
|
|||||||
return () => { active = false; clearInterval(id); };
|
return () => { active = false; clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Self-hide off-hours (nothing graded A yet) — never an empty shell.
|
// Self-hide only when there's NOTHING real (no grades and no settles).
|
||||||
if (!signals || signals.length === 0) return null;
|
if (!signals || signals.length === 0) return null;
|
||||||
|
const headerText = header === 'signals' ? "TONIGHT'S TOP SIGNALS"
|
||||||
|
: header === 'proof' ? "YESTERDAY'S SETTLED READS" : "TONIGHT'S BOARD";
|
||||||
|
const headerSub = header === 'proof' ? '· MISSES INCLUDED' : '· LIVE FROM THE SLATE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ maxWidth: 960, margin: '0 auto', padding: '8px 16px 24px' }}>
|
<section style={{ maxWidth: 960, margin: '0 auto', padding: '8px 16px 24px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, letterSpacing: '0.1em', color: 'var(--text-secondary, #8A8A9A)' }}>
|
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, letterSpacing: '0.1em', color: 'var(--text-secondary, #8A8A9A)' }}>
|
||||||
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--grade-a, #00D4A0)', display: 'inline-block' }} />
|
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--grade-a, #00D4A0)', display: 'inline-block' }} />
|
||||||
<span style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>TONIGHT'S TOP SIGNALS</span>
|
<span style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>{headerText}</span>
|
||||||
<span style={{ color: 'var(--text-tertiary, #707080)' }}>· LIVE FROM THE SLATE</span>
|
<span style={{ color: 'var(--text-tertiary, #707080)' }}>{headerSub}</span>
|
||||||
</div>
|
</div>
|
||||||
<AccuracyBadge variant="inline" />
|
<AccuracyBadge variant="inline" />
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +126,13 @@ export default function TopSignals() {
|
|||||||
<div style={{ fontWeight: 700, fontSize: 14, color: '#fff', fontFamily: 'var(--sans, sans-serif)', marginBottom: 4 }}>{player}</div>
|
<div style={{ fontWeight: 700, fontSize: 14, color: '#fff', fontFamily: 'var(--sans, sans-serif)', marginBottom: 4 }}>{player}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-secondary, #B8BCC8)' }}>
|
<div style={{ fontSize: 12, color: 'var(--text-secondary, #B8BCC8)' }}>
|
||||||
{shortStat(g.stat_type || g.stat)} {side}{g.line}
|
{shortStat(g.stat_type || g.stat)} {side}{g.line}
|
||||||
|
{/* Outcome chip on the proof-strip fallback — misses included. */}
|
||||||
|
{g.outcome && (
|
||||||
|
<span style={{ marginLeft: 8, fontWeight: 700, color: g.outcome.result === 'hit' ? 'var(--grade-a, #00D4A0)' : g.outcome.result === 'miss' ? 'var(--miss, #FF5252)' : 'var(--text-tertiary)' }}>
|
||||||
|
{g.outcome.result === 'hit' ? '✓ HIT' : g.outcome.result === 'miss' ? '✕ MISS' : '– PUSH'}
|
||||||
|
{g.outcome.actual != null ? ` (${g.outcome.actual})` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ interface Aggregate {
|
|||||||
export default function ModelRecord({
|
export default function ModelRecord({
|
||||||
sport,
|
sport,
|
||||||
player,
|
player,
|
||||||
|
team,
|
||||||
align = 'center',
|
align = 'center',
|
||||||
}: {
|
}: {
|
||||||
sport?: string;
|
sport?: string;
|
||||||
player?: string;
|
player?: string;
|
||||||
|
/** VYNDR-on-team (5.2) — exact team name as the feed writes it. */
|
||||||
|
team?: string;
|
||||||
align?: 'center' | 'left';
|
align?: 'center' | 'left';
|
||||||
}) {
|
}) {
|
||||||
const [agg, setAgg] = useState<Aggregate | null>(null);
|
const [agg, setAgg] = useState<Aggregate | null>(null);
|
||||||
@@ -41,6 +44,7 @@ export default function ModelRecord({
|
|||||||
const params = new URLSearchParams({ limit: '1' });
|
const params = new URLSearchParams({ limit: '1' });
|
||||||
if (sport) params.set('sport', sport.toLowerCase());
|
if (sport) params.set('sport', sport.toLowerCase());
|
||||||
if (player) params.set('player', player);
|
if (player) params.set('player', player);
|
||||||
|
if (team) params.set('team', team);
|
||||||
fetch(`/api/ledger/model?${params}`)
|
fetch(`/api/ledger/model?${params}`)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -50,7 +54,7 @@ export default function ModelRecord({
|
|||||||
})
|
})
|
||||||
.catch(() => { /* self-hide */ });
|
.catch(() => { /* self-hide */ });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [sport, player]);
|
}, [sport, player, team]);
|
||||||
|
|
||||||
if (!agg) return null;
|
if (!agg) return null;
|
||||||
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useParlay } from '@/contexts/ParlayContext';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
|
// 4.4 — the ONE shared stat-label formatter (lib/gradeAdapter, unit-tested).
|
||||||
|
import { statLabel } from '@/lib/gradeAdapter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ParlayPanel (Session 50) — the Parlay Lab. A bottom slide-up that shows the
|
* ParlayPanel (Session 50) — the Parlay Lab. A bottom slide-up that shows the
|
||||||
@@ -20,17 +22,25 @@ function tierMaxLegs(tier: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ParlayPanel() {
|
export default function ParlayPanel() {
|
||||||
const { legs, isOpen, toggle, close, removeLeg, clear, combined, correlation, payout, grading, maxLegs, setMaxLegs } = useParlay();
|
const { legs, isOpen, toggle, close, open, removeLeg, clear, combined, correlation, payout, grading, maxLegs, setMaxLegs } = useParlay();
|
||||||
const { tier } = useAuth();
|
const { tier } = useAuth();
|
||||||
const fullLab = tier === 'desk' || tier === 'analyst';
|
const fullLab = tier === 'desk' || tier === 'analyst';
|
||||||
|
|
||||||
// Sync the leg cap to the user's tier.
|
// Sync the leg cap to the user's tier.
|
||||||
useEffect(() => { setMaxLegs(tierMaxLegs(tier || 'free')); }, [tier, setMaxLegs]);
|
useEffect(() => { setMaxLegs(tierMaxLegs(tier || 'free')); }, [tier, setMaxLegs]);
|
||||||
|
|
||||||
if (legs.length === 0) return null;
|
// Session 60 (night2/E, 4.4) — a discoverable entry point: the Nav's
|
||||||
|
// PARLAY LAB item opens the drawer even with zero legs.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
(window as Window & { __openParlay?: () => void }).__openParlay = open;
|
||||||
|
return () => { delete (window as Window & { __openParlay?: () => void }).__openParlay; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (legs.length === 0 && !isOpen) return null;
|
||||||
|
|
||||||
// Floating trigger when closed.
|
// Floating trigger when closed.
|
||||||
if (!isOpen) {
|
if (!isOpen && legs.length > 0) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -72,7 +82,8 @@ export default function ParlayPanel() {
|
|||||||
<div key={l.id} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '8px 10px', background: 'var(--bg-2)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
<div key={l.id} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '8px 10px', background: 'var(--bg-2)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||||
{l.archetype && <ArchetypeBadge archetype={l.archetype} size="sm" variant="full" />}
|
{l.archetype && <ArchetypeBadge archetype={l.archetype} size="sm" variant="full" />}
|
||||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#fff', whiteSpace: 'nowrap' }}>{l.player}</span>
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#fff', whiteSpace: 'nowrap' }}>{l.player}</span>
|
||||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{l.stat} {l.direction === 'under' ? 'U' : 'O'}{l.line}</span>
|
{/* 4.4 — humanized stat labels via the ONE shared formatter. */}
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{statLabel(l.stat)} {l.direction === 'under' ? 'U' : 'O'}{l.line}</span>
|
||||||
<GradeBadge grade={l.grade} size="sm" />
|
<GradeBadge grade={l.grade} size="sm" />
|
||||||
<button type="button" onClick={() => removeLeg(l.id)} aria-label="Remove leg" className="mono" style={{ marginLeft: 'auto', background: 'transparent', border: 'none', color: 'var(--miss)', cursor: 'pointer', fontSize: 14 }}>✕</button>
|
<button type="button" onClick={() => removeLeg(l.id)} aria-label="Remove leg" className="mono" style={{ marginLeft: 'auto', background: 'transparent', border: 'none', color: 'var(--miss)', cursor: 'pointer', fontSize: 14 }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,8 +131,17 @@ export default function ParlayPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : legs.length === 1 ? (
|
||||||
|
// 4.4 — the 1-leg provisional read instead of a dead panel.
|
||||||
|
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 9, fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>
|
||||||
|
<span>Leg grade:</span>
|
||||||
|
<GradeBadge grade={legs[0].grade} size="sm" />
|
||||||
|
<span>— add a leg for the combined read.</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>Add another leg to see the combined grade.</div>
|
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>
|
||||||
|
No legs yet — tap <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>+</span> on any graded prop to start building.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="button" onClick={clear} className="mono"
|
<button type="button" onClick={clear} className="mono"
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayerStreaks (Session 60, night2/C) — a player's ACTIVE streaks + form
|
||||||
|
* heat on the dossier page. FREE layer (the picture), rendered through the
|
||||||
|
* lens (the interpreted read), beside the paid intelligence blocks.
|
||||||
|
* Self-hides when the player has no active streaks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface StreakRow {
|
||||||
|
type: string;
|
||||||
|
currentStreak: number;
|
||||||
|
description: string;
|
||||||
|
lens?: { read?: string | null; difficulty?: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerStreaks({ player, sport }: { player: string; sport: string }) {
|
||||||
|
const [rows, setRows] = useState<StreakRow[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
fetch(`/api/streaks/${encodeURIComponent(sport)}?player=${encodeURIComponent(player)}`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => { if (active && data) setRows(Array.isArray(data.streaks) ? data.streaks : []); })
|
||||||
|
.catch(() => { /* self-hide */ });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [player, sport]);
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginBottom: 14 }}>
|
||||||
|
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: '#ff8b7a', marginBottom: 10 }}>
|
||||||
|
🔥 ACTIVE STREAKS
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{rows.slice(0, 5).map((s) => (
|
||||||
|
<div key={s.type} style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||||
|
<span className="mono" style={{ flexShrink: 0, fontSize: 12, fontWeight: 800, color: '#ffb0a4' }}>{s.currentStreak}G</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12.5, color: 'var(--text-0)' }}>{s.lens?.read || s.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PriorReads (Session 60, night2/E — work-order 4.3, spec §7).
|
||||||
|
* "PRIOR READS: [chips]" under a scan result — the model's own public
|
||||||
|
* history on this player from ledger_entries. Deferred-render: nothing
|
||||||
|
* shows until real rows exist; settled rows carry their outcome, pending
|
||||||
|
* rows say pending. Never invented.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PriorRow {
|
||||||
|
id: string;
|
||||||
|
stat: string;
|
||||||
|
line: number;
|
||||||
|
side: string;
|
||||||
|
grade: string;
|
||||||
|
game_date: string;
|
||||||
|
outcome?: 'hit' | 'miss' | 'push' | null;
|
||||||
|
actual_value?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PriorReads({ player, stat }: { player: string; stat?: string }) {
|
||||||
|
const [rows, setRows] = useState<PriorRow[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const params = new URLSearchParams({ player, limit: '8' });
|
||||||
|
fetch(`/api/ledger/model?${params}`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => { if (active && data) setRows(Array.isArray(data.entries) ? data.entries : []); })
|
||||||
|
.catch(() => { /* self-hide */ });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [player]);
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) return null;
|
||||||
|
// Same-stat reads first — the most relevant history for this scan.
|
||||||
|
const sorted = stat
|
||||||
|
? [...rows].sort((a, b) => Number(b.stat === stat.toLowerCase()) - Number(a.stat === stat.toLowerCase()))
|
||||||
|
: rows;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '4px 4px 0' }}>
|
||||||
|
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-2)', marginBottom: 8 }}>
|
||||||
|
PRIOR READS · {rows.length}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{sorted.slice(0, 6).map((r) => {
|
||||||
|
const color = r.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
|
||||||
|
: r.outcome === 'miss' ? 'var(--miss, #FF5252)'
|
||||||
|
: r.outcome === 'push' ? 'var(--text-1)' : 'var(--text-2)';
|
||||||
|
const mark = r.outcome === 'hit' ? '✓' : r.outcome === 'miss' ? '✕' : r.outcome === 'push' ? '–' : '…';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={r.id}
|
||||||
|
className="mono"
|
||||||
|
title={`${r.game_date} · graded ${r.grade}${r.actual_value != null ? ` · actual ${r.actual_value}` : ''}`}
|
||||||
|
style={{ fontSize: 10.5, fontWeight: 700, padding: '3px 8px', borderRadius: 5, border: `1px solid ${color}`, color, letterSpacing: '0.03em' }}
|
||||||
|
>
|
||||||
|
{mark} {String(r.side).toUpperCase() === 'UNDER' ? 'U' : 'O'}{r.line} {r.stat.replace(/_/g, ' ')} · {r.grade}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,24 +36,33 @@ interface ProcessingGradeProps {
|
|||||||
* then the GradeResultCard reveals. Pure chrome — the underlying grade is fixed.
|
* then the GradeResultCard reveals. Pure chrome — the underlying grade is fixed.
|
||||||
*/
|
*/
|
||||||
export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToParlay, onReadAnother }: ProcessingGradeProps) {
|
export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToParlay, onReadAnother }: ProcessingGradeProps) {
|
||||||
const [proc, setProc] = useState(true);
|
// Session 60 (night2/E, spec §7) — full reveal choreography:
|
||||||
|
// analyzing steps → DECLASSIFIED stamp → card with 90ms-staggered panels.
|
||||||
|
// Reduced-motion users skip straight to the card (chrome, never data).
|
||||||
|
const [phase, setPhase] = useState<'proc' | 'stamp' | 'card'>('proc');
|
||||||
const [lit, setLit] = useState(0);
|
const [lit, setLit] = useState(0);
|
||||||
|
|
||||||
const factors = (data.signals || []).slice(0, 5);
|
const factors = (data.signals || []).slice(0, 5);
|
||||||
const total = factors.length || 1;
|
const total = factors.length || 1;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProc(true);
|
if (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
setPhase('card');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhase('proc');
|
||||||
setLit(0);
|
setLit(0);
|
||||||
const stepMs = 190;
|
const stepMs = 190;
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
for (let i = 1; i <= total; i++) timers.push(setTimeout(() => setLit(i), stepMs * i));
|
for (let i = 1; i <= total; i++) timers.push(setTimeout(() => setLit(i), stepMs * i));
|
||||||
timers.push(setTimeout(() => setProc(false), stepMs * total + 360));
|
timers.push(setTimeout(() => setPhase('stamp'), stepMs * total + 360));
|
||||||
|
timers.push(setTimeout(() => setPhase('card'), stepMs * total + 360 + 700));
|
||||||
return () => timers.forEach(clearTimeout);
|
return () => timers.forEach(clearTimeout);
|
||||||
}, [replayKey, total]);
|
}, [replayKey, total]);
|
||||||
|
|
||||||
if (!proc) {
|
if (phase === 'card') {
|
||||||
return (
|
return (
|
||||||
|
<div className="grade-reveal">
|
||||||
<GradeResultCard
|
<GradeResultCard
|
||||||
data={data}
|
data={data}
|
||||||
replayKey={replayKey}
|
replayKey={replayKey}
|
||||||
@@ -61,6 +70,7 @@ export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToP
|
|||||||
onAddToParlay={onAddToParlay}
|
onAddToParlay={onAddToParlay}
|
||||||
onReadAnother={onReadAnother}
|
onReadAnother={onReadAnother}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +81,17 @@ export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToP
|
|||||||
style={{ width: '100%', maxWidth: 640, margin: '0 auto', borderRadius: 12, overflow: 'hidden', border: '1px solid rgba(0,255,184,.25)', minHeight: 360, position: 'relative', boxShadow: '0 0 0 1px rgba(0,212,160,.18), 0 24px 70px -28px rgba(0,212,160,.35)' }}
|
style={{ width: '100%', maxWidth: 640, margin: '0 auto', borderRadius: 12, overflow: 'hidden', border: '1px solid rgba(0,255,184,.25)', minHeight: 360, position: 'relative', boxShadow: '0 0 0 1px rgba(0,212,160,.18), 0 24px 70px -28px rgba(0,212,160,.35)' }}
|
||||||
aria-label="Processing grade"
|
aria-label="Processing grade"
|
||||||
>
|
>
|
||||||
|
{/* §7 — the DECLASSIFIED stamp lands between analysis and the card. */}
|
||||||
|
{phase === 'stamp' && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, zIndex: 5, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(4,20,15,.55)' }}>
|
||||||
|
<span
|
||||||
|
className="mono stamp-in"
|
||||||
|
style={{ fontSize: 30, fontWeight: 900, letterSpacing: '0.22em', color: 'var(--g-ap)', border: '3px solid var(--g-ap)', borderRadius: 6, padding: '10px 26px', transform: 'rotate(-7deg)', textShadow: '0 0 18px rgba(0,255,184,.7)', boxShadow: '0 0 30px rgba(0,255,184,.35), inset 0 0 22px rgba(0,255,184,.12)' }}
|
||||||
|
>
|
||||||
|
DECLASSIFIED
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="proc-scan" style={{ position: 'absolute', left: 0, right: 0, top: 0, height: '30%', background: 'linear-gradient(180deg, transparent, rgba(0,255,184,.12) 50%, transparent)', zIndex: 1 }} />
|
<div className="proc-scan" style={{ position: 'absolute', left: 0, right: 0, top: 0, height: '30%', background: 'linear-gradient(180deg, transparent, rgba(0,255,184,.12) 50%, transparent)', zIndex: 1 }} />
|
||||||
<div style={{ position: 'relative', zIndex: 2, padding: '26px 24px' }}>
|
<div style={{ position: 'relative', zIndex: 2, padding: '26px 24px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 22 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 22 }}>
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ export interface StripProp {
|
|||||||
awaiting?: boolean; // not yet graded by a snapshot → "Awaiting next scan"
|
awaiting?: boolean; // not yet graded by a snapshot → "Awaiting next scan"
|
||||||
// Session 55 — settled outcome from the self-learning loop (once the game is final).
|
// Session 55 — settled outcome from the self-learning loop (once the game is final).
|
||||||
outcome?: { result: 'hit' | 'miss' | 'push' | string; actual?: number | null } | null;
|
outcome?: { result: 'hit' | 'miss' | 'push' | string; actual?: number | null } | null;
|
||||||
|
// Session 60 (Phase 2.5) — intraday movement + public revision.
|
||||||
|
movement?: { kind: 'steam' | 'value' | 'against' | 'revised' | string; delta: number; currentLine: number } | null;
|
||||||
|
revisedFrom?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase 2.5 movement chip: STEAM ▲ (market chasing), VALUE ▲ (better
|
||||||
|
* entry, read holds). Revisions render on the grade itself. */
|
||||||
|
function MovementChip({ p }: { p: StripProp }) {
|
||||||
|
const m = p.movement;
|
||||||
|
if (!m || (m.kind !== 'steam' && m.kind !== 'value')) return null;
|
||||||
|
const steam = m.kind === 'steam';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="mono"
|
||||||
|
title={steam ? 'Market moved toward the graded side since lock' : 'Line moved against the grade — re-graded and the read holds: better entry'}
|
||||||
|
style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.06em', padding: '1px 5px', borderRadius: 3,
|
||||||
|
color: steam ? 'var(--amber, #FFB347)' : 'var(--g-a, #00D4A0)',
|
||||||
|
border: `1px solid ${steam ? 'rgba(255,179,71,.4)' : 'rgba(0,212,160,.4)'}` }}
|
||||||
|
>
|
||||||
|
{steam ? `STEAM ▲ ${m.delta > 0 ? '+' : ''}${m.delta}` : 'VALUE ▲ better entry'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
export interface StripArchetype {
|
export interface StripArchetype {
|
||||||
primary: string;
|
primary: string;
|
||||||
@@ -232,7 +254,12 @@ export default function StatStrip({
|
|||||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}>
|
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}>
|
||||||
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
|
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
|
||||||
|
{/* Phase 2.5 — a revised grade is PUBLIC: original struck through. */}
|
||||||
|
{p.revisedFrom && (
|
||||||
|
<span className="mono" title="Grade revised after the line moved against the read — original preserved" style={{ fontSize: 10.5, color: 'var(--text-2)', textDecoration: 'line-through' }}>{p.revisedFrom}</span>
|
||||||
|
)}
|
||||||
{p.grade && <GradeBadge grade={p.grade} size="sm" />}
|
{p.grade && <GradeBadge grade={p.grade} size="sm" />}
|
||||||
|
<MovementChip p={p} />
|
||||||
<OutcomeChip p={p} />
|
<OutcomeChip p={p} />
|
||||||
{!p.outcome && <ParlayBtn p={p} />}
|
{!p.outcome && <ParlayBtn p={p} />}
|
||||||
{!p.outcome && <BookItTeaser p={p} />}
|
{!p.outcome && <BookItTeaser p={p} />}
|
||||||
|
|||||||
@@ -316,6 +316,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
|||||||
// Session 55 — settled outcome from the self-learning loop (hit/miss/push
|
// Session 55 — settled outcome from the self-learning loop (hit/miss/push
|
||||||
// + actual stat) once the game completes. null until settled.
|
// + actual stat) once the game completes. null until settled.
|
||||||
outcome: rec.outcome ? { result: rec.outcome.result, actual: rec.outcome.actual } : null,
|
outcome: rec.outcome ? { result: rec.outcome.result, actual: rec.outcome.actual } : null,
|
||||||
|
// Session 60 (Phase 2.5) — intraday movement (STEAM/VALUE/revised)
|
||||||
|
// + the ORIGINAL grade when a public revision happened.
|
||||||
|
movement: rec.movement || null,
|
||||||
|
revisedFrom: rec.revised_from_grade || null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
byPlayer[pk].props.push({
|
byPlayer[pk].props.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user