Session 44: Make it visible — VYNDR archetype names, grade intel, schedule fix, landing page (2061 tests)

Frontend + wiring only. Wires existing backend into the pages users see.

- VYNDR Original archetype rename (41) across archetypeService.js + lib/
  archetypes.js + ArchetypeBadge, each keeping legacyName (resolves stale data).
  Judge -> BOMBER. Old POWER PULL slot -> WHIFF strikeout-artist pitcher.
- BACKEND_HANDOFF.md: canonical frontend<->backend data contract.
- Grade card intel: scan/page.tsx now forwards the engine's intel fields
  (season_avg/form/usage/matchup_grade/archetype/...) into mapScanToGradeResult
  -> STAT CONTEXT + VYNDR INTELLIGENCE sections populate. The chain already
  preserved them (tierGating + /api/scan spread); the page was dropping them.
- Schedule freshness: slateAdapter.isRelevantGame drops completed games >24h
  old; Slate.filteredGames applies it. (TTL already 60s.)
- Landing: Features.tsx rewritten to user-facing copy (no Point-biserial/Zone
  14/ABS/Phi-coefficient).
- Depth chart Next proxies added (/api/stats/lineup|depth|cascade) - were 404.
- GameCard swap DEFERRED (Kev): legacy on-demand card stays as a bridge until
  the snapshot pipeline populates the grades cache; vyndr/GameCard swaps in then.

