diff --git a/src/routes/content.js b/src/routes/content.js index 7e4a08d..0cf26f0 100644 --- a/src/routes/content.js +++ b/src/routes/content.js @@ -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; diff --git a/tests/integration/contentAggregator.test.js b/tests/integration/contentAggregator.test.js new file mode 100644 index 0000000..ee2c592 --- /dev/null +++ b/tests/integration/contentAggregator.test.js @@ -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'); + }); +}); diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 816d004..942d841 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -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 3–4s "LOADING THE SLATE" first paint was THIS +// page blocking its entire render on Supabase auth initialization +// (`loading || user`), even for anonymous visitors who were never going to +// redirect. Synchronous localStorage check instead: only a visitor who +// actually HAS a stored session (and will redirect to /dashboard) sees the +// suppressed render; anonymous traffic paints the hero immediately. +function hasStoredSession(): boolean { + if (typeof window === 'undefined') return false; + try { + for (let i = 0; i < window.localStorage.length; i += 1) { + const k = window.localStorage.key(i) || ''; + if (/^sb-.*-auth-token$/.test(k) || k === 'sb-token') return true; + } + } catch { /* storage blocked → treat as anonymous */ } + return false; +} + export default function Home() { const { user, loading } = useAuth(); const router = useRouter(); + const [maybeSignedIn] = useState(() => 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 (

diff --git a/web/src/app/player/[name]/layout.tsx b/web/src/app/player/[name]/layout.tsx new file mode 100644 index 0000000..00b71e5 --- /dev/null +++ b/web/src/app/player/[name]/layout.tsx @@ -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 { + 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; +} diff --git a/web/src/app/player/[name]/opengraph-image.tsx b/web/src/app/player/[name]/opengraph-image.tsx new file mode 100644 index 0000000..1fe890f --- /dev/null +++ b/web/src/app/player/[name]/opengraph-image.tsx @@ -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( + ( +

+
+
+
+ PLAYER INTELLIGENCE +
+
+ {player} +
+
+ Archetype DNA · Active streaks · Prop DNA · Settled record +
+
+
+
+ VYND + R +
+
+ EVERY NUMBER WITH CONTEXT +
+
+
+ ), + { ...size }, + ); +} diff --git a/web/src/components/TopSignals.tsx b/web/src/components/TopSignals.tsx index 7f29c25..a5382dc 100644 --- a/web/src/components/TopSignals.tsx +++ b/web/src/components/TopSignals.tsx @@ -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(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 (
- TONIGHT'S TOP SIGNALS - · LIVE FROM THE SLATE + {headerText} + {headerSub}
@@ -104,6 +126,13 @@ export default function TopSignals() {
{player}
{shortStat(g.stat_type || g.stat)} {side}{g.line} + {/* Outcome chip on the proof-strip fallback — misses included. */} + {g.outcome && ( + + {g.outcome.result === 'hit' ? '✓ HIT' : g.outcome.result === 'miss' ? '✕ MISS' : '– PUSH'} + {g.outcome.actual != null ? ` (${g.outcome.actual})` : ''} + + )}
);