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:
@@ -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="A–Z" />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user