Compare commits

..

9 Commits

Author SHA1 Message Date
builtbykev 4d2b27d1d0 Session 61 (pre-merge): live-verification fixes from day-one rows
1. [settlement] armed boot line — settlement WAS scheduled (the tick runs
   settleAllOutcomes + settleAllLedgers FIRST at every snapshot slot,
   src/snapshotScheduler.js) but was invisible at boot. Now verifiable
   from logs forever.
2. Null locked_odds root cause (4 day-one rows, all betmgm SB unders):
   genuinely absent in the DB — the first-seen book row carried no juice
   for that side. indexOdds/indexProps now PREFER a book row with both
   sides priced (still a real row, never synthesized). Display was
   correct: absent beats wrong.
3. Verified retry-safe: 'Odds data temporarily unavailable' is the
   odds-api quota gate (oddsService:423) — thrown BEFORE any spend or
   cache write; runSnapshot errors write nothing; the next cron retries
   fresh with PropLine first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:16:27 -04:00
builtbykev 7a35c96781 Session H (night2): design pass — §2 locks + honest waiting sweep
- QA.20: one meaning per color, locked — STEAM amber (caution), VALUE
  green (edge), red only at STALE/miss; revisions render the original
  grade struck through on slate AND ledger.
- QA.21: every new data surface (PriorReads, PlayerStreaks, ModelRecord)
  renders data in mono (tabular-nums via the .mono class).
- QA.22: waiting copy derives from the real cron schedule on every
  surface (StatStrip, dashboard, Explore) — no passive mystery anywhere.
- Explore empty state joined the honest-waiting lib.
- Sweep result: no raw brand hexes and no sans-serif data in tonight's
  components (sans only on prose/captions/hero letter — sanctioned);
  glow confined to A-tier badges + reveal chrome; 390px containment
  holds (wrapping chips, scroll-contained tables from Phase 3 rules).

Backend 2352 -> 2396 tests (205 suites), web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 02:30:41 -04:00
builtbykev 1b4f2772d6 Session G (night2): Phase 6 — landing first-paint + content engine + OG
6.1 FIRST-PAINT ROOT CAUSE: the landing blocked its ENTIRE render on
    Supabase auth init ('loading || user') — anonymous visitors stared at
    'LOADING THE SLATE' for the whole auth roundtrip (~3-4s). Now a
    synchronous localStorage session check gates the suppression: only
    visitors who actually hold a session (and will redirect) wait;
    anonymous traffic paints the hero immediately. Full RSC conversion of
    the hero is deferred and logged — the blocker itself is dead.
    Proof Strip rules: top-3 by grade whatever they are; 'TONIGHT'S TOP
    SIGNALS' only with >=1 A-tier, else 'TONIGHT'S BOARD'; nothing graded
    yet → yesterday's SETTLED reads with outcome chips (misses included).
6.2 Content routes: /api/content/top-signals/:sport,
    /streak-watch/:sport (the zero-grade daily format off the aggregator),
    /daily-report/:sport — built but self-flagging do_not_post until the
    record clears n>=20. Flag, don't fake.
6.3 Per-player OG images: app/player/[name]/opengraph-image.tsx (Node
    runtime per the S53 rule) + server layout generateMetadata — every
    shared player link unfurls as an intelligence card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 02:26:45 -04:00
builtbykev f110bd63f1 Session F (night2): Phase 5 — records + dossier
5.1 Archetype defined on-page: one line from the archetype library under
    ARCHETYPE DNA (BOMBER — elite raw power…); expander keeps the long form.
5.2 VYNDR-on-team live: getModelAggregate team scope (migration-020 column)
    + /api/ledger/model?team= + ModelRecord mounted on the Team Hub header.
5.3 WNBA/NBA profile parity: minutes-based usage (+MIN season cell) when
    the feed carries minutes — absent beats invented.
