Session 51: Complete Team Hub (2234 tests)

Research-depth team view: /team/[abbr] with roster, archetypes, stats, props.

- Team API: mlbStatsAdapter.getTeams/resolveTeam/getTeamRoster (statsapi, abbr→id
  + active roster, cached). teamService.getTeamHub assembles roster → per-player
  season stats (bounded concurrency) + archetype (snapshot grade or classify) +
  tonight's graded props from grades:{sport}; whole hub cached 15min. MLB real;
  NBA/WNBA graceful snapshot roster. GET /api/team/:abbr (404 unknown) + proxy.
- Team Hub page: server page.tsx (generateMetadata) + TeamHub client — header,
  sort (archetype/graded/A-Z), archetype filter chips, roster rows (archetype +
  player link + position + stats + graded props + parlay "+"), "No active props"
  greyed state, loading/error.
- Game cards: team abbreviations are now TeamLinks → /team/:abbr?sport= (green
  hover, stops propagation). Team Hub has "← Back to Slate".

Backend 2215 -> 2234 tests (+19), 190 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-19 11:55:10 -04:00
parent f1956dc953
commit f0674ca07d
14 changed files with 696 additions and 4 deletions
+175
View File
@@ -0,0 +1,175 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import SportBadge from '@/components/vyndr/SportBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import { playerHref } from '@/lib/playerHref';
import { useParlay, legKey } from '@/contexts/ParlayContext';
interface RosterProp { stat: string; line: number | string; side: string; grade: string }
interface RosterPlayer {
player: string; position: string | null;
archetype: { primary: string } | null;
stats: { k: string; v: string }[];
props: RosterProp[];
propCount: number;
}
interface TeamHubData {
team: { name: string; abbr: string; sport: string };
roster: RosterPlayer[];
record?: { wins: number; losses: number };
note?: string | null;
}
type SortKey = 'archetype' | 'props' | 'name';
export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }) {
const router = useRouter();
const { addLeg, removeLeg, legs, hasLeg } = useParlay();
const [data, setData] = useState<TeamHubData | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [sortBy, setSortBy] = useState<SortKey>('props');
const [archetypeFilter, setArchetypeFilter] = useState<string | null>(null);
useEffect(() => {
let active = true;
setState('loading');
fetch(`/api/team/${encodeURIComponent(abbr)}?sport=${encodeURIComponent(sport)}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => { if (active) { setData(d); setState('ready'); } })
.catch(() => { if (active) setState('error'); });
return () => { active = false; };
}, [abbr, sport]);
const archetypesPresent = useMemo(() => {
const set = new Set<string>();
(data?.roster || []).forEach((p) => { if (p.archetype?.primary) set.add(p.archetype.primary); });
return [...set].sort();
}, [data]);
const roster = useMemo(() => {
let list = [...(data?.roster || [])];
if (archetypeFilter) list = list.filter((p) => p.archetype?.primary === archetypeFilter);
list.sort((a, b) => {
if (sortBy === 'name') return a.player.localeCompare(b.player);
if (sortBy === 'props') return b.propCount - a.propCount || a.player.localeCompare(b.player);
// archetype: named first (A-Z), unclassified last
const aa = a.archetype?.primary || 'zzz';
const bb = b.archetype?.primary || 'zzz';
return aa.localeCompare(bb) || b.propCount - a.propCount;
});
return list;
}, [data, sortBy, archetypeFilter]);
if (state === 'loading') {
return <section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><p className="mono" style={{ color: 'var(--text-2)' }}>Loading team intelligence</p></section>;
}
if (state === 'error' || !data) {
return (
<section style={{ maxWidth: 600, margin: '0 auto', padding: '40px 16px' }}>
<p className="mono" style={{ color: 'var(--miss)' }}>Team not found.</p>
<a href="/dashboard" className="mono" style={{ color: 'var(--g-a)', fontSize: 13 }}> Back to Slate</a>
</section>
);
}
const SortBtn = ({ k, label }: { k: SortKey; label: string }) => (
<button type="button" onClick={() => setSortBy(k)} className="mono"
style={{ cursor: 'pointer', padding: '6px 11px', borderRadius: 7, fontSize: 11, fontWeight: 700, letterSpacing: '0.04em',
background: sortBy === k ? 'color-mix(in srgb, var(--g-a) 14%, transparent)' : 'var(--bg-2)',
border: `1px solid ${sortBy === k ? 'var(--g-a)' : 'var(--border-hi)'}`, color: sortBy === k ? 'var(--g-a)' : 'var(--text-1)' }}>
{label}
</button>
);
const onPropClick = (p: RosterPlayer, pr: RosterProp) => {
const sp = (data.team.sport || 'mlb').toUpperCase();
const leg = {
sport: (sp === 'MLB' || sp === 'WNBA' ? sp : 'NBA') as 'NBA' | 'MLB' | 'WNBA',
player: p.player, team: data.team.abbr, game: '', archetype: p.archetype?.primary,
stat: String(pr.stat), line: Number(pr.line) || 0,
direction: (String(pr.side).toUpperCase() === 'U' ? 'under' : 'over') as 'over' | 'under',
grade: String(pr.grade || 'C'), confidence: 60,
};
const k = legKey(leg);
const existing = legs.find((l) => legKey(l) === k);
if (existing) removeLeg(existing.id); else addLeg(leg);
};
const propActive = (p: RosterPlayer, pr: RosterProp) =>
hasLeg(legKey({ player: p.player, stat: String(pr.stat), line: Number(pr.line) || 0, direction: String(pr.side).toUpperCase() === 'U' ? 'under' : 'over' }));
return (
<section style={{ maxWidth: 920, margin: '0 auto', padding: '20px 16px 120px' }}>
<a href="/dashboard" className="mono" style={{ fontSize: 12, color: 'var(--text-1)', textDecoration: 'none' }}> Back to Slate</a>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, margin: '14px 0 22px', flexWrap: 'wrap' }}>
<SportBadge sport={data.team.sport} />
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800, letterSpacing: '-0.015em' }}>{data.team.name}</h1>
<span className="mono" style={{ fontSize: 13, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{data.team.abbr}</span>
{data.record && <span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>{data.record.wins}-{data.record.losses}</span>}
</div>
{data.note && <p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 16 }}>{data.note}</p>}
{/* Controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.06em' }}>SORT</span>
<SortBtn k="archetype" label="Archetype" /><SortBtn k="props" label="Graded" /><SortBtn k="name" label="AZ" />
{archetypesPresent.length > 0 && <span style={{ width: 1, height: 18, background: 'var(--border-hi)', margin: '0 4px' }} />}
{archetypesPresent.map((a) => (
<button key={a} type="button" onClick={() => setArchetypeFilter((f) => (f === a ? null : a))}>
<ArchetypeBadge archetype={a} size="sm" variant={archetypeFilter === a ? 'full' : 'tint'} />
</button>
))}
</div>
{/* Roster */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{roster.map((p, i) => {
const noProps = p.propCount === 0;
return (
<div key={i} style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', opacity: noProps ? 0.6 : 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap', marginBottom: 8 }}>
{p.archetype && <ArchetypeBadge archetype={p.archetype.primary} size="sm" variant="full" />}
<a href={playerHref(p.player, data.team.sport)} style={{ fontWeight: 700, fontSize: 15, color: '#fff', textDecoration: 'none' }}>{p.player}</a>
{p.position && <span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{p.position}</span>}
</div>
{p.stats.length > 0 && (
<div className="mono game-lines-grid" style={{ fontSize: 12, color: 'var(--text-0)', marginBottom: noProps ? 0 : 8 }}>
{p.stats.map((s, j) => (
<span key={j}>{j > 0 && <span style={{ color: '#3A3A48', margin: '0 8px' }}>·</span>}{s.v} {s.k}</span>
))}
</div>
)}
{noProps ? (
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', fontStyle: 'italic' }}>No active props</div>
) : (
<div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{p.props.map((pr, j) => {
const active = propActive(p, pr);
return (
<span key={j} className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
{pr.stat} {pr.side}{pr.line} <GradeBadge grade={pr.grade} size="sm" />
<button type="button" onClick={() => onPropClick(p, pr)} title={active ? 'Remove from Parlay' : 'Add to Parlay'}
className="mono" style={{ cursor: 'pointer', width: 20, height: 20, borderRadius: 5, lineHeight: 1,
background: active ? 'color-mix(in srgb, var(--g-a) 18%, transparent)' : 'var(--bg-2)',
border: `1px solid ${active ? 'var(--g-a)' : 'var(--border-hi)'}`, color: 'var(--g-a)', fontSize: 13, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
{active ? '✓' : '+'}
</button>
</span>
);
})}
</div>
)}
</div>
);
})}
{roster.length === 0 && <p className="mono" style={{ color: 'var(--text-2)' }}>No players match this filter.</p>}
</div>
</section>
);
}