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>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+22
-4
@@ -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<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' }}>
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user