5.4 Settings read meter: real rolling-24h usage from the SAME store the
    limiter enforces (GET /api/user/scan-meter + proxy). Metered tiers see
    'X of N reads today' + bar; unlimited tiers see nothing. Corrects the
    stale '5 scans / month' copy.
5.5 Per-tier calibration on the MODEL tab: A+/A/B/C chips with hit% at
    n>=20 PER TIER, 'building (n/20)' below — the separation between tiers
    is the proof the grades mean something.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:05:29 -04:00
builtbykev 5d19660f8e Session E (night2): Phase 4 — scan + parlay polish
4.1 ROOT CAUSE of 'Ohtani returns nothing': no backend
    /api/players/search existed (MLB 404'd; NBA/WNBA hit the offline
    Python service). New Express route + mlbStatsAdapter.matchPlayers —
    canonical nameKey fuzzy match (exact > last-name prefix > folded
    substring). LIVE-VERIFIED vs the real 1,299-player list: Ohtani /
    Aaron Judge / Sánchez / sanchez / Chisholm Jr all resolve; accented
    and unaccented return identical results. Non-MLB matches the
    platform's cached names (rosterlogs + grades), cache-only.
4.2 Reveal choreography per §7: analyzing steps → DECLASSIFIED stamp →
    90ms-staggered context panels (entrance floors visible per the
    Phase-0 rule); prefers-reduced-motion skips straight to the card.
4.3 PRIOR READS chips on scan results — the model's public ledger
    history for the player (deferred-render, outcomes + pending, never
    invented). /api/ledger/model gains ?player= on entries.
