# VYNDR — Build State ## Last Updated 2026-07-11 ## Session S9 (a1 board, 2026-07-11) — Slip Reader: zero-API OCR ✅ Spec: `specs/a1-s9-slip-reader.md`. +34 tests (24 unit + 10 integration). - **slipReader service** — tesseract.js (self-hosted WASM, Apache-2.0, zero API spend) + PURE per-book layout parsers (DraftKings, FanDuel, BetMGM, Caesars) on OCR-text fixtures. Per-field confidence; below 0.6 → field null + `needs_review` — the reader never guesses. Stats normalize to the scan-route vocabulary; names through `playerName`. - **POST /api/slips/parse** — auth, free 1/day paid 10/day (`slips:{user}: {day}` Redis + memory mirror), 4MB image cap, text paste path. Next proxy added. Mounted in app.js. - **/slip page** (gated) — upload/paste screenshot → manual-correct UI (amber = uncertain) → per-leg grade via existing /api/scan (honest refusals) → add-all to Parlay Lab → "VYNDR read my slip" share card. - **Acceptance: FULL image path** — synthetic DK slip PNG (sharp SVG→PNG) → real tesseract.js OCR → parser → 3/3 complete legs. Untested on a real phone screenshot yet — grab one post-deploy. - Vision-model upgrade logged post-revenue in specs/vyndr-roadmap.md. ## Session S3 (a1 board, 2026-07-11) — Affiliate + Partnership Plumbing ✅ Zero out-of-pocket; everything config-flip-ready but DISABLED/organic. 2398 → **2439 tests** (209 suites), web build exit 0. - **BOOK IT deep links** — `web/src/lib/bookLinks.js` + `affiliateConfig.js` (every book `enabled:false`; Impact/Partnerize param shapes documented). StatStrip BOOK IT is a real anchor now (organic); scan hand-off links moved to the builder. Every book anchor renders `rel="sponsored noopener noreferrer"`. - **Best-price marker** — `slateAdapter.detectBestBook` (≥2 books, SAME line, differing prices, else null) + green dot in StatStrip; `books[]` (the grouped odds rows) now threads Slate → strips instead of being discarded by pickLine. - **Partner refs** — `?ref=CODE` → 90d first-party `vyndr_ref` cookie (first-touch, `PartnerRefCapture` in layout) → signup metadata `partner_ref` → internal `GET /api/partners/report/:code` (zeros + note until the TODO migration in docs/PARTNERS.md adds `user_profiles.partner_ref`; NOT run). - **docs/PARTNERS.md** — ref link spec, Stripe promo-code mapping convention (partner code == promotion code), TODO migration SQL. ## Session S7 (a1 board, 2026-07-11) — Newsletter: THE VYNDR REPORT ✅ Branch off day1/a1-board (e4d2e79). 2398 → **2429 tests** (207 suites), web build exit 0. Spec: `specs/feature-a1-s7-newsletter.md`. Runbook: `docs/NEWSLETTER.md`. - **Capture**: `NewsletterCapture` (dark terminal, mono data) on the landing (below FAQ) + `/welcome` (the real signup success surface — the signup "done" card auto-redirects in 1.5s, useless for a form). Double-opt-in note under the field; "Signups open soon" when Listmonk env is unset. - **Backend**: `POST /api/newsletter/subscribe` (public, 10/min IP limit, honeypot, server-side email validation) → Listmonk subscribers API with `preconfirm_subscriptions:false` (Listmonk sends the confirmation). No env → calm 200 `{ ok:false, reason:'not configured' }`. Next proxy `web/src/app/api/newsletter/subscribe/route.ts` (S25 rule). - **Assembly**: `newsletterService.buildDailyReport` — signals from `snapshot:{sport}:latest`, STREAK WATCH via rosterLogs → streaksService → streakLens (one row per player), THE RECORD via `getModelAggregate` (percentage ONLY when hit_pct != null — the n≥20 gate — else "RECORD BUILDING · N pending"). RG footer (21+, 1-800-GAMBLER, `{{ UnsubscribeURL }}`) in html + text. VOICE lint locked by tests: no `!`, no banned vocabulary, numbers only from injected pipeline data. - **Send**: `sendDailyReport` creates + starts a Listmonk campaign; operator-triggered ONLY via `POST /api/internal/newsletter/send` (internal key). NOT scheduled. Empty report (0 signals + 0 streaks) refuses to send. - **Env for Coolify** (all four required, else graceful no-op): `LISTMONK_URL`, `LISTMONK_USER`, `LISTMONK_TOKEN`, `LISTMONK_LIST_ID`. ## Session S2 (a1 board, 2026-07-11) — BUILT ✅ COMPLIANCE + APPROVAL PACK Branch worktree off `day1/a1-board`. 2429 tests (206 suites), web build exit 0 (`next build --webpack`). No backend routes touched. - **/responsible-gambling REBUILT sincerely** — 21+, 1-800-GAMBLER (1-800-426-2537) as the primary helpline, 17-state resource list, warning signs, state self-exclusion guidance, links to the EXISTING /settings Responsible Play + Danger Zone surfaces. Zero marketing adjacency (no pricing/scan/upgrade links — a test enforces it). - **/terms + /privacy redrafted** as honest approval-pack drafts (marked DRAFT: JULY 2026). Entity details are placeholder tokens Kev must fill: [ENTITY NAME], [STATE OF FORMATION], [ARBITRATION VENUE], [CONTACT EMAIL]. Privacy sub-processors now match reality (Stripe, Supabase, Resend, Sentry; PostHog described honestly — autocapture off, banner is disclosure-only, NOT consent-gated). - **/methodology NEW** (server component + metadata, footer COMPANY link, OPEN_ROUTES): pipeline → engine → 11-step letter grades, refusals (insufficient_data), VYNDR Originals (41), Ledger settle (locked at grade time, box-score settle, CLV vs captured close, n≥20, public revisions), why misses are public. - **/about audited** to North Star framing: data intelligence platform, THE PROOF card (Ledger + n≥20 + refusals + methodology link). Kept the "give it back" h1 (Phase-E test) + Wordmark (QA.5). - **Footer**: helpline updated 1-800-522-4700 → 1-800-GAMBLER; Methodology link added. Compliance audit: footer is global in the root layout, no nested layout or CSS suppresses it on any page — zero pages missing it. - **content/articles/** — 5 Ghost-ready seed drafts (front matter, VOICE v1.1, zero exclamation points, test-enforced) + docs/GHOST-PUBLISHING.md manual runbook (no Ghost credentials used; nothing auto-posts). - Tests: tests/unit/compliancePages.test.js (31 assertions, source-text style). ## Current Phase SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum, 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 8 (A1 board, 2026-07-11) — SHIPPED ✅ OPS: THE PRODUCT WATCHES ITSELF Branch off day1/a1-board tip. 2398 → 2437 tests (208 suites). Backend-only. - **Settlement alarm** (`snapshotScheduler`): outcome/ledger settle pass THROW → ntfy high. Morning zero-settle alarm: ledger settle found pre-today Postgres rows but settled none → page once per ET date (signal = settleLedger's own return values — survives the Redis-TTL failure mode that silently killed morning settles in S60; documented in `opsWatch.zeroSettleAlarm`). Genuinely empty yesterday never alerts. - **Persistent-failure pager** (`opsWatch.createFailureTracker`, pure): 3 consecutive error / skipped-'no props' slots for a sport pages once (high); any good slot resets and re-arms. Single-slot misses stay quiet. - **Quota** (`opsWatch.checkQuotaDaily`): odds-api ≥80% after a snapshot run → one alert/day, Redis-deduped (`ops:quota_day:{provider}:{date}`). - **Box health** (`services/systemHealth`, pure + injectable): fs.statfs + os mem → `{disk_pct, mem_pct}`; pages high at disk>85 / mem>90. - **Daily pulse** 13:00 UTC (9 AM EDT; `PULSE_HOUR_UTC` to move), ONE notification: ledger rows yesterday (`ledgerService.countRowsForDate`, 'n/a' when Supabase off — never a fabricated 0), settles last 24h, quota pct, disk/mem, 'desk pack: see /desk'. Dedupe: in-process date + `ops:pulse:{date}`. - **docs/OPS-RUNBOOK.md**: Uptime Kuma monitors (vyndr.app 200, api /api/health keyword 'healthy', /api/snapshot/summary), Coolify deploy-failure webhook → ntfy, ntfy phone-subscription steps. - Live acceptance alert POSTed to ntfy.sh/vyndr-pipeline-kev2026. - All alert copy VOICE v1.1 — deadpan, numbers, zero exclamation points (tests lint for `!`). Spec: `specs/session-8-ops-watch.md`. ## Session 60 (night2/full-board, 2026-07-11) — BUILT ✅ THE WHOLE BOARD (awaiting "merge the train") Branch `night2/full-board` off d10bb4c — ZERO pushes to main. 2352 → **2396 tests** (205 suites), web build exit 0. Sessions A–H all shipped: - **A** AUTONOMY.md + P0: SNAP_TTL 6h→24h (the 11h overnight cron gap expired the snapshot before the morning settle — accuracy loop silently settled nothing; the Postgres ledger settle was immune). - **B** Streaks engine RESURRECTED + fed: the snapshot pipeline now writes rosterlogs:{sport} from the stats it already fetches (producers were all external/unarmed — that's why panels never rendered). Form heat (hot hitters/sluggers/shooters, Σ/Σ rate math) + THE LENS (built-vs, tonight's matchup + opposing SP ERA, step-up/down, one-line read). LIVE acceptance: 32 real rows (Turang 12-gm on-base, Reynolds 8-gm, Pratt 7-gm, Meidroth 5-gm, Cortes 5-gm). - **C** Aggregator mounted: Explore = SEO server shell + hub (leaders + full streaks + hot lists); streaks FREE for all tiers (the picture is free); landing teaser fixed (pointed at off-season NBA → never showed); one stat selection filters props+streaks+hotlists; player-dossier ACTIVE STREAKS block (+?player= on /api/streaks). - **D** Phase 2.5: 20-min odds-only refresh in slate hours (skips snapshot slots, INTRADAY_REFRESH=0 kill switch, POST /api/internal/refresh/all). STEAM ▲ (with, no re-grade) / VALUE ▲ (against ≥1.0, re-grade holds) / PUBLIC revision (grade drops → revised_from_grade, strikethrough). Refresh-fidelity closing capture; ticker MOVEs at refresh frequency. Quota: ≤144 PropLine calls/day vs 9,000 free capacity. Post-deploy: set SNAPSHOT_EXPECTED_INTERVAL=1200 and SYNC goes live. - **E** Phase 4: /api/players/search built (didn't exist — the Ohtani bug); nameKey fuzzy match live-verified vs the real 1,299-player list. §7 reveal (steps → DECLASSIFIED stamp → 90ms staggered panels, reduced-motion safe). PRIOR READS chips. Parlay: shared statLabel, 1-leg provisional grade, Nav entry via window.__openParlay. - **F** Phase 5: archetype definition line on-page; VYNDR-on-team (ModelRecord team scope); WNBA minutes-usage; Settings meter from the LIVE limiter (3/day free, 15/day analyst, ∞ desk); per-tier calibration chips (n≥20 PER TIER). - **G** Phase 6: landing first-paint root cause KILLED (page blocked its render on Supabase auth init for anonymous visitors; now a sync localStorage gate — anon paints instantly). Proof Strip: A-tier-gated header, "TONIGHT'S BOARD" fallback, yesterday's settled reads with outcome chips when ungraded. /api/content/top-signals + /streak-watch (zero-grade format) + /daily-report (self-flags do_not_post until n≥20). Per-player OG images (Node runtime) + player metadata. - **H** Design pass: QA.20–22 locks (STEAM amber/VALUE green/STALE-only red, revisions struck through, new data surfaces mono, honest waiting copy everywhere). ### The word: "merge the train" → main fast-forwards → Coolify ships board #2. ## Session 59 (2026-07-10 overnight) — SHIPPED ✅ ADDENDUM + 1.6 + PHASE 2 + PHASE 3 Backend 2327 → **2352 tests** (+25), 202 suites. Web build clean (exit 0). Migration 020 (team/opponent) APPLIED while ledger_entries was at 0 rows — zero backfill needed; VYNDR-on-team unblocked. - **Task A** — `ledger_entries.team/opponent` populated in both write paths (pipeline: stats-resolve team + prop's other side via nickname match, NEVER guessed; scan: snapshot-grade team, opponent null). Roadmap: Phase 4.5 (WNBA ESPN box-score settlement — DUE ~Jul 24) + Phase 5 calibration- by-grade-tier logged. - **Task B (work-order 1.6 CLOSED)** — `mlbStatsAdapter.searchPlayer` now resolves via canonical `nameKey` (the old matcher DELETED accents: "Sánchez"→"snchez"≠"sanchez", then substring-guessed → wrong player's last-10). Fallback = UNIQUE last-name+first-initial or null. `buildPlayerStripsFromProps` gained the JOIN INVARIANT: a graded prop whose player's real team isn't a game participant is DROPPED (TB player can't render under MIL@PIT — suite fails if it regresses). Snapshot grades now carry `team`. Root cause of /team "No active props": grades:{sport} TTL (2h) < cron gap (5h) → now SNAP_TTL (6h). - **Task C (Phase 2)** — 2.1 sport tabs are THE filter, URL-driven (?sport=, deep-linkable); the dashboard's duplicate legacy tablist is GONE (legacy sections subscribe via onTabChange). 2.2 cards cap at 6 graded props sorted A+→F with "ALL N READS →" expanding in place. 2.3 waiting states show the REAL next pipeline run ("Grades post ~6:00 PM ET" via lib/pipelineSchedule — mirrors SNAPSHOT_HOURS_UTC; update BOTH if the cron changes). 2.4 = TTL fix + join fix (verify on the live Yankees page post-deploy). - **Task D (Phase 3)** — ROOT CAUSE of vanished mobile nav: HIDE_ON included '/' — the landing had NO nav at 390px (desktop links hidden, hamburger retired S37, tab bar suppressed). '/' removed. 3.2: html/body overflow-x hidden <768; GAME LINES collapses to a best-line summary + "N BOOKS ⌄" expander <640; venue (.gc-venue) drops first on phones. - **Live verification:** raw ESPN (no date pin) STILL returns the Jun 13 NYK@SA Finals game today; the pinned fetch returns 0 games, 0 off-date. ### Morning deploy runbook (Sat, before ~10 AM ET) 1. Push already sent to gitea (Coolify betonblk) — confirm deploy finished. 2. `GET /api/internal/snapshot/status` (x-internal-key) → cron_armed true. 3. When the day's lines post: `POST /api/internal/snapshot/all` (x-internal-key) → first public ledger rows lock against fresh lines. 4. Verify: `SELECT count(*), count(*) FILTER (WHERE team IS NOT NULL) FROM ledger_entries WHERE user_id IS NULL;` + one sample row. ## Session 58 (2026-07-10) — SHIPPED ✅ PHASE 1: TRUTH INFRASTRUCTURE Backend 2309 → **2327 tests** (+18), 201 suites. Web build clean (exit 0). Spec: `specs/phase-1-truth-infrastructure.md`. Migration `supabase/migrations/019_ledger_entries.sql` APPLIED to prod (betonblk) — dedupe (`UNIQUE NULLS NOT DISTINCT`) + RLS verified live (anon reads public rows only; anon INSERT rejected 42501). - **ledgerService** — recordPipelineGrades (public model record, idempotent ignoreDuplicates upsert), captureClosing (every snapshot overwrites today's closing_line/odds from the real feed; last write before game start = close), settleLedger (outcome + actual + SIGNED CLV: over = locked−closing), getModelAggregate (30d; percentages NULL under 20 settles). No-ops without SUPABASE env. `Number(null)===0` fabrication bug caught by tests → strict numOrNull everywhere. - **Write paths:** snapshotService → ledger (user_id null, priority path); Next /api/scan → ledger row for AUTHED users only (anon would pollute the public record). Refused reads write NOTHING (no scan_history either) and don't burn a scan. - **Honest refusal (work-order 1.5):** analyzeViaEngine1 returns `insufficient_data: true, grade: null` when the model has no projection (l5→l20→{stat}_per_90→xG for soccer). The web gradeAdapter no longer displays the LINE as the projection (the audit's model==line degenerate); GradeResultCard renders "—" absent states; scan page renders "INSUFFICIENT DATA — NO READ". - **/ledger:** MY READS | MODEL tabs; model header shows "H-M · X% HIT · Y% BEAT CLOSE" only at n≥20, else "RECORD BUILDING" + pending count. ModelRecord component mounted on landing + player hero (deferred-render). - **SYNC (Task 5):** thresholds vs SNAPSHOT_EXPECTED_INTERVAL (default 18000s): normal <1.5x · amber ≥1.5x · STALE red ≥3x. - **Phase 2.5 LOGGED** in specs/vyndr-roadmap.md (intraday refresh + STEAM/VALUE/revision handling; build after Phase 3). - **Settle trigger:** scheduler settle pass + POST /api/internal/ledger/settle. ## Session 57 (2026-07-10) — SHIPPED ✅ PHASE 0: KILL THE LIES Backend 2289 → **2309 tests** (+20), 199 suites. Web build clean (exit 0). Spec: `specs/phase-0-kill-the-lies.md`. Work order: Phase 0 of the overhaul (audit-driven; Phases 1–6 queued). ### 0.1 — Fake NBA game (root cause found + fixed at the source) - `scheduleService.fetchScheduleFromEspn` hit ESPN with NO `?dates=` param and NO date filter → off-season ESPN returns the NEAREST slate (the Jun 13 NYK@SA Finals game rendered as "tonight" all July). Now pins `?dates=YYYYMMDD` AND filters events to the requested ET date; undated events dropped. - Honest per-sport empty states: `web/src/lib/emptyState.js` (month-aware — "NBA returns in October." vs in-season "No NBA games today."), used by the dashboard NO SLATE block + the Slate empty state. ### 0.2 — Fake header counters deleted - `liveTick.js` stripped to `{ tick }` (1s re-render pulse only). The fake auto-incrementing "247 graded", sin-driven brain-%, aPlus/cascades: DEAD. - New `GET /api/snapshot/summary` (public, cache-only, registered before `/:sport`) → `{ graded, updated_at, sports }`. Next proxy added. - `HeartbeatBar` shows the REAL graded count + SYNC = elapsed since the last pipeline run (amber past 5 min). No data → "SYNC —", nothing invented. ### 0.3 — Ticker: real items only - Nav's hardcoded fallback items (fake Wembanyama A+/"NYK vs SA Q3"/invented Tatum MOVE) deleted. MOVE stays — `computeLineDeltas` is real movement. - Spec §3 rule: <4 real items → ticker renders nothing. It publishes `--ticker-h` (32px/0px) on ``; layout main paddingTop and Slate sticky top are now `calc(base + var(--ticker-h))` so the header collapses cleanly. ### 0.4 — /terminal retired (fabricated surface) - Route now `redirect('/dashboard')`. Layouts preserved UNROUTED at `web/src/components/intel/TerminalTemplates.tsx` (§12 content-engine templates — feed real data before ever routing them). - Nav PRIMARY = Slate/Scan/Ledger. BottomTabBar: Terminal→Explore. PWA manifest shortcut Terminal→Ledger. routes.js: `/terminal` out of OPEN_ROUTES; `#terminal` → `/dashboard`. ### 0.5 — ›Query nav pill deleted (duplicate /scan link) ### Next (work order Phase 1 — starts the settle clock) - `ledger_entries` table + write-on-grade (user scans AND pipeline pre-grades) - Settlement worker; populate /ledger (personal + MODEL tabs) - Degenerate scan output (model==line, +0% edge) → honest "INSUFFICIENT DATA" - Canonical player_key normalization (blocks 2.4, 4.1) ## Session 56 (2026-07-10) — SHIPPED ✅ THE FULL AUDIT Backend 2276 → **2289 tests** (+13), 197 suites. Web build clean (exit 0). Docs: `specs/propline-audit.md`, `specs/vyndr-roadmap.md`, `specs/audit-data/propline-audit-raw.json`, `scripts/propline-audit.js`. ### Research (Phases 1–3, 5) — verified against LIVE APIs - Fetched real data: The Odds API `/v4/sports` (in-season now: MLB, WNBA, NFL preseason, NBA summer league, 40+ soccer leagues; NHL dark), a real MLB boxscore (Braves@Pirates 2026-07-09 — every settle field confirmed), ESPN WNBA/soccer scoreboards. PropLine inventory derived from code (no dev keys). - **Headline bug found:** `oddsNormalizer` mapped `batter_rbis → 'rbis'` but the entire grade/feature/outcome chain keys on `'rbi'` → **every PropLine RBI prop silently failed to grade AND settle.** Fixed. - Coverage truth: **MLB is the only end-to-end-live sport.** Outcome settlement is MLB-only (WNBA/NBA/soccer never settle → accuracy is MLB-only). Documented as the #1 roadmap gap. ### Phase 4 — wired missing MLB stat types (a full vertical slice) - `batter_rbis → rbi` (unblocks a market already requested). - PropLine MLB MARKETS 6 → 12: +batter_runs, +batter_walks, +batter_doubles, +pitcher_earned_runs, +pitcher_hits_allowed, +pitcher_outs (same request, no extra quota; runs/walks/earned_runs/hits_allowed were already supported downstream — just never asked for). - `doubles` + `outs` (+`triples`) added to featureCache + outcomeService MLB_LOG_FIELD AND all three grade whitelists (analyze/scan/validation.py). ### Phase 6 — pipeline alerting + resilience - **`src/utils/opsNotify.js`** — ntfy.sh push (`vyndr-pipeline-kev2026`), never throws, auto-disabled under test / `PIPELINE_ALERTS=0`. Injectable `fetchImpl`. - `snapshotService.runSnapshot`: success alert ("✅ MLB snapshot: N graded, D deltas, X% accuracy"), stale alert (empty slate), failure alert; **retry-once** on a hard odds error/null (NOT on a legit empty slate — that's off-hours, not a failure). - **Missed-cron watchdog:** `snapshotScheduler.mostRecentExpectedSlot` + `isSnapshotOverdue` (pure, tested); the tick alerts once per missed slot; the status probe (`GET /api/internal/snapshot/status`) now returns `overdue`. ### Phase 7 — the master roadmap `specs/vyndr-roadmap.md` — priority-ordered Sessions 57–64 (WNBA/NBA settlement → ESPN features → soccer e2e → live calibration → NFL/NBA readiness), stat-type + sport coverage targets, operating invariants. ## Session 55 (2026-07-10) — SHIPPED ✅ SELF-LEARNING LOOP + REAL-TIME LAYER Backend 2255 (4 failing) → **2274 tests** (all green; +19 new, +4 fixed), 196 suites. Web build clean (exit 0). Spec: `specs/session55-self-learning.md`. ### Phase 2 — the self-learning loop (the crown jewel; nothing like it existed) - **`src/services/outcomeService.js`** — settles each locked snapshot grade against the REAL result (MLB Stats API game log — free, same source the grade pipeline uses) → hit/miss/push, aggregated by grade tier over a trailing 30-day window. Presence of a game-log row for the graded date ⇒ FINAL. Fully injectable → unit-tested with zero network. Idempotent (dedupe by `nameKey|stat|line|side|date`). NBA/WNBA degrade to `pending` (offline stats), never throw. - **Redis:** `outcomes:{sport}:log` (settled, cap 1000), `accuracy:{sport}`, `accuracy:overall` ({ overall, byGrade } over 30d; pct excludes pushes). - **Routes:** `GET /api/accuracy` (public, cached 5m), `GET /api/ledger/accuracy` (buckets — fills the pre-existing Next proxy that had no writer), internal `POST /api/internal/outcomes/:sport|/all`. Cron: `settleAllOutcomes()` runs on the snapshot scheduler tick BEFORE grading (settle yesterday, grade today). - **UI:** `AccuracyBadge` (dashboard header + scan result + landing) — honest by construction: below MIN_SAMPLE (8) it reads "MODEL · LEARNING" (amber) instead of faking a number; above it, "A-RATED · 68% HIT · 30D" (green). Settled outcome chips (`✓ HIT (2)` / `✕ MISS`) on slate props via a snapshot-route overlay + `StatStrip.OutcomeChip`. ### Phase 1 — the real-time layer (make it feel ALIVE) - **Slate auto-refresh:** silent 60s poll (no skeleton flash, never wipes a good view on a transient blip) + a "SIGNAL LIVE · N PROPS GRADED · M LIVE · UPDATED Xs ago" strip with a ticking freshness clock (`nowTick`, 15s). - **Ticker:** anchored `LIVE` badge (pulsing dot) that flashes green when a fresh event slides in (breaking-news feel). ### Phase 3 — landing top-signals preview - **`TopSignals.tsx`** — pulls tonight's REAL top-3 A-rated grades from `/api/snapshot/{mlb,nba,wnba}` as mini grade cards (archetype + grade + line) + the live `AccuracyBadge`. Self-hides off-hours. The product shown, not described. ### Founder pricing (the real cause of the "4 stripe failures") `FOUNDER_CODE_EXPIRY` default was `2026-06-30` — lapsed as of the current date (2026-07-10), silently disabling every founder code (and the ClaimMeter scarcity pitch). Extended the default to `2026-12-31` (operators still override via env). That restored founder pricing AND turned the 4 failing tests green. ### Honest scope (deferred — NOT built this session) The prompt's Phases 4–8 (dashboard card redesign, scan reveal polish, Parlay Lab derivatives-desk polish, nav/mobile/onboarding/settings/team-hub polish) are real follow-on work. This session invested in the two TRANSFORMATIVE, differentiated systems (real-time + self-learning) end-to-end with tests + the highest-leverage first-visit surfaces (landing signals, dashboard accuracy). Live calibration adjustment (spec 2.3, feeding outcomes back into grade confidence) is scaffolded by the accuracy record but not yet wired into the engine. ## Session 54 (2026-06-19) — SHIPPED ✅ AUDIT CLEANUP Backend 2246 → **2255 tests** (+9), 194 suites. Web build clean (exit 0). ### Phase 1 — name edge cases (BOTH playerName.js copies, kept identical) - **Hyphens** stripped in `normalizeName` display+key (`.replace(/-/g,' ')`) so "Jung-hoo Lee" === "Jung Hoo Lee". - **Middle single-letter tokens** stripped in `nameKey` (keep first + last) so "Josh H Smith" === "Josh Smith". Guarded: real middle names ("Juan Carlos Smith") and collapsed first initials ("JC Escarra") are untouched. - **`richie: 'richard'`** added to NICKNAMES. ### Phase 2 — polish - **Team Hub names normalized** at the source: `teamService.getTeamHub` now maps every roster player (MLB + snapshot-fallback) through `normalizeName().display` → "J.C. Escarra" renders as "JC Escarra", matching the dashboard. - **Accent-keeping dedup** (`snapshotService`): when collapsing variant grades, the GRADE picked is still highest-confidence, but the DISPLAY now prefers the accented variant ("José" over "Jose") so prop rows match the pitcher line. - **Parlay copy**: same-game (different-team) warning now names the game — "⚠ 2 legs from the same game (NYY @ BOS) — correlated". ## Session 53 (2026-06-19) — SHIPPED ✅ SOCIAL PREVIEW FIX ## Session 53 (2026-06-19) — SHIPPED ✅ SOCIAL PREVIEW FIX Backend 2239 → **2246 tests** (+7), 193 suites. Web build clean (exit 0). - **OG/Twitter copy de-jargoned** (`layout.tsx`): the openGraph description had "Bayesian intelligence / kill conditions"; the main description had "xG regression / penalty taker". Replaced with "Pre-graded player props with proprietary archetypes. Correlation-aware Parlay Lab. Real-time line movement tracking. Built in Detroit." Title → "VYNDR — The edge the books don't want you to have". NOTE: did NOT write "45 archetypes" (the spec said 45, but the codebase has 41 — used "proprietary archetypes" to stay accurate). - **Dynamic OG image** (`app/opengraph-image.tsx` + `twitter-image.tsx` re-export): 1200x630, #06060B bg, VYNDR wordmark (green R), "The books have every advantage. / We built this to give it back." + feature row. Node runtime (NOT edge — this is a self-hosted standalone build). Both routes prerender to a real PNG at build. - The stale static `/og-image.png` reference was removed from metadata so the file-based convention owns the image (no duplicate/conflicting og:image tag). ## Session 52 (2026-06-19) — SHIPPED ✅ TEASER + INFRA VERIFICATION Backend 2234 → **2239 tests** (+5), 192 suites. Web build clean (exit 0). ### Phase 1 — Push-to-Book teaser (feature NOT live; teaser only) - `StatStrip`: a `BookItTeaser` ("BOOK IT ⟶", hover tooltip "Push-to-Book coming soon — connect your sportsbook") after the parlay "+" on every graded prop. - `GradeResultCard`: a "PUSH-TO-BOOK · COMING SOON" footer section. ### Phase 2 — infrastructure verification - `snapshotScheduler` now logs BOTH states: armed (`[snapshotScheduler] armed — SNAPSHOT_CRON=1, hours=…`) and disarmed (so container logs disambiguate off-vs-crashed). Test asserts the armed log. - NEW `GET /api/internal/snapshot/status` (internal-key gated): `{ cron_armed, cron_hours_utc, last_snapshot:{sport:{updated_at,gradeCount,deltaCount}}, redis_keys:{…:bool}, ticker_count }`. The single probe to verify the pipeline post-deploy. - REDIS PERSISTENCE FINDING: the app uses `REDIS_URL` (ioredis) — AOF/RDB persistence is a server-side (Coolify Redis) config the app can't set/verify. If snapshot keys vanish on restart, enable persistence on the Redis instance. ### Phase 3 — delta pipeline (verified sound; no fix needed) `runSnapshot` already reads `:latest` as prev → `computeLineDeltas(enriched, prev.grades)` → writes old→`:previous`, new→`:latest`. So deltas populate on the 2nd+ run. Added an opt-in debug log (`SNAPSHOT_DEBUG=1`) + a trace test asserting `:previous` is preserved verbatim and the delta math is correct. ## Session 51 (2026-06-19) — SHIPPED ✅ TEAM HUB ## Session 51 (2026-06-19) — SHIPPED ✅ TEAM HUB Backend 2215 → **2234 tests** (+19), 190 suites. Web build clean (exit 0). New routes: `/team/[abbr]`, `/api/team/[abbr]`. ### Phase 1 — team data API - `mlbStatsAdapter` gained `getTeams()` / `resolveTeam(abbr)` (statsapi `/teams?sportId=1`, cached 24h, abbr→id) + `getTeamRoster(teamId)` (active roster, cached 6h). - `src/services/teamService.js getTeamHub(sport, abbr)` assembles the hub: resolve team → roster → per-player season stats (bounded concurrency 8, reuses playerIntelService mappers) → archetype (from the snapshot grade, else classify) → tonight's graded props (from `grades:{sport}`). Whole result cached 15 min. MLB = real; NBA/WNBA = a snapshot-built partial roster (graceful note). - `GET /api/team/:abbr` (public, 404 on unknown MLB team) + Next proxy. ### Phase 2 — Team Hub page `team/[abbr]/page.tsx` (server, `generateMetadata`) + `TeamHub.tsx` (client): team header + sport badge, sort (archetype / graded / A–Z), archetype filter chips, roster rows (archetype badge + player link + position + horizontal stats + graded props with grade badges + parlay "+"), "No active props" greyed state, loading/error states. ### Phase 3 — game card team links + back nav `vyndr/GameCard` team abbreviations are now `TeamLink`s → `/team/:abbr?sport=` (green hover, stops propagation from the open-game handler). Team Hub has "← Back to Slate". ## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB ## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB Backend 2185 → **2215 tests** (+30), 187 suites. Web build clean (exit 0). ### Phase 1 — correlation-score model (parlayService.js, ADDED to S28 funcs) Numeric, GAME-AWARE model: `correlationScore(l1,l2)` → 0.7 same-player/game / 0.4 same-team/game / 0.2 same-game-diff-team / 0.0 diff-game. `combinedGrade` = leg-grade avg penalized by `avgCorrelation * 0.5`. `estimatedPayout` = product of per-grade fair odds × (1 − avgCorrelation) discount. `correlationWarning` → "⚠ N legs from {TEAM}…". `gradeParlay` bundles it all. ### Phase 2 — POST /api/parlay/grade Returns `{ combined, correlation, payout, legs }` (2–6 legs, public/stateless). The Next `/api/parlay/grade` proxy was forwarding to the wrong upstream (`/api/scan/parlay`) — fixed to the new route. ### Phase 3 — ParlayContext extended `ParlayLeg` gained team/game/archetype; MAX_LEGS now 6 with a tier-aware `maxLegs` (set by the panel). Auto-grades the slip (debounced) via the endpoint when legs ≥ 2 → live `combined`/`correlation`/`payout`. + `hasLeg`/`legKey`/`atCap`. ### Phase 4 — "+" buttons `StatStrip` renders a "+"/"✓" per graded prop (toggles add/remove); `vyndr/GameCard` wires it via `useParlay` (builds a leg with team + game id). `GradeResultCard`'s existing "Add to Parlay" feeds the same context from the scan page. ### Phase 5 — ParlayPanel (replaces legacy ParlayTray in the layout) Bottom slide-up: legs (archetype + grade + remove), correlation warning, combined grade, est. payout, CLEAR ALL + a floating leg-count badge (bottom-right) when closed. Tier-gated: free 2 legs (payout blurred → Desk upgrade), Analyst 4, Desk 6. ## Session 49 (2026-06-19) — SHIPPED ✅ ONBOARDING FLOW ## Session 49 (2026-06-19) — SHIPPED ✅ ONBOARDING FLOW Backend 2156 → **2185 tests** (+29), 184 suites. Web build clean (exit 0). New routes: `/onboarding`, `/api/preferences`. ### Phase 1 — name micro-fixes (closes the normalization arc) `playerName.js` (both copies): collapse adjacent single-letter words ("J C Escarra" → "JC Escarra", display + key) + added `mickey: 'michael'`. ### Phases 2–5 — onboarding, end-to-end - **Storage:** Supabase `user_metadata.preferences` — NO migration. Shape: `{ sports[], books[], weekly_limit, onboarding_complete }`. - **API:** `src/routes/preferences.js` GET/POST (requireAuth; service-client admin getUserById/updateUserById; POST is a PARTIAL merge + sanitized) + `/api/preferences` Next proxy (forwards the bearer). - **Page:** `web/src/app/onboarding/page.tsx` — 3 self-contained steps (sports [≥1 required] → books [skip] → weekly bankroll [presets + custom + skip]) → "SIGNAL ACTIVE / You're locked in" → POST `onboarding_complete:true` → 2s → `/dashboard`. Redirects to /login when unauthenticated. - **Redirect:** the dashboard fetches `/api/preferences` (fresh — avoids stale session metadata); if `onboarding_complete !== true` AND the user is NEW (`created_at >= 2026-06-19 cutoff`), → `/onboarding`. Never fires while auth is loading (would bounce unauthenticated users). Existing users are exempt. - **Personalization:** the Slate defaults its tab to `prefs.sports[0]`; preferred books are highlighted (green glow) in each game card's lines grid (`isPreferredBook` in `lib/books.js`, threaded dashboard → Slate → GameCard). - **Settings:** a PREFERENCES section loads (GET) + edits + saves (POST) sports, books, and weekly limit. ## Session 48 (2026-06-19) — SHIPPED ✅ NORMALIZATION AT EVERY LAYER ## Session 48 (2026-06-19) — SHIPPED ✅ NORMALIZATION AT EVERY LAYER The normalizer functions were correct (S47) but raw names still flowed through paths that skipped them. Traced top-down, fixed each. Backend 2149 → **2156 tests** (+7), 181 suites. Web build clean (exit 0). ### The trace (3 leaking paths) 1. **Snapshot grades** stored RAW player names + no dedup → Explore/leaders/ profile/GameCard-overlay all saw variants. 2. **Game-card strips** merged the player but pushed EVERY variant prop → "Ks 5.5 AND Ks 3.5" duplicate rows. 3. **Scan player grid** (`tonightsPlayers`) grouped by raw `p.player` → variant tiles. ### Fixes - **2a (source chokepoint):** `snapshotService.runSnapshot` now normalizes each grade's player to the de-dotted display AND dedupes to ONE grade per normalized player+stat (highest confidence) BEFORE writing `grades:{sport}` + `snapshot:{sport}:latest`. Every consumer now gets clean, merged names for free. - **2b:** `buildPlayerStripsFromProps` dedupes a player's props by stat (prefers the graded over an awaiting row) → one row per stat. - **2c:** scan `tonightsPlayers` groups by `nameKey`, displays `normalizeName().display`. - **3 (profile usage/rest):** the "+0%"/"—" was the PLAYER PROFILE's `buildIntel` (separate from the grade card's `buildIntelFields`, which already works — the full feature merge carries ab_per_game/rest_days, NOT filtered by FEATURE_NAMES which is meta-only). `resolvePlayerStats` (MLB) now attaches real `usage` (AB/G) + `rest` (B2B/Xd) and `buildIntel` renders them; REST default is now "—" not "+0%". ## Session 47 (2026-06-19) — SHIPPED ✅ NAME NORM + INTEL + TICKER POLISH ## Session 47 (2026-06-19) — SHIPPED ✅ NAME NORM + INTEL + TICKER POLISH Backend 2122 → **2149 tests** (+27), 179 suites. Web build clean (exit 0). ### Phase 1 — complete name normalization `playerName.js` (both copies) gained: parenthetical-tag strip ("Jose Fermin (STL)" → "Jose Fermin"), a ~50-entry NICKNAMES table resolved in `nameKey` ("Matt"↔ "Matthew", "Mike"↔"Michael"), and the existing accent-fold now verified for Iván/Ivan, José/Jose. The slate strip now DISPLAYS the normalized de-dotted name (`buildPlayerStripsFromProps` uses `normalizeName().display`) — "A.J. Ewing" shows as "AJ Ewing", not the raw PropLine string. ### Phase 2 — complete VYNDR INTELLIGENCE for MLB The audit showed only Form. `mlbGameLogFeatures` now also derives `rest_days` (days off between the two most recent game-log dates; 0 = B2B, matching the NBA convention) and `ab_per_game` (the MLB "usage" equivalent). `buildIntelFields` renders `usage` as "X AB/G", `rest` as "B2B"/"Xd rest", and a matchup grade from `bvp_advantage` when no opp rank. So MLB grade cards now show Form + Usage + Rest (+ Matchup when available). ### Phase 3 — ticker SCAN dedup `pushTickerItems` keeps only the LATEST SCAN per sport (drops prior SCAN events for any sport with a fresh scan; parses the sport from a `sport` field or the text prefix for legacy items). MOVE/GRADE events preserved; cap stays 50. ### Phase 4 — archetype threshold BOMBER's HR thresholds prorated for mid-season (`hr>=15` strong / `hr>=10` moderate, was `>=20`/`>=15`), so June sluggers (Schwarber/Harper ~17-18 HR) classify as BOMBER instead of FLEX/DRIVER. `computeLineDeltas` re-verified structurally sound (deltas populate on the 2nd+ snapshot). ## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES ## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES Backend 2100 → **2122 tests** (+22), 176 suites. Web build clean (exit 0). ### Phase 1 — grade card intel (ROOT CAUSE) The STAT CONTEXT + VYNDR INTELLIGENCE sections were empty for MLB because `gameLogService.getGameLogs` is NBA/WNBA-only (offline Python service) — MLB props NEVER got `l5_avg`/`l20_avg`, so `buildIntelFields` always returned `{}`. FIX: `featureCache.gameLogFeatures` now has an MLB branch that derives l5/l10/l20 averages from `mlbStatsAdapter.getPlayerStats` (free statsapi.mlb.com) via the pure `mlbGameLogFeatures` + an MLB stat_type→game-log-field map. `buildIntelFields(features, opts)` also gained `playerStats`/`projection` fallbacks so partial intel renders (Sessions 43/44 had the wiring; the engine just never produced the values for MLB). ### Phase 2 — player name normalization `src/utils/playerName.js` + `web/src/lib/playerName.js` (identical; cross-checked by a test): `normalizeName(raw)` → `{ display, key }`. Strips periods, de-dots suffixes, accent-folds the key. Applied in `snapshotService` (grouping/deltas), `slateAdapter` (grade index + `buildPlayerStripsFromProps` merges variants, displays the longest), and `playerIntelService.sanitizePlayerName`/`normName`. "A.J. Ewing"/"AJ Ewing" and "Jazz Chisholm"/"Jazz Chisholm Jr." now merge. ### Phase 3 — MLB starting pitchers The ESPN schedule lacks probable pitchers. NEW `GET /api/schedule/:sport/pitchers` (MLB) → `probablePitchers` service wrapping `mlbStatsAdapter.getScheduleWithPitchers` + best-effort season ERA. The Slate fetches it, builds a team→pitcher map (`slateAdapter.buildPitcherMap`/`pitchersForGameTeams`, matched by full name + mascot), and attaches `pitchers` to MLB GameCardData. + Next proxy. ### Phase 4 — verify `computeLineDeltas` confirmed structurally sound (S45 tests); `deltas: 0` in the audit was just the first snapshot (no previous to diff). BACKEND_HANDOFF.md updated. ## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE ## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE The on-demand grade model is retired. Backend 2061 → **2100 tests** (+39), 173 suites. Web build clean (exit 0). ### Phase 1 — snapshotService (orchestration of existing services) `src/services/snapshotService.js` `runSnapshot(sport)`: getOdds (PropLine rotation) → gradeAndCacheSlate (captured via injected cacheSet) → classify each player's archetype (resolvePlayerStats + archetypeService, pure math) → attach `gradedAt {line, odds, timestamp}` (LOCKED) → compute line deltas vs the previous snapshot → write `snapshot:{sport}:latest|previous` + `grades:{sport}` → emit ticker events. `runAllSnapshots()` loops mlb/nba/wnba/soccer. Everything injectable → fully unit-tested with zero network. Deltas: `toward` = market confirming our side, `away` = opposing; noise filtered <0.5. ### Phase 2 — internal API + cron + ticker API - `POST /api/internal/snapshot/:sport` + `/snapshot/all` (existing `requireInternalAuth`; `/all` registered first so it isn't captured as a sport). - `GET /api/ticker` (public, cache-only, merges `TICKER_MANUAL` pins) + Next proxy. - In-process cron `src/snapshotScheduler.js` (gated `SNAPSHOT_CRON=1`, UTC hours 14,19,22,1,3 = 10AM/3PM/6PM/9PM/11PM ET), armed in server.js. No new dep — 1-min unref'd interval, fires once per slot. Prefer external n8n cron hitting the internal endpoint for multi-replica deploys. ### Phase 3 — GameCard swap (THE product shift) The live Slate now renders `vyndr/GameCard` (legacy kept for TYPES only). Reads `GET /api/snapshot/:sport` (cache-only, public; falls back to `grades:{sport}`), overlays the locked grades onto each game's odds-derived props via `slateAdapter.buildPlayerStripsFromProps` → player name ONCE + archetype badge + locked grade + "Graded Xh ago at -115 · Current 2.5 · ▲ TOWARD +1.0" line delta. Ungraded props → "Awaiting next scan" (NO Read button). The on-demand onGrade flow was deleted. ### Phase 4 — live ticker `vyndr/Ticker` now polls `/api/ticker` every 30s (graceful: keeps the passed hardcoded items as initial/fallback, never blanks). Tag colors: A+/A/SCAN green, MOVE/CASCADE amber, ALERT white. ### Phase 5 — NBA/WNBA ESPN fallback `espnStatsAdapter.getSeasonAverages(name, sport)` — free public ESPN stats, DEFENSIVE (`parseAthleteStats` returns null on any unrecognized shape → caller degrades to found:false; never a wrong-but-confident number). Wired as the NBA/WNBA fallback in `resolvePlayerStats` after the offline Python service. Note: the live ESPN athlete-stats shape may need tuning against production — the parser is tolerant and tested against a representative payload. ### Env vars (set in Coolify) - `PROPLINE_API_KEY_1/2/3` — PropLine props rotation (snapshot odds source). - `VYNDR_INTERNAL_KEY` — the internal-auth secret (header `x-internal-key`) for the snapshot trigger. (This is the existing key, NOT a new INTERNAL_AUTH_TOKEN.) - `SNAPSHOT_CRON=1` — arm the in-process scheduler (omit if using n8n). - `TICKER_MANUAL` — JSON array of editorial pins, e.g. `[{"tag":"ALERT","text":"VYNDR 2.0 is live."}]`. ## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE ## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE Wired existing backend work into the pages users see + renamed archetypes to VYNDR Originals. Backend 2045 → **2061 tests** (+16), 167 suites. Web build clean (exit 0). `BACKEND_HANDOFF.md` added as the canonical data contract. ### VYNDR Original archetype rename (proprietary names) All 41 archetypes renamed in `archetypeService.js` + `lib/archetypes.js` + `ArchetypeBadge`, each keeping `legacyName`/`legacy` (never displayed; resolves for stale data). NBA: TORCH, CONDUCTOR, FORTRESS, ARTILLERY, SURGE, DUAL THREAT, CONNECTOR, FASTBREAK, PAINT BOSS, LOCKDOWN, SWITCHBOARD, ARCHITECT, PISTON, SENTINEL, IGNITER. WNBA-unique: DISTRIBUTOR, SHIELD, RANGE, SPARK, ANCHOR. MLB: BOMBER, BRUSH, DRIVER, ALPHA, WHIFF, GHOST, HYBRID, FLEX, WORKHORSE, CATALYST, MIRROR, HAMMER, SINKER, BRIDGE, SWITCH. Soccer: FINISHER, MAESTRO, TOWER, MOTOR, BLADE, WALL. **MLB note:** the old taxonomy had two power hitters (POWER PULL/POWER SLUGGER) but the new set has one power name (BOMBER), so BOMBER now fires for any high-HR bat (Judge → BOMBER per the audit checklist) and the freed slot became a real WHIFF strikeout-artist pitcher (improves pitcher coverage). `getArchetype`/`archetypeInfo` resolve legacy names → VYNDR Originals. ### Phase 2 — grade-card intel now populates (the real bug) The chain engine→tierGating→/api/scan proxy already PRESERVED the intel fields (all spread `...result`/`...data`). The ONE broken link: `scan/page.tsx` called `mapScanToGradeResult` with a hardcoded field subset and DROPPED season_avg/ form/usage/matchup_grade/etc. Now forwards them (+ extended `ScanResponse`), so STAT CONTEXT + VYNDR INTELLIGENCE sections light up on a real MLB grade. ### Phase 3 — stale-game filtering `slateAdapter.isRelevantGame(game, now)`: upcoming/live always show; a COMPLETED game is dropped once >24h old (no more 5-day-old FINALs). Applied in `Slate`'s `filteredGames`. Schedule TTL was already 60s (≤30min) — no change. ### Phase 4 — landing copy `Features.tsx` rewritten: no more "Point-biserial", "Zone 14", "ABS intelligence", "Auto-calibrating", "Phi-coefficient". User-facing benefit copy (Player DNA archetypes / Self-improving model / Lineup intel before tip-off / Deep pitcher-batter matchups). ### Phase 5 — depth chart proxies (404 fix) Added the missing Next proxies `/api/stats/lineup/[team]`, `/depth/[team]`, `/cascade/[player]` → return JSON, not 404. ### Phase 1 — GameCard swap DEFERRED (decision: Kev) The live Slate keeps the legacy "Read"/on-demand grade card as a **temporary bridge**. The vyndr/GameCard swap lands WITH the snapshot pipeline (next session): the on-demand "Read" model is being retired for a pre-graded snapshot model (full slate graded at scheduled intervals, grades locked to the line), and the new card is designed for that. Swapping now would remove grading + show blank cards (grades cache unpopulated). BookChip brand colors already render in the legacy card (S43). ## Session 43 (2026-06-18) — SHIPPED ✅ DATA PIPELINE + AUDIT FIXES ## Session 43 (2026-06-18) — SHIPPED ✅ DATA PIPELINE + AUDIT FIXES Post-S42 Chrome audit: architecture renders but no real data flowed (every player `found:false`, leaders empty, grade intel hidden) + a P0 dropdown bug. Backend 2011 → **2045 tests** (+34), 163 suites. Web build clean (exit 0). ### Phase 1 — P0 bug fixes - **Avatar/More dropdown z-index** — the `