Backend 2045 -> 2061 tests (+16), 167 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-18 20:15:38 -04:00
parent 80683e71b4
commit 7969a4971a
26 changed files with 766 additions and 302 deletions
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Cascade proxy (Session 44) — forwards GET /api/stats/cascade/:player to Express. */
export async function GET(req: NextRequest, ctx: { params: Promise<{ player: string }> }) {
const { player } = await ctx.params;
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/stats/cascade/${encodeURIComponent(player)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ error: 'Cascade service is unreachable. Try again in a moment.' }, { status: 502 });
}
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Depth chart proxy (Session 44) — forwards GET /api/stats/depth/:team to Express. */
export async function GET(req: NextRequest, ctx: { params: Promise<{ team: string }> }) {
const { team } = await ctx.params;
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/stats/depth/${encodeURIComponent(team)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ error: 'Depth chart service is unreachable. Try again in a moment.' }, { status: 502 });
}
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Lineup proxy (Session 44) — forwards GET /api/stats/lineup/:team to Express. */
export async function GET(req: NextRequest, ctx: { params: Promise<{ team: string }> }) {
const { team } = await ctx.params;
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/stats/lineup/${encodeURIComponent(team)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ error: 'Lineup service is unreachable. Try again in a moment.' }, { status: 502 });
}
}
+24
View File
@@ -47,6 +47,18 @@ interface ScanResponse {
tier: 'free' | 'analyst' | 'desk';
error?: string;
upgrade?: { tier: string; price: number };
// Session 43/44 — Player-Intelligence fields the engine attaches; the grade
// card's STAT CONTEXT + VYNDR INTELLIGENCE sections read these.
season_avg?: number;
last10_avg?: number;
vs_opp_avg?: number;
form?: number;
usage?: string;
matchup_grade?: string;
rest?: string;
archetype?: string;
archetype_blend?: { archetype: string; weight: number }[];
prop_dna?: { reliable: string[]; volatile: string[] };
}
const NBA_STATS = [
@@ -689,6 +701,18 @@ export default function ScanPage() {
alt_lines: result.alt_lines,
kill_conditions: result.kill_conditions,
tier,
// Session 44 — forward the engine's intel fields so the grade
// card's STAT CONTEXT + VYNDR INTELLIGENCE sections populate.
season_avg: result.season_avg,
last10_avg: result.last10_avg,
vs_opp_avg: result.vs_opp_avg,
form: result.form,
usage: result.usage,
matchup_grade: result.matchup_grade,
rest: result.rest,
archetype: result.archetype,
archetype_blend: result.archetype_blend,
prop_dna: result.prop_dna,
}) as GradeResultData}
onAddToParlay={() => {
addLeg({
+10 -10
View File
@@ -1,33 +1,33 @@
const FEATURES = [
{
icon: '◆',
title: 'Multi-dimensional player archetypes',
body: 'Players aren\'t one thing. Our model scores every dimension — pitcher discipline, batter approach, NBA usage shape — and blends them per matchup.',
title: 'Player DNA archetypes',
body: 'Every player has a prop fingerprint. We classify it, show you which props are reliable vs volatile, and grade accordingly.',
},
{
icon: '↻',
title: 'Auto-calibrating engine',
body: 'Every resolved grade trains the next one. Point-biserial weight tuning, per-stat calibration, blind-spot detection. The model improves itself.',
title: 'Self-improving model',
body: 'Every resolved grade makes the next one sharper. The engine learns what works and corrects what doesn\'t.',
},
{
icon: '⚡',
title: 'Beat reporter intelligence',
body: 'Lineup intel from the people closest to the team — 30 minutes before tip. Trust-tiered, redistribution-aware, line-correlated.',
title: 'Lineup intel before tip-off',
body: 'Real-time lineup and injury data from trusted sources, giving you the edge before the books adjust.',
},
{
icon: '⊘',
title: 'Kill conditions',
body: 'We don\'t just grade the prop. We tell you what kills it. Six hard checks per read: minutes, sample, fatigue, blowout risk, splits, line conflict.',
body: 'Six hard checks on every prop. If minutes, fatigue, blowout risk, or splits say no, we flag it — even on an A grade.',
},
{
icon: '∿',
title: 'Parlay correlation math',
body: 'Phi-coefficient analysis catches the legs that secretly fight each other. The books love correlated unders. We surface them.',
body: 'We catch the legs that secretly fight each other. The books love correlated unders — we surface them before you tap.',
},
{
icon: '⌧',
title: 'ABS intelligence (MLB)',
body: 'The automated strike zone changes everything. Per-pitcher, per-batter discipline scoring. Zone 14 framing loss. Challenge math.',
title: 'Deep pitcher-batter matchups',
body: 'We score discipline, contact quality, and zone tendencies for every MLB matchup. Not just ERA.',
},
{
icon: '◯',
+6 -2
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import GameCard, { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { isRelevantGame } from '@/lib/slateAdapter';
import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the
// navigation system; streaks + hot lists layer ON TOP of the odds the
@@ -507,9 +508,12 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
// Filter pipeline — searchQuery applied to games + props.
const filteredGames = useMemo(() => {
if (!searchQuery.trim()) return games;
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never
// lingers on the dashboard. Upcoming + live always show.
const fresh = games.filter((g) => isRelevantGame(g));
if (!searchQuery.trim()) return fresh;
const q = searchQuery.toLowerCase();
return games
return fresh
.map((g) => {
const homeMatch = g.homeTeam.toLowerCase().includes(q);
const awayMatch = g.awayTeam.toLowerCase().includes(q);
+1 -1
View File
@@ -1,7 +1,7 @@
import { badgeStyle, glyphSvg } from '@/lib/archetypes';
interface ArchetypeBadgeProps {
archetype: string; // archetype name, e.g. "POWER PULL" (case-insensitive)
archetype: string; // VYNDR archetype name, e.g. "BOMBER" (case-insensitive; legacy names resolve too)
sport?: string; // nba/mlb/wnba/soccer — informational; styling is per-archetype
variant?: 'full' | 'ghost' | 'tint';
size?: 'sm' | 'md';
+58 -44
View File
@@ -33,60 +33,71 @@ const GLYPHS = {
postup: '<rect x="6.4" y="2.6" width="3.2" height="11" rx="1.6" fill="currentColor"/><circle cx="12" cy="5.2" r="1.7" fill="currentColor"/>',
};
/* name → { color, glyph, desc } — the design's MAP. */
/* name → { color, glyph, desc, legacy } — VYNDR Originals (Session 44).
Keys/colors/glyphs MUST match src/services/archetypeService.js ARCHETYPES.
`legacy` is the old descriptive label, kept for reference (never displayed). */
const ARCHETYPE_MAP = {
// NBA
'VOLUME SCORER': { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle' },
'FLOOR GENERAL': { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node' },
'TWO-WAY ANCHOR': { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield' },
'STRETCH BIG': { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target' },
'USAGE SPONGE': { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow' },
'COMBO GUARD': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin' },
'ROLE GLUE': { c: '#9499A8', d: 'Low-usage specialist', g: 'chain' },
'TRANSITION ENGINE': { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons' },
'POST SCORER': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup' },
'DEFENSIVE SPECIALIST': { c: '#6366F1', d: 'Perimeter stopper, low usage', g: 'shieldCheck' },
'POINT FORWARD': { c: '#38BDF8', d: 'Oversized primary creator', g: 'half' },
SLASHER: { c: '#FB923C', d: 'Rim-attacking, foul-drawing driver', g: 'slash' },
'RIM RUNNER': { c: '#F472B6', d: 'Lob and putback finisher', g: 'arc' },
'3-AND-D': { c: '#818CF8', d: 'Catch-and-shoot plus defense', g: 'crosshair' },
'SIXTH MAN': { c: '#FACC15', d: 'Bench scoring spark', g: 'bolt' },
TORCH: { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle', legacy: 'VOLUME SCORER' },
CONDUCTOR: { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node', legacy: 'FLOOR GENERAL' },
FORTRESS: { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield', legacy: 'TWO-WAY ANCHOR' },
ARTILLERY: { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target', legacy: 'STRETCH BIG' },
SURGE: { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow', legacy: 'USAGE SPONGE' },
'DUAL THREAT': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin', legacy: 'COMBO GUARD' },
CONNECTOR: { c: '#9499A8', d: 'Low-usage specialist', g: 'chain', legacy: 'ROLE GLUE' },
FASTBREAK: { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons', legacy: 'TRANSITION ENGINE' },
'PAINT BOSS': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup', legacy: 'POST SCORER' },
LOCKDOWN: { c: '#6366F1', d: 'Perimeter stopper, low usage', g: 'shieldCheck', legacy: 'DEFENSIVE SPECIALIST' },
SWITCHBOARD: { c: '#38BDF8', d: 'Oversized primary creator', g: 'half', legacy: 'POINT FORWARD' },
ARCHITECT: { c: '#FB923C', d: 'Self-created shot maker, rim-attacking', g: 'slash', legacy: 'SLASHER' },
PISTON: { c: '#F472B6', d: 'Lob and putback finisher', g: 'arc', legacy: 'RIM RUNNER' },
SENTINEL: { c: '#818CF8', d: 'Catch-and-shoot plus defense', g: 'crosshair', legacy: '3-AND-D' },
IGNITER: { c: '#FACC15', d: 'Bench scoring spark', g: 'bolt', legacy: 'SIXTH MAN' },
// WNBA-unique
'POST FACILITATOR': { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node' },
'TWO-WAY WING': { c: '#A78BFA', d: 'Two-way perimeter wing', g: 'shieldCheck' },
'STRETCH FORWARD': { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target' },
'SLASHING GUARD': { c: '#FB923C', d: 'Downhill driving guard', g: 'slash' },
'INTERIOR ANCHOR': { c: '#6366F1', d: 'Paint defender and rebounder', g: 'shield' },
DISTRIBUTOR: { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node', legacy: 'POST FACILITATOR' },
SHIELD: { c: '#A78BFA', d: 'Two-way perimeter forward', g: 'shieldCheck', legacy: 'TWO-WAY WING' },
RANGE: { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target', legacy: 'STRETCH FORWARD' },
SPARK: { c: '#FB923C', d: 'Downhill scoring guard', g: 'slash', legacy: 'SLASHING GUARD' },
ANCHOR: { c: '#6366F1', d: 'Dominant paint defender and rebounder', g: 'shield', legacy: 'INTERIOR ANCHOR' },
// MLB
'POWER PULL': { c: '#FF5C5C', d: 'HR-dependent, high strikeout power', g: 'batball' },
CONTACT: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair' },
'RUN PRODUCER': { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond' },
ACE: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star' },
'BULLPEN ARM': { c: '#FFB347', d: 'Short outings, high leverage', g: 'bolt' },
'SPEED THREAT': { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons' },
'TWO-WAY PLAYER': { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half' },
'UTILITY PLAYER': { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus' },
'INNINGS EATER': { c: '#818CF8', d: 'Durable, deep-start workhorse', g: 'clock' },
'POWER SLUGGER': { c: '#FF6B4A', d: 'All-fields power producer', g: 'triangle' },
'TABLE SETTER': { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine' },
'GAP HITTER': { c: '#34D399', d: 'Doubles and extra-base gaps', g: 'uparrow' },
CLOSER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock' },
SWINGMAN: { c: '#FBBF24', d: 'Spot starter and long relief', g: 'swap' },
'DEFENSIVE WIZARD': { c: '#6366F1', d: 'Glove-first defensive value', g: 'shieldCheck' },
BOMBER: { c: '#FF6B4A', d: 'Middle-of-the-order power producer', g: 'triangle', legacy: 'POWER SLUGGER' },
BRUSH: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair', legacy: 'CONTACT' },
DRIVER: { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond', legacy: 'RUN PRODUCER' },
ALPHA: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star', legacy: 'ACE' },
WHIFF: { c: '#FFB347', d: 'Bat-missing arm, high K with traffic', g: 'bolt', legacy: 'STRIKEOUT ARTIST' },
GHOST: { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons', legacy: 'SPEED THREAT' },
HYBRID: { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half', legacy: 'TWO-WAY PLAYER' },
FLEX: { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus', legacy: 'UTILITY PLAYER' },
WORKHORSE: { c: '#818CF8', d: 'Durable, deep-start arm', g: 'clock', legacy: 'INNINGS EATER' },
CATALYST: { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine', legacy: 'TABLE SETTER' },
MIRROR: { c: '#34D399', d: 'Gap-to-gap line-drive bat', g: 'uparrow', legacy: 'GAP HITTER' },
HAMMER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock', legacy: 'CLOSER' },
SINKER: { c: '#FBBF24', d: 'Groundball spot-starter / long relief', g: 'swap', legacy: 'SWINGMAN' },
BRIDGE: { c: '#6366F1', d: 'Setup / high-leverage middle relief', g: 'shieldCheck', legacy: 'BULLPEN ARM' },
SWITCH: { c: '#FF5C5C', d: 'Platoon-leveraged, glove-first bat', g: 'batball', legacy: 'DEFENSIVE WIZARD' },
// Soccer
POACHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair' },
CREATOR: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node' },
'TARGET MAN': { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle' },
'BOX-TO-BOX': { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons' },
'WING WIZARD': { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash' },
'SWEEPER KEEPER': { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield' },
FINISHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair', legacy: 'POACHER' },
MAESTRO: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node', legacy: 'CREATOR' },
TOWER: { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle', legacy: 'TARGET MAN' },
MOTOR: { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons', legacy: 'BOX-TO-BOX' },
BLADE: { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash', legacy: 'WING WIZARD' },
WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' },
};
const FALLBACK = { c: '#9499A8', d: '', g: '' };
// Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to
// its VYNDR Original (BOMBER) — defends any stale cached data post-rename.
const LEGACY_INDEX = {};
for (const [k, v] of Object.entries(ARCHETYPE_MAP)) {
if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k;
}
function archetypeInfo(name) {
const key = (name == null ? '' : String(name)).toUpperCase();
return ARCHETYPE_MAP[key] || FALLBACK;
if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key];
if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]];
return FALLBACK;
}
function archetypeColor(name) {
@@ -115,8 +126,11 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
} else {
textColor = info.c; bg = info.c + '1F'; borderColor = info.c + '52'; glyphColor = info.c;
}
// Display the canonical VYNDR name even if a legacy name was passed.
const upper = (name == null ? '' : String(name)).toUpperCase();
const canonical = ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper;
return {
name: (name == null ? '' : String(name)).toUpperCase(),
name: canonical,
desc: info.d,
glyph: info.g,
color: info.c,
+18
View File
@@ -175,6 +175,23 @@ function mapPitchers(game) {
};
}
/**
* Should this game still show on the slate (Session 44)? Upcoming + live games
* always show; a COMPLETED game is dropped once it's more than 24h old, so a
* 5-day-old FINAL never lingers on the dashboard. Unknown/missing date → keep
* (degrade open). `now` is injectable for tests.
*/
function isRelevantGame(game, now = Date.now()) {
if (!game) return false;
const state = String(game.state || game.status || '').toLowerCase();
const isFinal = state === 'final' || state === 'post' || state === 'closed' || state === 'complete';
if (!isFinal) return true;
const raw = game.date || game.gameTime || game.commence_time || game.startTime;
const t = raw ? new Date(raw).getTime() : NaN;
if (Number.isNaN(t)) return true; // no parseable date → don't hide
return (now - t) / 3_600_000 < 24;
}
module.exports = {
parseAmericanOdds,
detectBestLines,
@@ -183,4 +200,5 @@ module.exports = {
formatGameTime,
groupPropsByPlayer,
mapPitchers,
isRelevantGame,
};