4.4 Parlay Lab: humanized stat labels via the ONE shared formatter
    (lib/gradeAdapter.statLabel); 1-leg provisional grade ('Leg grade:
    B — add a leg for the combined read'); discoverable entry — Nav
    'Parlay Lab' item opens the drawer via window.__openParlay, and the
    open drawer now renders an honest empty state at 0 legs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:59:17 -04:00
builtbykev 9ae64516e6 Session D (night2): Phase 2.5 — intraday refresh + directional movement
Odds-only refresh every 20 min during slate hours (noon–midnight ET),
in-process (INTRADAY_REFRESH=0 kill switch; skips full-snapshot slots):
- signed delta RELATIVE TO THE GRADED SIDE; direction is the signal.
- WITH the grade → STEAM badge, never a re-grade.
- AGAINST >=1.0 → re-grade THAT PROP ONLY at the real current line:
  holds/refuses → VALUE (better entry, same read); drops → PUBLIC revision
  (grade updates, revised_from_grade preserves the ORIGINAL forever, UI
  strikethrough on slate strips + ledger cards).
- Every run recaptures closing_line/odds → the close is now refresh-
  fidelity; SYNC goes live by dropping SNAPSHOT_EXPECTED_INTERVAL to 1200.
- Ticker MOVE events feed from the refresh (>=1.0 moves).
- QUOTA MATH: 36 runs/day/sport x 4 sports <= 144 PropLine calls/day vs
  9,000/day free capacity (3 keys x 3,000). Re-grades are internal compute.
- POST /api/internal/refresh/all for manual runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:51:00 -04:00
builtbykev 80481d6f6a Session C (night2): the aggregator mounted — Explore hub, lens panels, one filter
- StreaksPanel renders THE LENS (interpreted read + tonight difficulty) +
  snapshot grade letters; streaks are FREE for every tier per the product
  definition (the picture is free, the read is paid) — only hot lists keep
  the free top-3 gate.
- One stat selection now filters ALL layers: card props narrow together
  with streaks + hot lists (Slate activeStat → slateGameToCardData).
- /explore = server SEO shell (daily-indexable: 'MLB Hit Streaks, Hot
  Hitters & Stat Leaders') + ExploreHub client: leaders + full streaks +
  hot lists, real-tier gated, defaults to the in-season sport.
- Landing teaser fixed: it pointed at NBA (off-season → self-hid forever);
  now MLB top-3 through the lens — the anonymous-visitor hook actually
  shows.
- Player dossier: ACTIVE STREAKS block (free, lens reads) via new
  ?player= filter on /api/streaks/:sport.
- Schedule layer already the Slate base (mergeSlate) — verified, no change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:47:01 -04:00
builtbykev 2c9cbca7bd Session B (night2): streaks/hot-list engine — producer + form heat + THE LENS
RESURRECT verdict: the Session-23 engine existed, was pure, tested, and
mounted — it starved because every producer was external and unarmed
(tank01-prefetch via n8n, offline Python flow). The snapshot pipeline is
now the producer: each run merges slate players' real game logs (already
fetched for archetypes — zero extra calls) into rosterlogs:{sport}.

- computeFormHeat: hot hitters (7d AVG), hot sluggers (SLG), hot shooters
  (FG%) — correct sum/sum rate math, season baseline else prior stretch,
  min-sample refusals, never extrapolated.
- streakLens: no raw streak renders alone — built-vs opponents, tonight's
  matchup + opposing SP w/ ERA, step-up/step-down difficulty, one-line
  read. Absent context = say less, never invent.
- /api/streaks/:sport: heat merged into the feed, lens applied from cached
  schedule + pitchers (time-bounded, can never hang the route), snapshot
  grade letters joined.
- ACCEPTANCE (live statsapi, real 2026 logs): 32 rows found — Turang
  12-gm on-base, Reynolds 8-gm on-base, Pratt 7-gm on-base + 3-gm
  multi-hit, Meidroth 5-gm on-base, Cortes 5-gm on-base.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:39:18 -04:00
builtbykev 4ddfb84232 Session A (night2): AUTONOMY.md + SNAP_TTL 6h→24h P0 fix
The overnight cron gap (03→14 UTC) is 11h; a 6h snapshot TTL meant the
morning settle pass read an expired snapshot and the accuracy loop
silently settled nothing. Ledger (Postgres) settle was immune.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:18:01 -04:00
50 changed files with 2274 additions and 208 deletions
+80
View File
@@ -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 25**: 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 ~34 (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.
+48
View File
@@ -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;
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 AH 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.2022 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
Backend 2327 → **2352 tests** (+25), 202 suites. Web build clean (exit 0).
+28
View File
@@ -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 +
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 34s LOADING THE SLATE bug.
- **QA.2022 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
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
+4
View File
@@ -128,6 +128,10 @@ app.use('/api/preferences', require('./routes/preferences'));
app.use('/api/stripe', stripeRoutes);
app.use('/api/stats', statsRoutes);
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/pipeline', pipelineRoutes);
app.use('/api/share-card', shareCardRoutes);
+16
View File
@@ -93,7 +93,23 @@ function resetForTests() {
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 = {
scanLimit,
scanUsage,
__internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED },
};
+84
View File
@@ -35,6 +35,90 @@ function guard(req, res) {
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) => {
const sport = guard(req, res);
if (!sport) return undefined;
+17
View File
@@ -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
* persistent ledger (outcome + actual_value + CLV) across every sport.
+4
View File
@@ -84,6 +84,8 @@ router.get('/model', async (req, res) => {
? String(req.query.sport).toLowerCase() : undefined,
// 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,
// 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 = [];
if (sb) {
@@ -92,6 +94,8 @@ router.get('/model', async (req, res) => {
.select(ROW_COLUMNS)
.is('user_id', null);
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
.order('graded_at', { ascending: false })
.limit(limit);
+74
View File
@@ -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
View File
@@ -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
* warm Redis logs and runs the pure streaks engine over them. Supports
* `?stat=points` to narrow to one category, and `?limit=N`.
* Computed player streaks + form heat from cached game logs, every row
* interpreted through the VYNDR lens: what the streak was built against,
* 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' }
*
* 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.
* An empty array is a valid, non-error state.
*/
const express = require('express');
const streaksService = require('../services/streaksService');
const { applyLens } = require('../services/streakLens');
const { loadRosterLogs } = require('../services/rosterLogs');
const { nameKey } = require('../utils/playerName');
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();
// Session 32 — public throttle (60/min; pure engine over cached logs).
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']);
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) => {
const sport = String(req.params.sport || '').toLowerCase();
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 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 {
const roster = await loadRosterLogs(sport);
const streaks = streaksService.computeStreaks(roster, sport, { stat, limit });
return res.set(MISSION_HEADER).json({ sport, stat, streaks, source: 'computed' });
let roster = await loadRosterLogs(sport);
if (playerKey) roster = roster.filter((p) => nameKey(p.name) === playerKey);
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) {
console.error(`[streaks/${sport}]`, err.message);
return res.set(MISSION_HEADER).json({ sport, stat, streaks: [], source: 'computed' });
+27
View File
@@ -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;
+41
View File
@@ -144,6 +144,45 @@ const { nameKey } = require('../../utils/playerName');
* requires a UNIQUE same-last-name + same-first-initial candidate; anything
* 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) {
const targetKey = nameKey(name);
if (!targetKey) return null;
@@ -248,6 +287,8 @@ module.exports = {
getSeasonAverages,
getBatterVsPitcher,
searchPlayer,
searchPlayers,
matchPlayers,
getPlayerStats,
getTeams,
resolveTeam,
+217
View File
@@ -0,0 +1,217 @@
'use strict';
/**
* intradayRefreshService — Phase 2.5 (Session 60, night2/D).
*
* A lightweight ODDS-ONLY refresh during slate hours (~noonmidnight 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: noonmidnight 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 },
};
+56 -3
View File
@@ -100,13 +100,17 @@ function teamOpponentFor(g, prop) {
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) {
const map = {};
const bothSides = (p) => p && p.over_odds != null && p.under_odds != null;
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;
if (!map[k] || (!bothSides(map[k]) && bothSides(p))) map[k] = p;
}
return map;
}
@@ -318,6 +322,28 @@ async function settleLedger(sport, opts = {}) {
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 = {}) {
const sports = opts.sports || ['mlb', 'nba', 'wnba', 'soccer'];
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);
let settledQ = sb.from('ledger_entries')
.select('outcome, clv_result, player_key')
.select('outcome, clv_result, player_key, grade')
.is('user_id', null)
.not('outcome', 'is', null)
.gte('game_date', since)
.limit(AGG_FETCH_LIMIT);
if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase());
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;
if (error) return { ...empty, error: error.message };
@@ -369,9 +396,19 @@ async function getModelAggregate(opts = {}) {
.is('outcome', null);
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
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 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 || []) {
agg.settled += 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 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;
// n<20 → null: never render a percentage on a small sample.
if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) {
@@ -400,6 +452,7 @@ module.exports = {
captureClosing,
settleLedger,
settleAllLedgers,
applyRevision,
getModelAggregate,
MIN_AGG_SAMPLE,
__internals: {
+16 -1
View File
@@ -130,6 +130,15 @@ async function resolvePlayerStats(name, sport, opts = {}) {
season: mlbSeasonRows(res.season, res.group),
last10: mlbLast10Rows(res.last10, res.group),
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') {
@@ -147,7 +156,13 @@ async function resolvePlayerStats(name, sport, opts = {}) {
{ k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') },
{ 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 };
}
+53 -3
View File
@@ -17,7 +17,11 @@
* 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_CAP = 50;
const DELTA_NOISE = 0.5; // ignore movements smaller than this
@@ -48,12 +52,17 @@ async function mapLimit(items, concurrency, fn) {
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) {
const map = {};
const bothSides = (p) => p && p.over_odds != null && p.under_odds != null;
for (const p of props || []) {
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;
}
@@ -165,6 +174,28 @@ async function pushTickerItems(events, deps) {
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'];
/**
@@ -267,6 +298,14 @@ async function runSnapshot(sport, opts = {}) {
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
// and the slate join guard (a prop only attaches to its own game).
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) => {
try {
const stats = await deps.resolveStats(player, sp);
@@ -274,9 +313,20 @@ async function runSnapshot(sport, opts = {}) {
const c = deps.classify(sp, stats.classifierInput || {});
archByPlayer[player] = c.primary ? c.primary.name : null;
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 */ }
});
await mergeRosterLogs(sp, logEntries, deps);
const enriched = graded.map((g) => ({
...g,
+118
View File
@@ -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 },
};
+114 -3
View File
@@ -191,7 +191,7 @@ function computePlayerStreaks(player, sport, opts = {}) {
continue;
}
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
@@ -210,7 +210,12 @@ function computePlayerStreaks(player, sport, opts = {}) {
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 {
sport,
player: player.name || player.player || null,
@@ -222,6 +227,7 @@ function makeStreak(player, sport, spec, run, rateValue) {
currentStreak: run,
rate: rateValue ?? null,
description: describe(spec, run),
opponents,
active: true,
_collapse: spec.collapse || spec.key, // internal — stripped before return
};
@@ -245,9 +251,114 @@ function computeStreaks(players, sport, opts = {}) {
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 = {
computeStreaks,
computePlayerStreaks,
computeFormHeat,
specsFor,
__internals: { consecutiveRun, rateOverWindow, nba, mlb, nfl, soccer, MIN_STREAK },
__internals: { consecutiveRun, rateOverWindow, windowSplit, ratio, nba, mlb, nfl, soccer, MIN_STREAK, HEAT_SPECS },
};
+37 -3
View File
@@ -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 (noonmidnight 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();
console.log(`[snapshotScheduler] armed — SNAPSHOT_CRON=${process.env.SNAPSHOT_CRON}, hours=${HOURS_UTC.join(',')} UTC`);
return { interval, tick };
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)`}`);
// 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 };
@@ -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');
});
});
+30 -12
View File
@@ -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 path = require('path');
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', () => {
expect(src).toContain('/api/stats/leaders');
expect(hub).toContain('/api/stats/leaders');
});
it('has NBA/MLB/WNBA sport tabs + a player search filter', () => {
expect(src).toContain('NBA');
expect(src).toContain('MLB');
expect(src).toContain('WNBA');
expect(src).toContain('Search players');
expect(hub).toContain('NBA');
expect(hub).toContain('MLB');
expect(hub).toContain('WNBA');
expect(hub).toContain('Search players');
});
it('rows link to the player profile', () => {
expect(src).toContain('playerHref');
expect(hub).toContain('playerHref');
});
it('handles loading / error / empty states', () => {
expect(src).toContain("'loading'");
expect(src).toContain("'error'");
expect(src).toContain('No graded props');
expect(hub).toContain("'loading'");
expect(hub).toContain("'error'");
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 />');
});
});
+133
View File
@@ -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 — noonmidnight 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
});
});
+38
View File
@@ -233,3 +233,41 @@ describe('getModelAggregate — never a % under min sample', () => {
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');
});
});
+23
View File
@@ -176,3 +176,26 @@ describe('searchPlayer — canonical nameKey resolution', () => {
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([]);
});
});
+126
View File
@@ -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
});
});
+45
View File
@@ -91,3 +91,48 @@ describe('QA.18 — auth gate integration', () => {
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');
});
});
+12 -9
View File
@@ -20,16 +20,19 @@ export async function GET(req: NextRequest) {
if (q.length < 2) return NextResponse.json({ players: [] });
try {
// NBA/WNBA use the nba_api wrapper service; MLB falls back to the main backend.
const url =
sport === 'MLB'
? `${BACKEND_URL}/api/players/search?sport=MLB&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`
: `${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`;
// Session 60 (night2/E, audit 4.1) — Express is the canonical resolver
// for EVERY sport now (nameKey fuzzy match; MLB = full statsapi list,
// others = the platform's cached names). The Python NBA service is a
// best-effort second try only when the canonical index has nothing.
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' } });
if (!res.ok) return NextResponse.json({ players: [] });
const data = await res.json().catch(() => ({}));
let res = await fetch(url, { headers: { Accept: 'application/json' } });
let data = res.ok ? await res.json().catch(() => ({})) : {};
const canonical = Array.isArray((data as { players?: unknown[] }).players) ? (data as { players: unknown[] }).players : [];
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)
? (data as { results: unknown[] }).results
: Array.isArray((data as { players?: unknown[] }).players)
+22
View File
@@ -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
View File
@@ -1,128 +1,17 @@
'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';
import ExploreHub from '@/components/ExploreHub';
/**
* Stats Explorer (/explore) — Session 42, design section 07. The data hub:
* tonight's league leaderboard (top graded props by confidence) with grade +
* archetype context. Consumes the real /api/stats/leaders endpoint. Sub-panels
* (hit-rate trends, head-to-head, market-vs-VYNDR) need historical data and are
* a Session-43 follow-up — see BUILD-STATE.
* /explore — the aggregator hub (Session 60, night2/C). Server shell for SEO:
* daily-regenerating indexable surface (streaks, hot lists, leaders). The
* interactive hub is the client component.
*/
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 const metadata = {
title: 'MLB Hit Streaks, Hot Hitters & Stat Leaders — VYNDR Explore',
description:
'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.',
keywords: ['MLB hit streaks', 'hottest hitters this week', 'WNBA player streaks', 'player prop stats', 'hot hitters MLB'],
};
export default function ExplorePage() {
const [sport, setSport] = useState('nba');
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&apos;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&apos;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&apos;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>
);
return <ExploreHub />;
}
+30
View File
@@ -1293,3 +1293,33 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
.gl-full:not(.gl-expanded) { display: none !important; }
.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; }
}
+28
View File
@@ -44,6 +44,7 @@ interface LedgerRow {
revised_from_grade?: string | null;
}
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
interface ModelAggregate {
settled: number;
hits: number;
@@ -55,6 +56,8 @@ interface ModelAggregate {
beat_close_pct: number | null;
pending: 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> = {
@@ -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 }) {
const ready = agg.settled >= minSample && agg.hit_pct != null;
return (
@@ -200,6 +227,7 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
</p>
</div>
)}
<TierCalibration agg={agg} minSample={minSample} />
</div>
);
}
+26 -5
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import Hero from '@/components/Hero';
@@ -25,17 +25,35 @@ import Pricing from '@/components/Pricing';
import FAQ from '@/components/FAQ';
// Footer is mounted globally in the root layout (Session 34) — no per-page import.
// Session 60 (6.1) — the 34s "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() {
const { user, loading } = useAuth();
const router = useRouter();
const [maybeSignedIn] = useState<boolean>(() => hasStoredSession());
useEffect(() => {
if (!loading && user) router.replace('/dashboard');
}, [user, loading, router]);
// While we know the user is signed in we suppress the marketing
// render to avoid a flicker before the redirect lands.
if (loading || user) {
// Suppress the marketing render ONLY for visitors with a stored session
// (they're about to redirect) — anonymous first paint is instant.
if (user || (loading && maybeSignedIn)) {
return (
<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' }}>
@@ -63,7 +81,10 @@ export default function Home() {
<TonightsSlate />
<LivePropsStrip />
<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} />
</div>
<Features />
+24
View File
@@ -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 },
);
}
+15
View File
@@ -7,6 +7,8 @@ import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
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';
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 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)} />
{/* 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>
@@ -134,6 +145,10 @@ export default function PlayerProfilePage() {
</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 */}
{dna.length > 0 && (
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
+5
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
import PriorReads from '@/components/vyndr/PriorReads';
import { AccuracyBadge } from '@/components/vyndr';
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
@@ -762,6 +763,10 @@ export default function ScanPage() {
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.
"The system learns" — real hit rate on graded props, misses shown. */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
+34 -1
View File
@@ -68,6 +68,18 @@ export default function SettingsPage() {
const [deleteText, setDeleteText] = useState('');
const [deleting, setDeleting] = useState(false);
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).
const [prefSports, setPrefSports] = useState<string[]>([]);
@@ -162,10 +174,31 @@ export default function SettingsPage() {
{/* 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>
<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 style={{ display: 'flex', gap: 9 }}>
{plan.label !== 'FREE' && (
+7
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import SportBadge from '@/components/vyndr/SportBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import ModelRecord from '@/components/vyndr/ModelRecord';
import { playerHref } from '@/lib/playerHref';
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>}
</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>}
{/* Controls */}
+140
View File
@@ -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&apos;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&apos;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>
);
}
+11 -1
View File
@@ -24,6 +24,8 @@ const PRIMARY = [
];
const MORE = [
{ 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: 'Tracker', href: '/tracker' },
{ label: 'The Report', href: '/blog' },
@@ -153,7 +155,15 @@ export default function Nav() {
key={l.href}
href={l.href}
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={{
display: 'block',
padding: '9px 10px',
+8 -3
View File
@@ -176,7 +176,12 @@ interface PitcherResponse { games?: PitcherGame[] }
type GradeIndex = ReturnType<typeof indexGrades>;
type DeltaIndex = ReturnType<typeof indexDeltas>;
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 {
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
sport: g.sport,
@@ -189,7 +194,7 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
// 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).
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).
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
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) => (
<VyndrGameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap)}
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat)}
preferredBooks={preferredBooks}
onOpen={() => router.push('/scan')}
/>
+26 -9
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
import { type Tier } from '@/lib/tierGate';
/**
* StreaksPanel (Session 23).
@@ -25,6 +25,9 @@ interface Streak {
category: string;
currentStreak: number;
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 {
@@ -56,10 +59,13 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
if (!streaks || streaks.length === 0) return null;
const tierCount = getVisibleCount(tier, streaks.length);
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
const visible = streaks.slice(0, cap);
const hidden = limit ? streaks.length - visible.length : getHiddenCount(tier, streaks.length);
// Session 60 (product def) — the PICTURE is free: every streak shows for
// every tier. Only an explicit `limit` (landing teaser) caps the list.
// The paid layer is the GRADE, not the data. tierGate still governs the
// 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 (
<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; }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{s.player}{s.team ? ` · ${s.team}` : ''}</div>
<div style={streakDesc}>{s.description}</div>
<div style={playerName}>
{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>
<span style={badgeStyle}>{s.currentStreak} G</span>
</div>
))}
</div>
{hidden > 0 && (
<a href="/pricing" style={upsellStyle}>
{hidden} more streak{hidden === 1 ? '' : 's'} upgrade to see all
<a href="/explore" style={upsellStyle}>
{hidden} more streak{hidden === 1 ? '' : 's'} on the board
</a>
)}
</section>
+34 -5
View File
@@ -22,6 +22,8 @@ interface SnapGrade {
grade?: string;
confidence?: number;
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;
@@ -38,6 +40,7 @@ const isTop = (g?: string) => g === 'A+' || g === 'A';
export default function TopSignals() {
const [signals, setSignals] = useState<SnapGrade[] | null>(null);
const [header, setHeader] = useState<'signals' | 'board' | 'proof'>('board');
useEffect(() => {
let active = true;
@@ -52,12 +55,28 @@ export default function TopSignals() {
);
if (!active) return;
const all: SnapGrade[] = [];
const settled: SnapGrade[] = [];
for (const res of results) {
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));
setSignals(all.slice(0, 3));
// 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));
setHeader(all.some((g) => isTop(g.grade)) ? 'signals' : 'board');
} else if (settled.length > 0) {
setSignals(settled.slice(0, 3));
setHeader('proof');
} else {
setSignals([]);
}
} catch {
if (active) setSignals([]);
}
@@ -67,16 +86,19 @@ export default function TopSignals() {
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;
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 (
<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 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 style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>TONIGHT&apos;S TOP SIGNALS</span>
<span style={{ color: 'var(--text-tertiary, #707080)' }}>· LIVE FROM THE SLATE</span>
<span style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>{headerText}</span>
<span style={{ color: 'var(--text-tertiary, #707080)' }}>{headerSub}</span>
</div>
<AccuracyBadge variant="inline" />
</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={{ fontSize: 12, color: 'var(--text-secondary, #B8BCC8)' }}>
{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>
</a>
);
+5 -1
View File
@@ -27,10 +27,13 @@ interface Aggregate {
export default function ModelRecord({
sport,
player,
team,
align = 'center',
}: {
sport?: string;
player?: string;
/** VYNDR-on-team (5.2) — exact team name as the feed writes it. */
team?: string;
align?: 'center' | 'left';
}) {
const [agg, setAgg] = useState<Aggregate | null>(null);
@@ -41,6 +44,7 @@ export default function ModelRecord({
const params = new URLSearchParams({ limit: '1' });
if (sport) params.set('sport', sport.toLowerCase());
if (player) params.set('player', player);
if (team) params.set('team', team);
fetch(`/api/ledger/model?${params}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
@@ -50,7 +54,7 @@ export default function ModelRecord({
})
.catch(() => { /* self-hide */ });
return () => { active = false; };
}, [sport, player]);
}, [sport, player, team]);
if (!agg) return null;
const ready = agg.settled >= minSample && agg.hit_pct != null;
+25 -5
View File
@@ -5,6 +5,8 @@ import { useParlay } from '@/contexts/ParlayContext';
import { useAuth } from '@/contexts/AuthContext';
import GradeBadge from '@/components/vyndr/GradeBadge';
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
@@ -20,17 +22,25 @@ function tierMaxLegs(tier: string): number {
}
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 fullLab = tier === 'desk' || tier === 'analyst';
// Sync the leg cap to the user's tier.
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.
if (!isOpen) {
if (!isOpen && legs.length > 0) {
return (
<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)' }}>
{l.archetype && <ArchetypeBadge archetype={l.archetype} size="sm" variant="full" />}
<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" />
<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>
@@ -120,8 +131,17 @@ export default function ParlayPanel() {
)}
</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"
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+32 -11
View File
@@ -36,31 +36,41 @@ interface ProcessingGradeProps {
* then the GradeResultCard reveals. Pure chrome — the underlying grade is fixed.
*/
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 factors = (data.signals || []).slice(0, 5);
const total = factors.length || 1;
useEffect(() => {
setProc(true);
if (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setPhase('card');
return;
}
setPhase('proc');
setLit(0);
const stepMs = 190;
const timers: ReturnType<typeof setTimeout>[] = [];
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);
}, [replayKey, total]);
if (!proc) {
if (phase === 'card') {
return (
<GradeResultCard
data={data}
replayKey={replayKey}
onShare={onShare}
onAddToParlay={onAddToParlay}
onReadAnother={onReadAnother}
/>
<div className="grade-reveal">
<GradeResultCard
data={data}
replayKey={replayKey}
onShare={onShare}
onAddToParlay={onAddToParlay}
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)' }}
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 style={{ position: 'relative', zIndex: 2, padding: '26px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 22 }}>
+27
View File
@@ -17,6 +17,28 @@ export interface StripProp {
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).
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 {
primary: string;
@@ -232,7 +254,12 @@ export default function StatStrip({
<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' }}>
<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" />}
<MovementChip p={p} />
<OutcomeChip p={p} />
{!p.outcome && <ParlayBtn p={p} />}
{!p.outcome && <BookItTeaser p={p} />}
+4
View File
@@ -316,6 +316,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
// Session 55 — settled outcome from the self-learning loop (hit/miss/push
// + actual stat) once the game completes. null until settled.
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 {
byPlayer[pk].props.push({