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
+32 -2
View File
@@ -4,8 +4,38 @@
2026-06-18
## Current Phase
SHIP BUILD v50.0 — Parlay Lab: correlation-aware combined grading, live slip,
"+" on every graded prop, tier-gated panel. The Desk-tier differentiator.
SHIP BUILD v51.0 — Team Hub: /team/[abbr] roster with archetypes, season stats,
graded props; clickable team abbrs on every game card. Research depth.
## Session 51 (2026-06-19) — SHIPPED ✅ TEAM HUB
Backend 2215 → **2234 tests** (+19), 190 suites. Web build clean (exit 0).
New routes: `/team/[abbr]`, `/api/team/[abbr]`.
### Phase 1 — team data API
- `mlbStatsAdapter` gained `getTeams()` / `resolveTeam(abbr)` (statsapi
`/teams?sportId=1`, cached 24h, abbr→id) + `getTeamRoster(teamId)` (active
roster, cached 6h).
- `src/services/teamService.js getTeamHub(sport, abbr)` assembles the hub:
resolve team → roster → per-player season stats (bounded concurrency 8, reuses
playerIntelService mappers) → archetype (from the snapshot grade, else
classify) → tonight's graded props (from `grades:{sport}`). Whole result cached
15 min. MLB = real; NBA/WNBA = a snapshot-built partial roster (graceful note).
- `GET /api/team/:abbr` (public, 404 on unknown MLB team) + Next proxy.
### Phase 2 — Team Hub page
`team/[abbr]/page.tsx` (server, `generateMetadata`) + `TeamHub.tsx` (client):
team header + sport badge, sort (archetype / graded / AZ), archetype filter
chips, roster rows (archetype badge + player link + position + horizontal stats
+ graded props with grade badges + parlay "+"), "No active props" greyed state,
loading/error states.
### Phase 3 — game card team links + back nav
`vyndr/GameCard` team abbreviations are now `TeamLink`s → `/team/:abbr?sport=`
(green hover, stops propagation from the open-game handler). Team Hub has
"← Back to Slate".
## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB
## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB
+21
View File
@@ -556,6 +556,27 @@ snapshot, locked to the line, and read from cache.
then-missing `/grade` endpoint). Floating badge bottom-right when closed; free
tier blurs the payout with a `window.__goPaywall` upsell.
## Team Hub (Session 51 — non-obvious)
- **`teamService.getTeamHub(sport, abbr)`** is the single payload builder for
`/team/:abbr`. MLB is the real path: `mlbStatsAdapter.resolveTeam` (abbr→id via
the cached `/teams` list) → `getTeamRoster` → per-player `getSeasonAverages(id)`
(bounded concurrency 8, reuses `playerIntelService._internals` mappers) →
archetype (the snapshot grade's locked archetype, else `classify`) → graded
props from `grades:{sport}`. The whole hub is cached 15 min (`teamhub:{sport}:
{abbr}`); each player's season stats cache 6h. NBA/WNBA have no free roster
feed → it returns a partial roster built from tonight's graded players + a note.
All deps injectable for tests.
- **To add a sport's roster**, add a real roster source in `getTeamHub` (the MLB
branch is the template); abbr→id mapping is fetched live from statsapi (no
hardcoded team table to maintain).
- **`GET /api/team/:abbr`** (public, cached) 404s an unknown MLB team. Browser
must use the Next proxy `app/api/team/[abbr]`.
- **Page split:** `team/[abbr]/page.tsx` is a server wrapper (for
`generateMetadata`) rendering the `TeamHub` client component (sort/filter/parlay
are interactive). Player names → `playerHref`; team abbrs on game cards →
`vyndr/GameCard`'s `TeamLink` (stops propagation from the open-game handler).
The roster "+" reuses the Parlay Lab (`useParlay`/`legKey`).
## Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
+2
View File
@@ -153,6 +153,8 @@ app.use('/api/ticker', tickerRoutes);
// Session 45 — pre-graded slate read (snapshot:{sport}:latest). Public, cache-only.
const snapshotReadRoutes = require('./routes/snapshot');
app.use('/api/snapshot', snapshotReadRoutes);
// Session 51 — Team Hub (roster + archetypes + graded props). Public, cached.
app.use('/api/team', require('./routes/team'));
const gameLinesRoutes = require('./routes/gameLines');
app.use('/api/gamelines', gameLinesRoutes);
const streaksRoutes = require('./routes/streaks');
+32
View File
@@ -0,0 +1,32 @@
'use strict';
/**
* GET /api/team/:abbr?sport=mlb (Session 51) — the Team Hub payload (roster +
* per-player archetype/stats/props). Public, cache-backed (15 min). Returns 404
* for an unknown MLB team; NBA/WNBA degrade to a snapshot-built roster.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const { getTeamHub } = require('../services/teamService');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
router.get('/:abbr', async (req, res) => {
const sport = String(req.query.sport || 'mlb').toLowerCase();
const abbr = String(req.params.abbr || '');
try {
const hub = await getTeamHub(sport, abbr);
if (!hub) {
return res.status(404).json({ error: `No ${sport.toUpperCase()} team for "${abbr}". Check the abbreviation.` });
}
res.set('Cache-Control', 'public, max-age=300');
return res.json(hub);
} catch (err) {
console.error('[team]', err.message);
return res.status(503).json({ error: 'Team service temporarily unavailable' });
}
});
module.exports = router;
+39
View File
@@ -188,6 +188,42 @@ async function getPlayerStats(name, season = DEFAULT_SEASON) {
}
}
/**
* All MLB teams (Session 51) → [{ id, abbr, name }]. Cached 24h. Used to map a
* UI abbreviation ("NYY") to the statsapi team id.
*/
async function getTeams(season = DEFAULT_SEASON) {
const url = `${BASE}/teams?sportId=1&season=${season}`;
const data = await fetchWithCache(url, `mlbstats:teams:${season}`, 24 * 3600);
const teams = (data && Array.isArray(data.teams)) ? data.teams : [];
return teams.map((t) => ({ id: t.id, abbr: t.abbreviation || null, name: t.name || null }));
}
/** Resolve a team abbreviation → { id, abbr, name } or null. */
async function resolveTeam(abbr, season = DEFAULT_SEASON) {
const a = String(abbr || '').toUpperCase();
if (!a) return null;
const teams = await getTeams(season);
return teams.find((t) => String(t.abbr).toUpperCase() === a) || null;
}
/**
* Active roster for a team id (Session 51) → [{ id, name, position, jersey }].
* Cached 6h. [] on failure.
*/
async function getTeamRoster(teamId, season = DEFAULT_SEASON) {
if (!teamId) return [];
const url = `${BASE}/teams/${teamId}/roster?rosterType=active&season=${season}`;
const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}`, 6 * 3600);
const roster = (data && Array.isArray(data.roster)) ? data.roster : [];
return roster.map((r) => ({
id: r.person?.id ?? null,
name: r.person?.fullName ?? null,
position: r.position?.abbreviation ?? null,
jersey: r.jerseyNumber ?? null,
})).filter((p) => p.id && p.name);
}
module.exports = {
getScheduleWithPitchers,
getPlayerGameLog,
@@ -195,5 +231,8 @@ module.exports = {
getBatterVsPitcher,
searchPlayer,
getPlayerStats,
getTeams,
resolveTeam,
getTeamRoster,
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, normName },
};
+142
View File
@@ -0,0 +1,142 @@
'use strict';
/**
* teamService — the Team Hub payload (Session 51).
*
* Assembles, in one cached call, everything `/team/:abbr` needs: team meta +
* a roster where each player carries their archetype, season stats, and tonight's
* graded props. MLB is the real-data path (statsapi.mlb.com roster + season
* stats); NBA/WNBA degrade to a roster built from the players graded tonight.
*
* Sources (all existing): mlbStatsAdapter (roster/season), archetypeService
* (classify), the grades:{sport} snapshot cache (props + locked archetype).
* Everything is injectable so the whole build is unit-testable with no network.
*/
const { nameKey } = require('../utils/playerName');
const HUB_TTL = 15 * 60; // expensive to build; 15-min cache
const ROSTER_CONCURRENCY = 8;
async function mapLimit(items, concurrency, fn) {
const out = new Array(items.length);
let i = 0;
async function worker() {
while (i < items.length) {
const idx = i++;
out[idx] = await fn(items[idx], idx);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length || 1) }, worker));
return out;
}
const sideChar = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
const statLabel = (s) => String(s || '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
/** Index the grades cache by normalized player → { archetype, props[] }. */
function indexGradesByPlayer(grades) {
const map = {};
for (const g of grades || []) {
const k = nameKey(g.player || g.player_name);
if (!k) continue;
if (!map[k]) map[k] = { archetype: g.archetype || null, props: [] };
if (!map[k].archetype && g.archetype) map[k].archetype = g.archetype;
map[k].props.push({
stat: statLabel(g.stat_type || g.stat),
line: g.line,
side: sideChar(g.direction),
grade: g.grade,
gradedAt: g.gradedAt || null,
});
}
return map;
}
/**
* Build the Team Hub for a sport + abbreviation. Returns the payload, or null
* for an unknown MLB team (→ 404). Never throws.
* opts: { mlbAdapter, classify, cacheGet, cacheSet, skipCache,
* mapMlbHitter, mapMlbPitcher, mlbSeasonRows }
*/
async function getTeamHub(sport, abbr, opts = {}) {
const sp = String(sport || 'mlb').toLowerCase();
const team = String(abbr || '').toUpperCase();
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
const classify = opts.classify || require('./archetypeService').classify;
const cacheKey = `teamhub:${sp}:${team}`;
if (!opts.skipCache) {
try { const c = await cacheGet(cacheKey); if (c) return c; } catch { /* ignore */ }
}
let gradeIndex = {};
try {
const env = await cacheGet(`grades:${sp}`);
gradeIndex = indexGradesByPlayer(env && env.grades);
} catch { gradeIndex = {}; }
// ── NBA/WNBA/soccer: graceful fallback from tonight's graded players ──
// No free roster feed; build a partial roster from the snapshot grades.
if (sp !== 'mlb') {
const env = await cacheGet(`grades:${sp}`).catch(() => null);
const byPlayer = {};
for (const g of (env && env.grades) || []) {
const disp = g.player || g.player_name;
const k = nameKey(disp);
if (!byPlayer[k]) byPlayer[k] = { player: disp, archetype: g.archetype ? { primary: g.archetype } : null, position: null, stats: [], props: [], propCount: 0 };
byPlayer[k].props.push({ stat: statLabel(g.stat_type || g.stat), line: g.line, side: sideChar(g.direction), grade: g.grade, gradedAt: g.gradedAt || null });
}
const list = Object.values(byPlayer).map((p) => ({ ...p, propCount: p.props.length }));
const hub = { team: { name: team, abbr: team, sport: sp }, roster: list, rosterSource: 'snapshot', note: list.length ? null : 'Roster unavailable — view individual players from the slate.' };
try { await cacheSet(cacheKey, hub, HUB_TTL); } catch { /* ignore */ }
return hub;
}
// ── MLB: full real roster ──
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
const intel = require('./playerIntelService')._internals;
const mapMlbHitter = opts.mapMlbHitter || intel.mapMlbHitter;
const mapMlbPitcher = opts.mapMlbPitcher || intel.mapMlbPitcher;
const mlbSeasonRows = opts.mlbSeasonRows || intel.mlbSeasonRows;
const meta = await mlb.resolveTeam(team);
if (!meta) return null; // unknown team → 404
const roster = await mlb.getTeamRoster(meta.id);
const players = await mapLimit(roster, ROSTER_CONCURRENCY, async (p) => {
const group = p.position === 'P' ? 'pitching' : 'hitting';
let classifierInput = {};
let stats = [];
try {
const season = await mlb.getSeasonAverages(p.id, undefined, group);
if (season) {
classifierInput = group === 'pitching' ? mapMlbPitcher(season) : mapMlbHitter(season);
stats = mlbSeasonRows(season, group);
}
} catch { /* best-effort */ }
const graded = gradeIndex[nameKey(p.name)];
let archetype = graded && graded.archetype ? { primary: graded.archetype } : null;
if (!archetype && Object.keys(classifierInput).length) {
const c = classify('mlb', classifierInput);
archetype = c.primary ? { primary: c.primary.name } : null;
}
return {
player: p.name,
position: p.position,
jersey: p.jersey,
archetype,
stats,
props: graded ? graded.props : [],
propCount: graded ? graded.props.length : 0,
};
});
const hub = { team: { name: meta.name, abbr: meta.abbr, sport: 'mlb' }, roster: players, rosterSource: 'mlb-stats' };
try { await cacheSet(cacheKey, hub, HUB_TTL); } catch { /* ignore */ }
return hub;
}
module.exports = { getTeamHub, indexGradesByPlayer, __internals: { mapLimit, statLabel } };
+34
View File
@@ -0,0 +1,34 @@
// Session 51 — GET /api/team/:abbr (Team Hub endpoint).
const request = require('supertest');
jest.mock('../../src/services/teamService', () => ({
getTeamHub: jest.fn(),
}));
const teamService = require('../../src/services/teamService');
const app = require('../../src/app');
beforeEach(() => jest.clearAllMocks());
describe('GET /api/team/:abbr', () => {
it('returns the hub for a known team', async () => {
teamService.getTeamHub.mockResolvedValue({ team: { name: 'New York Yankees', abbr: 'NYY', sport: 'mlb' }, roster: [{ player: 'Aaron Judge' }] });
const res = await request(app).get('/api/team/NYY?sport=mlb');
expect(res.status).toBe(200);
expect(res.body.team.name).toBe('New York Yankees');
expect(teamService.getTeamHub).toHaveBeenCalledWith('mlb', 'NYY');
});
it('404s an unknown team with a helpful message', async () => {
teamService.getTeamHub.mockResolvedValue(null);
const res = await request(app).get('/api/team/ZZZ?sport=mlb');
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/abbreviation/i);
});
it('defaults sport to mlb', async () => {
teamService.getTeamHub.mockResolvedValue({ team: { abbr: 'BOS' }, roster: [] });
await request(app).get('/api/team/BOS');
expect(teamService.getTeamHub).toHaveBeenCalledWith('mlb', 'BOS');
});
});
+70
View File
@@ -0,0 +1,70 @@
// Session 51 — Team Hub page + game-card team links (source-asserted, matching
// the repo's frontend test pattern).
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
describe('Team Hub page', () => {
const src = read('app/team/[abbr]/TeamHub.tsx');
const page = read('app/team/[abbr]/page.tsx');
it('fetches /api/team/:abbr and renders team name + roster', () => {
expect(src).toContain('/api/team/');
expect(src).toContain('data.team.name');
expect(src).toContain('roster.map');
});
it('player names link to the player profile', () => {
expect(src).toContain('playerHref(p.player, data.team.sport)');
});
it('has sort (archetype/props/name) + archetype filter', () => {
expect(src).toContain("k=\"archetype\"");
expect(src).toContain("k=\"props\"");
expect(src).toContain('archetypeFilter');
expect(src).toContain('ArchetypeBadge');
});
it('shows "No active props" for players without props (greyed)', () => {
expect(src).toContain('No active props');
expect(src).toContain('opacity: noProps ? 0.6 : 1');
});
it('has back-to-slate navigation', () => {
expect(src).toContain('← Back to Slate');
expect(src).toContain('href="/dashboard"');
});
it('handles loading + error states', () => {
expect(src).toContain("'loading'");
expect(src).toContain("'error'");
expect(src).toContain('Team not found');
});
it('wires the parlay "+" on graded props', () => {
expect(src).toContain('useParlay');
expect(src).toContain('onPropClick');
expect(src).toContain('legKey');
});
it('server page exports generateMetadata with the team abbr', () => {
expect(page).toContain('export async function generateMetadata');
expect(page).toContain('Team Hub');
});
});
describe('Game card team links', () => {
const src = read('components/vyndr/GameCard.tsx');
it('renders team abbreviations as links to /team/:abbr', () => {
expect(src).toContain('function TeamLink');
expect(src).toContain('/team/${encodeURIComponent(abbr)}?sport=');
expect(src).toContain('<TeamLink abbr={g.away.abbr}');
expect(src).toContain('<TeamLink abbr={g.home.abbr}');
});
it('stops propagation so the link does not trigger open-game', () => {
expect(src).toContain('onClick={(e) => e.stopPropagation()}');
});
});
describe('Team API Next proxy', () => {
it('forwards GET /api/team/:abbr to the backend', () => {
const src = read('app/api/team/[abbr]/route.ts');
expect(src).toContain('/api/team/');
expect(src).toContain('BACKEND_URL');
});
});
+81
View File
@@ -0,0 +1,81 @@
// Session 51 — Team Hub service. Adapter + cache injected (no network).
const svc = require('../../src/services/teamService');
function memCache(initial) {
const store = { ...(initial || {}) };
return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; } };
}
const judgeSeason = { homeRuns: 34, atBats: 330, gamesPlayed: 92, avg: '.288', ops: '1.012', rbi: 87 };
const acePitcherSeason = { era: '2.4', strikeOuts: 130, inningsPitched: '110.0', whip: '0.92', gamesStarted: 17, strikeoutsPer9Inn: '10.6' };
const mlbAdapter = {
async resolveTeam(abbr) { return abbr === 'NYY' ? { id: 147, abbr: 'NYY', name: 'New York Yankees' } : null; },
async getTeamRoster() {
return [
{ id: 592450, name: 'Aaron Judge', position: 'RF' },
{ id: 1, name: 'Gerrit Cole', position: 'P' },
{ id: 2, name: 'Bench Guy', position: '2B' },
];
},
async getSeasonAverages(id, _s, group) {
if (id === 592450) return judgeSeason;
if (id === 1 && group === 'pitching') return acePitcherSeason;
return null; // bench guy: no stats
},
};
const gradesEnv = {
grades: [
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A', archetype: 'BOMBER', gradedAt: { line: 1.5, timestamp: '2026-06-19T18:00:00Z' } },
{ player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'C', archetype: 'BOMBER' },
],
};
describe('getTeamHub (MLB, injected)', () => {
it('returns team name + roster with archetype, stats, props', async () => {
const cache = memCache({ 'grades:mlb': gradesEnv });
const hub = await svc.getTeamHub('mlb', 'nyy', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
expect(hub.team.name).toBe('New York Yankees');
expect(hub.team.abbr).toBe('NYY');
expect(hub.roster).toHaveLength(3);
const judge = hub.roster.find((p) => p.player === 'Aaron Judge');
expect(judge.archetype.primary).toBe('BOMBER'); // from snapshot
expect(judge.stats.length).toBeGreaterThan(0);
expect(judge.propCount).toBe(2);
const cole = hub.roster.find((p) => p.player === 'Gerrit Cole');
expect(cole.archetype.primary).toBe('ALPHA'); // classified from pitching stats
});
it('shows a player with no stats + no props gracefully', async () => {
const cache = memCache({ 'grades:mlb': gradesEnv });
const hub = await svc.getTeamHub('mlb', 'NYY', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
const bench = hub.roster.find((p) => p.player === 'Bench Guy');
expect(bench.propCount).toBe(0);
expect(bench.archetype).toBeNull();
});
it('returns null for an unknown team (→ 404)', async () => {
const cache = memCache();
expect(await svc.getTeamHub('mlb', 'ZZZ', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet })).toBeNull();
});
it('caches the assembled hub (writes teamhub:{sport}:{abbr})', async () => {
const cache = memCache({ 'grades:mlb': gradesEnv });
await svc.getTeamHub('mlb', 'NYY', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
expect(cache.store['teamhub:mlb:NYY']).toBeTruthy();
});
});
describe('getTeamHub (NBA fallback)', () => {
it('builds a snapshot roster when no MLB feed', async () => {
const cache = memCache({ 'grades:nba': { grades: [{ player: 'Victor Wembanyama', stat_type: 'points', line: 26.5, direction: 'over', grade: 'A', archetype: 'FORTRESS' }] } });
const hub = await svc.getTeamHub('nba', 'SA', { cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
expect(hub.rosterSource).toBe('snapshot');
expect(hub.roster[0].player).toBe('Victor Wembanyama');
expect(hub.roster[0].archetype.primary).toBe('FORTRESS');
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+21
View File
@@ -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 });
}
}
+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>
);
}
+27
View File
@@ -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()} />;
}
+19 -1
View File
@@ -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 }}>