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:
+32
-2
@@ -4,8 +4,38 @@
|
|||||||
2026-06-18
|
2026-06-18
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v50.0 — Parlay Lab: correlation-aware combined grading, live slip,
|
SHIP BUILD v51.0 — Team Hub: /team/[abbr] roster with archetypes, season stats,
|
||||||
"+" on every graded prop, tier-gated panel. The Desk-tier differentiator.
|
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 / A–Z), 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
|
## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB
|
||||||
|
|
||||||
|
|||||||
@@ -556,6 +556,27 @@ snapshot, locked to the line, and read from cache.
|
|||||||
then-missing `/grade` endpoint). Floating badge bottom-right when closed; free
|
then-missing `/grade` endpoint). Floating badge bottom-right when closed; free
|
||||||
tier blurs the payout with a `window.__goPaywall` upsell.
|
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
|
## Active Skills
|
||||||
- vyndr-voice (all user-facing output)
|
- vyndr-voice (all user-facing output)
|
||||||
- prop-analysis (grading methodology)
|
- prop-analysis (grading methodology)
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ app.use('/api/ticker', tickerRoutes);
|
|||||||
// Session 45 — pre-graded slate read (snapshot:{sport}:latest). Public, cache-only.
|
// Session 45 — pre-graded slate read (snapshot:{sport}:latest). Public, cache-only.
|
||||||
const snapshotReadRoutes = require('./routes/snapshot');
|
const snapshotReadRoutes = require('./routes/snapshot');
|
||||||
app.use('/api/snapshot', snapshotReadRoutes);
|
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');
|
const gameLinesRoutes = require('./routes/gameLines');
|
||||||
app.use('/api/gamelines', gameLinesRoutes);
|
app.use('/api/gamelines', gameLinesRoutes);
|
||||||
const streaksRoutes = require('./routes/streaks');
|
const streaksRoutes = require('./routes/streaks');
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 = {
|
module.exports = {
|
||||||
getScheduleWithPitchers,
|
getScheduleWithPitchers,
|
||||||
getPlayerGameLog,
|
getPlayerGameLog,
|
||||||
@@ -195,5 +231,8 @@ module.exports = {
|
|||||||
getBatterVsPitcher,
|
getBatterVsPitcher,
|
||||||
searchPlayer,
|
searchPlayer,
|
||||||
getPlayerStats,
|
getPlayerStats,
|
||||||
|
getTeams,
|
||||||
|
resolveTeam,
|
||||||
|
getTeamRoster,
|
||||||
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, normName },
|
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, normName },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 } };
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
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[];
|
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
|
/** A book-line cell with the Bloomberg pattern: best = green tint + green left
|
||||||
* border, worst = subtle red. The #1 visual upgrade (§13). */
|
* border, worst = subtle red. The #1 visual upgrade (§13). */
|
||||||
function LineCell({ value, best, worst }: { value: string; best?: boolean; worst?: boolean }) {
|
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' }}>
|
<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} />
|
<SportBadge sport={g.sport} />
|
||||||
<span className="mono" style={{ fontSize: 18, fontWeight: 700, letterSpacing: '0.01em' }}>
|
<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>
|
</span>
|
||||||
{g.live && (
|
{g.live && (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginLeft: 2 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginLeft: 2 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user