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:
+1
-1
File diff suppressed because one or more lines are too long
@@ -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';
|
||||
|
||||
/** Team Hub proxy (Session 51) — forwards GET /api/team/:abbr to Express. */
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ abbr: string }> }) {
|
||||
const { abbr } = await ctx.params;
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/team/${encodeURIComponent(abbr)}${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: 'Team service unreachable.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from 'next';
|
||||
import TeamHub from './TeamHub';
|
||||
|
||||
/**
|
||||
* /team/[abbr] (Session 51) — Team Hub. Thin server wrapper so we get proper
|
||||
* page metadata; the interactive roster lives in the TeamHub client component.
|
||||
*/
|
||||
export async function generateMetadata({ params }: { params: Promise<{ abbr: string }> }): Promise<Metadata> {
|
||||
const { abbr } = await params;
|
||||
const a = String(abbr || '').toUpperCase();
|
||||
return {
|
||||
title: `${a} — Team Hub`,
|
||||
description: `${a} roster, player archetypes, season stats, and tonight's graded props on VYNDR.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TeamPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ abbr: string }>;
|
||||
searchParams: Promise<{ sport?: string }>;
|
||||
}) {
|
||||
const { abbr } = await params;
|
||||
const { sport } = await searchParams;
|
||||
return <TeamHub abbr={abbr} sport={(sport || 'mlb').toLowerCase()} />;
|
||||
}
|
||||
@@ -68,6 +68,24 @@ interface GameCardProps {
|
||||
preferredBooks?: string[];
|
||||
}
|
||||
|
||||
/** Clickable team abbreviation → /team/:abbr (Session 51). Stops propagation so
|
||||
* it doesn't trigger the card's open-game handler; green underline on hover. */
|
||||
function TeamLink({ abbr, sport }: { abbr: string; sport: string }) {
|
||||
if (!abbr) return <span>—</span>;
|
||||
return (
|
||||
<a
|
||||
href={`/team/${encodeURIComponent(abbr)}?sport=${encodeURIComponent(sport || 'mlb')}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ color: '#fff', textDecoration: 'none', borderBottom: '1px solid transparent' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--g-a)'; e.currentTarget.style.borderBottomColor = 'var(--g-a)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderBottomColor = 'transparent'; }}
|
||||
title={`${abbr} team hub`}
|
||||
>
|
||||
{abbr}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** A book-line cell with the Bloomberg pattern: best = green tint + green left
|
||||
* border, worst = subtle red. The #1 visual upgrade (§13). */
|
||||
function LineCell({ value, best, worst }: { value: string; best?: boolean; worst?: boolean }) {
|
||||
@@ -148,7 +166,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
<div onClick={() => onOpen && onOpen(g.id)} title="Open game detail" style={{ display: 'flex', alignItems: 'center', gap: 11, minWidth: 0, cursor: onOpen ? 'pointer' : 'default' }}>
|
||||
<SportBadge sport={g.sport} />
|
||||
<span className="mono" style={{ fontSize: 18, fontWeight: 700, letterSpacing: '0.01em' }}>
|
||||
{g.away.abbr} <span style={{ color: 'var(--text-2)', fontWeight: 400 }}>@</span> {g.home.abbr}
|
||||
<TeamLink abbr={g.away.abbr} sport={g.sport} /> <span style={{ color: 'var(--text-2)', fontWeight: 400 }}>@</span> <TeamLink abbr={g.home.abbr} sport={g.sport} />
|
||||
</span>
|
||||
{g.live && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginLeft: 2 }}>
|
||||
|
||||
Reference in New Issue
Block a user