Fan Tier 2: venue pages, attack zones, broadcasts, push prefs, squads + UI/UX polish

Features (all from already-stored data):
- Venue pages: /venues hub + /venue/:name with each stadium's schedule,
  altitude badge (>1000 m), local timezone. Linked from the match hero,
  nav More sheet, and footer.
- Attacking-zones card on the match Stats tab (left/center/right share per
  side, from the stored FotMob payload).
- US broadcast networks on the pre-match hero (SUMMARY_V 6).
- Notification preferences: follow multiple teams + an all-matches feed
  ('*'); push_subs keyed by (endpoint, team) with a safe one-time migration;
  device drops its browser subscription only when the last follow is removed.
- FIFA tournament squad card on team pages (26-man list, position groups).
- Player match logs now show DNP rows for unused-squad appearances.
- OG/share meta for /player and /venue.

Polish (adversarial audit: 25 confirmed findings, all fixed):
- Mobile horizontal-scroll eliminated on /teams and /venues (grid-item
  min-w-0 blowout); two-line venue fixture rows.
- Truncation actually fires now across team fixtures, road-to-final,
  recent-form, H2H meetings, venue rows (min-w-0 on flex children).
- Team hero no longer detaches the standing block on wrap; bell denied-state
  no longer overflows the h1.
- AllMatchesBell: 36px touch target, aria-label, own i18n key, even rhythm.
- Server hardening: cached player index (one cross-fixture scan, not per
  request), migration rollback guard, 400 (not 500) on malformed name URLs,
  all-zero zones render nothing, tidyName fixes shouted particles (DE→De).
- German polish: removed stray English ("anytime", "Build-Geocode"), clearer
  squad note.

Gates: 170 tests, build, typecheck:server — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:05:27 +02:00
parent 0fd13d4c24
commit a1fb6d48c0
26 changed files with 726 additions and 104 deletions
+41 -8
View File
@@ -97,10 +97,11 @@ CREATE TABLE IF NOT EXISTS scorer_props (
CREATE INDEX IF NOT EXISTS idx_scorer_props ON scorer_props(fixture_num, market, athlete, captured_at); CREATE INDEX IF NOT EXISTS idx_scorer_props ON scorer_props(fixture_num, market, athlete, captured_at);
CREATE TABLE IF NOT EXISTS push_subs ( CREATE TABLE IF NOT EXISTS push_subs (
endpoint TEXT PRIMARY KEY, endpoint TEXT NOT NULL,
json TEXT NOT NULL, json TEXT NOT NULL,
team TEXT, team TEXT NOT NULL,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL,
PRIMARY KEY (endpoint, team)
); );
CREATE TABLE IF NOT EXISTS inplay_history ( CREATE TABLE IF NOT EXISTS inplay_history (
@@ -147,6 +148,34 @@ export function db(): DatabaseSync {
// Dead tables from abandoned plans — never written or read. (`injuries` // Dead tables from abandoned plans — never written or read. (`injuries`
// stays: the planned API-Football sweep will fill it.) // stays: the planned API-Football sweep will fill it.)
d.exec('DROP TABLE IF EXISTS squad; DROP TABLE IF EXISTS goalscorers;'); d.exec('DROP TABLE IF EXISTS squad; DROP TABLE IF EXISTS goalscorers;');
// push_subs grew from one team per device to one row per (endpoint, team);
// a pre-existing table keeps its old single-column PK, so rebuild it once.
const pushPk = d
.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('push_subs') WHERE pk > 0")
.get() as { c: number };
if (pushPk.c === 1) {
d.exec('DROP TABLE IF EXISTS push_subs_new;'); // clear any half-done prior attempt
try {
d.exec(`
BEGIN;
CREATE TABLE push_subs_new (
endpoint TEXT NOT NULL,
json TEXT NOT NULL,
team TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (endpoint, team)
);
INSERT INTO push_subs_new SELECT endpoint, json, COALESCE(team, '*'), created_at FROM push_subs;
DROP TABLE push_subs;
ALTER TABLE push_subs_new RENAME TO push_subs;
COMMIT;
`);
console.log('[db] push_subs migrated to (endpoint, team) rows');
} catch (e) {
try { d.exec('ROLLBACK;'); } catch { /* no open txn */ }
console.error('[db] push_subs migration failed, left as-is', e);
}
}
_db = d; _db = d;
console.log(`[db] sqlite ready → ${file}`); console.log(`[db] sqlite ready → ${file}`);
return d; return d;
@@ -242,24 +271,28 @@ export function injuriesFor(team: string): { player: string; status: string | nu
export interface PushSub { export interface PushSub {
endpoint: string; endpoint: string;
json: string; json: string;
team: string | null; /** Followed team, or '*' for the all-matches feed. */
team: string;
} }
export function addPushSub(endpoint: string, json: string, team: string | null): void { export function addPushSub(endpoint: string, json: string, team: string): void {
db() db()
.prepare('INSERT OR REPLACE INTO push_subs (endpoint, json, team, created_at) VALUES (?, ?, ?, ?)') .prepare('INSERT OR REPLACE INTO push_subs (endpoint, json, team, created_at) VALUES (?, ?, ?, ?)')
.run(endpoint, json, team, Date.now()); .run(endpoint, json, team, Date.now());
} }
export function removePushSub(endpoint: string): void { /** Drop one followed team, or the whole device when no team is given. */
db().prepare('DELETE FROM push_subs WHERE endpoint = ?').run(endpoint); export function removePushSub(endpoint: string, team?: string): void {
if (team != null) db().prepare('DELETE FROM push_subs WHERE endpoint = ? AND team = ?').run(endpoint, team);
else db().prepare('DELETE FROM push_subs WHERE endpoint = ?').run(endpoint);
} }
/** Subscriptions following any of these teams — or every match ('*'). */
export function pushSubsForTeams(teams: string[]): PushSub[] { export function pushSubsForTeams(teams: string[]): PushSub[] {
if (teams.length === 0) return []; if (teams.length === 0) return [];
const marks = teams.map(() => '?').join(','); const marks = teams.map(() => '?').join(',');
return db() return db()
.prepare(`SELECT endpoint, json, team FROM push_subs WHERE team IN (${marks})`) .prepare(`SELECT endpoint, json, team FROM push_subs WHERE team IN (${marks}) OR team = '*'`)
.all(...teams) as unknown as PushSub[]; .all(...teams) as unknown as PushSub[];
} }
+31 -6
View File
@@ -12,7 +12,7 @@ import { buildPreview, buildTeamProfile } from './preview';
import { buildPlayerProfile, playersIndex } from './player'; import { buildPlayerProfile, playersIndex } from './player';
import { buildScoreboard, snapshotCheck } from './scoreboard'; import { buildScoreboard, snapshotCheck } from './scoreboard';
import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats'; import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats';
import { normalizeMatchStats } from './ingest/fotmobStatsNormalize'; import { normalizeAttackingZones, normalizeMatchStats } from './ingest/fotmobStatsNormalize';
import { confirmedXI } from './ingest/fifaLineupNormalize'; import { confirmedXI } from './ingest/fifaLineupNormalize';
import { buildIcs } from './ics'; import { buildIcs } from './ics';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db'; import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
@@ -23,6 +23,12 @@ import { loadStatic } from './preview';
import { canonicalTeam } from '../../src/lib/teams'; import { canonicalTeam } from '../../src/lib/teams';
import type { MatchStatus, ServerMessage } from '../../src/lib/types'; import type { MatchStatus, ServerMessage } from '../../src/lib/types';
/** decodeURIComponent that returns null on malformed input (a stray "%GG"
* would otherwise throw a URIError → 500). */
function safeDecode(s: string): string | null {
try { return decodeURIComponent(s); } catch { return null; }
}
const PORT = Number(process.env.PORT ?? 8787); const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const IS_PROD = process.env.NODE_ENV === 'production'; const IS_PROD = process.env.NODE_ENV === 'production';
@@ -120,9 +126,9 @@ export function buildServer() {
}); });
app.post('/api/push/unsubscribe', async (req, reply) => { app.post('/api/push/unsubscribe', async (req, reply) => {
if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' }); if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' });
const b = (req.body ?? {}) as { endpoint?: string }; const b = (req.body ?? {}) as { endpoint?: string; team?: string };
if (!b.endpoint) return reply.code(400).send({ error: 'endpoint required' }); if (!b.endpoint) return reply.code(400).send({ error: 'endpoint required' });
pushUnsubscribe(b.endpoint); pushUnsubscribe(b.endpoint, typeof b.team === 'string' ? b.team : undefined);
return { ok: true }; return { ok: true };
}); });
@@ -143,6 +149,7 @@ export function buildServer() {
homeTeamId?: number | null; homeTeamId?: number | null;
shots?: FmShot[]; shots?: FmShot[];
stats?: unknown; stats?: unknown;
attackingZones?: unknown;
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null; momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null; matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
lineup?: { lineupType?: string; homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null; lineup?: { lineupType?: string; homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
@@ -218,6 +225,7 @@ export function buildServer() {
: null, : null,
weather: weather ? weather.data : null, weather: weather ? weather.data : null,
fullStats: fullStats.length ? fullStats : null, fullStats: fullStats.length ? fullStats : null,
zones: normalizeAttackingZones(fm?.data.attackingZones),
// Official XI published by FIFA, or FotMob no longer calling its // Official XI published by FIFA, or FotMob no longer calling its
// lineup "predicted" — either way, stop labeling them as projections. // lineup "predicted" — either way, stop labeling them as projections.
lineupConfirmed: confirmedXI(fifa?.data.live) != null || fm?.data.lineup?.lineupType === 'standard', lineupConfirmed: confirmedXI(fifa?.data.live) != null || fm?.data.lineup?.lineupType === 'standard',
@@ -258,14 +266,16 @@ export function buildServer() {
return preview ?? reply.code(404).send({ error: 'no such fixture' }); return preview ?? reply.code(404).send({ error: 'no such fixture' });
}); });
app.get('/api/team/:name', async (req, reply) => { app.get('/api/team/:name', async (req, reply) => {
const name = canonicalTeam(decodeURIComponent((req.params as { name: string }).name)); const raw = safeDecode((req.params as { name: string }).name);
const profile = buildTeamProfile(name, state, model); if (raw == null) return reply.code(400).send({ error: 'bad name' });
const profile = buildTeamProfile(canonicalTeam(raw), state, model);
return profile ?? reply.code(404).send({ error: 'no such team' }); return profile ?? reply.code(404).send({ error: 'no such team' });
}); });
// Player profiles, assembled from stored boards, lineups, shots and events. // Player profiles, assembled from stored boards, lineups, shots and events.
// Either source's spelling of a name resolves ("Hwang In-Beom" = "In-Beom Hwang"). // Either source's spelling of a name resolves ("Hwang In-Beom" = "In-Beom Hwang").
app.get('/api/player/:name', async (req, reply) => { app.get('/api/player/:name', async (req, reply) => {
const name = decodeURIComponent((req.params as { name: string }).name); const name = safeDecode((req.params as { name: string }).name);
if (name == null) return reply.code(400).send({ error: 'bad name' });
const profile = buildPlayerProfile(name, state); const profile = buildPlayerProfile(name, state);
return profile ?? reply.code(404).send({ error: 'unknown player' }); return profile ?? reply.code(404).send({ error: 'unknown player' });
}); });
@@ -392,6 +402,21 @@ export function buildServer() {
`World Cup 2026 · title odds ${(odds.champion * 100).toFixed(1)}% by the Cup26 model`, `World Cup 2026 · title odds ${(odds.champion * 100).toFixed(1)}% by the Cup26 model`,
); );
} }
const mPlayer = /^\/player\/([^/?]+)(?:\?|$)/.exec(url);
if (mPlayer) {
const p = buildPlayerProfile(decodeURIComponent(mPlayer[1]!), state);
if (!p) return null;
const goals = p.totals['goals']?.value ?? p.bootGoals;
const goalsText = goals >= 1 ? ` · ${Math.round(goals)} goal${goals >= 2 ? 's' : ''}` : '';
return withMeta(`${p.name} — Cup26`, `World Cup 2026 · ${p.team}${goalsText}`);
}
const mVenue = /^\/venue\/([^/?]+)(?:\?|$)/.exec(url);
if (mVenue) {
const venue = decodeURIComponent(mVenue[1]!);
const n = state.allFixtures().filter((f) => f.venue === venue).length;
if (!n) return null;
return withMeta(`${venue} — Cup26`, `World Cup 2026 venue · ${n} matches`);
}
} catch { /* fall through to the plain shell */ } } catch { /* fall through to the plain shell */ }
return null; return null;
}; };
+14 -3
View File
@@ -75,7 +75,7 @@ export interface EspnTimelineEvent {
shootout?: boolean; shootout?: boolean;
} }
/** Current normalizer schema — bump to make the boot backfill re-store. */ /** Current normalizer schema — bump to make the boot backfill re-store. */
export const SUMMARY_V = 5; export const SUMMARY_V = 6;
export interface EspnCommentaryLine { minute: number | null; text: string } export interface EspnCommentaryLine { minute: number | null; text: string }
@@ -102,6 +102,8 @@ export interface EspnSummary {
report?: { headline: string; story: string } | null; report?: { headline: string; story: string } | null;
/** Match video clips (highlights, reactions) — links open on ESPN. */ /** Match video clips (highlights, reactions) — links open on ESPN. */
videos?: EspnVideo[]; videos?: EspnVideo[];
/** US broadcast networks (ESPN only lists its home market). */
broadcasts?: string[];
} }
export interface RawSummary { export interface RawSummary {
@@ -109,7 +111,10 @@ export interface RawSummary {
commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[]; commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[];
article?: { headline?: string; story?: string }; article?: { headline?: string; story?: string };
videos?: { headline?: string; duration?: number; thumbnail?: string; links?: { web?: { href?: string } } }[]; videos?: { headline?: string; duration?: number; thumbnail?: string; links?: { web?: { href?: string } } }[];
header?: { competitions?: { competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[] }[] }; header?: { competitions?: {
competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[];
broadcasts?: { media?: { shortName?: string } }[];
}[] };
headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[]; headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[];
boxscore?: { boxscore?: {
form?: { team?: { id: string }; events?: { gameResult?: string }[] }[]; form?: { team?: { id: string }; events?: { gameResult?: string }[] }[];
@@ -206,5 +211,11 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
}) })
.slice(0, 6); .slice(0, 6);
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report, videos }; const broadcasts = [...new Set(
(raw.header?.competitions?.[0]?.broadcasts ?? [])
.map((b) => b.media?.shortName?.trim() ?? '')
.filter(Boolean),
)].slice(0, 4);
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report, videos, broadcasts };
} }
+31 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { confirmedXI } from './fifaLineupNormalize'; import { confirmedXI, squadList, tidyName } from './fifaLineupNormalize';
const squad = (starters: number, bench = 15, prefix = 'P') => ({ const squad = (starters: number, bench = 15, prefix = 'P') => ({
Players: [ Players: [
@@ -32,3 +32,33 @@ describe('confirmedXI', () => {
expect(confirmedXI({ HomeTeam: { Players: 'nope' }, AwayTeam: squad(11) })).toBeNull(); expect(confirmedXI({ HomeTeam: { Players: 'nope' }, AwayTeam: squad(11) })).toBeNull();
}); });
}); });
describe('squadList', () => {
it('tidies the shouted surnames and orders by position group then shirt', () => {
const live = {
HomeTeam: {
Players: [
{ Status: 2, ShirtNumber: 9, Position: 3, ShortName: [{ Description: 'Raul JIMENEZ' }] },
{ Status: 1, ShirtNumber: 1, Position: 0, ShortName: [{ Description: 'Raul RANGEL' }] },
{ Status: 1, ShirtNumber: 3, Position: 1, ShortName: [{ Description: 'Cesar MONTES' }] },
{ Status: 1, ShirtNumber: 6, Position: 2, ShortName: [{ Description: 'J. AL-HUSSAIN' }] },
],
},
};
expect(squadList(live, 'home')).toEqual([
{ name: 'Raul Rangel', num: 1, pos: 0 },
{ name: 'Cesar Montes', num: 3, pos: 1 },
{ name: 'J. Al-Hussain', num: 6, pos: 2 },
{ name: 'Raul Jimenez', num: 9, pos: 3 },
]);
expect(squadList(live, 'away')).toEqual([]);
expect(squadList(null, 'home')).toEqual([]);
});
it('tidyName keeps initials, fixes short particles, leaves mixed-case', () => {
expect(tidyName('J. GALLARDO')).toBe('J. Gallardo');
expect(tidyName('Hirving Lozano')).toBe('Hirving Lozano');
expect(tidyName('Kevin DE BRUYNE')).toBe('Kevin De Bruyne');
expect(tidyName('EL YAMIQ')).toBe('El Yamiq');
});
});
+35 -2
View File
@@ -1,8 +1,14 @@
// Pure normalizer for the FIFA live payload's lineups — no network or DB. // Pure normalizers for the FIFA live payload — no network or DB.
// FIFA publishes the official XI ~7590 min before kickoff; Status === 1 // FIFA publishes the official XI ~7590 min before kickoff; Status === 1
// marks starters within the 26-man squad list. // marks starters within the 26-man squad list.
interface RawFifaPlayer { Status?: number; ShortName?: { Description?: string }[] } interface RawFifaPlayer {
Status?: number;
ShirtNumber?: number;
/** 0 = GK, 1 = DF, 2 = MF, 3 = FW (verified against the match-1 squads). */
Position?: number;
ShortName?: { Description?: string }[];
}
interface RawFifaTeam { Players?: RawFifaPlayer[] } interface RawFifaTeam { Players?: RawFifaPlayer[] }
/** The `live` value stored under match_provider provider='fifa'. */ /** The `live` value stored under match_provider provider='fifa'. */
export interface RawFifaLive { HomeTeam?: RawFifaTeam; AwayTeam?: RawFifaTeam } export interface RawFifaLive { HomeTeam?: RawFifaTeam; AwayTeam?: RawFifaTeam }
@@ -27,3 +33,30 @@ export function confirmedXI(raw: unknown): { home: string[]; away: string[] } |
const away = side(live?.AwayTeam); const away = side(live?.AwayTeam);
return home && away ? { home, away } : null; return home && away ? { home, away } : null;
} }
/** "Cesar MONTES" → "Cesar Montes", "Kevin DE BRUYNE" → "Kevin De Bruyne"
* (FIFA shouts surnames); initials like "J." and mixed-case words survive. */
const tidyWord = (w: string): string =>
w.includes('.') || w !== w.toUpperCase()
? w
: w.split('-').map((p) => (p ? p.charAt(0) + p.slice(1).toLowerCase() : p)).join('-');
export const tidyName = (s: string): string => s.split(' ').map(tidyWord).join(' ');
/** One side's full tournament squad from a stored FIFA live payload,
* position groups first (GK → FW), shirt order within. [] on any surprise. */
export function squadList(raw: unknown, side: 'home' | 'away'): { name: string; num: number | null; pos: number | null }[] {
const live = raw as RawFifaLive | null | undefined;
const players = (side === 'home' ? live?.HomeTeam : live?.AwayTeam)?.Players;
if (!Array.isArray(players)) return [];
return players
.flatMap((p) => {
const n = p?.ShortName?.[0]?.Description;
if (typeof n !== 'string' || !n.trim()) return [];
return [{
name: tidyName(n.trim()),
num: typeof p.ShirtNumber === 'number' ? p.ShirtNumber : null,
pos: typeof p.Position === 'number' ? p.Position : null,
}];
})
.sort((a, b) => (a.pos ?? 9) - (b.pos ?? 9) || (a.num ?? 99) - (b.num ?? 99));
}
@@ -72,6 +72,19 @@ describe('normalizeMatchStats', () => {
expect(normalizeMatchStats({ Periods: {} })).toEqual([]); expect(normalizeMatchStats({ Periods: {} })).toEqual([]);
}); });
it('normalizes attacking zones and rejects partial shapes', async () => {
const { normalizeAttackingZones } = await import('./fotmobStatsNormalize');
// real shape from the match-1 capture
const zones = normalizeAttackingZones({
home: { total: { left: 45, center: 25, right: 30 }, firstHalf: { left: 49, center: 24, right: 27 } },
away: { total: { left: 27, center: 27, right: 46 } },
});
expect(zones).toEqual({ home: { left: 45, center: 25, right: 30 }, away: { left: 27, center: 27, right: 46 } });
expect(normalizeAttackingZones(null)).toBeNull();
expect(normalizeAttackingZones({ home: { total: { left: 45, center: 25 } }, away: { total: { left: 1, center: 1, right: 1 } } })).toBeNull();
expect(normalizeAttackingZones({ home: { total: { left: '45', center: 25, right: 30 } }, away: {} })).toBeNull();
});
it('skips rows with missing or empty values', () => { it('skips rows with missing or empty values', () => {
const rows = normalizeMatchStats({ const rows = normalizeMatchStats({
Periods: { All: { stats: [{ stats: [ Periods: { All: { stats: [{ stats: [
+22
View File
@@ -35,6 +35,28 @@ export interface RawMatchStats { Periods?: { All?: { stats?: RawStatGroup[] } }
const usable = (v: unknown): v is string | number => const usable = (v: unknown): v is string | number =>
(typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && v.trim() !== ''); (typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && v.trim() !== '');
export interface ZoneSplit { left: number; center: number; right: number }
interface RawZoneSide { total?: Partial<ZoneSplit> }
/** Full-match attacking-zone percentages per side, or null on any shape
* surprise (the payload is FotMob-unofficial like everything else here). */
export function normalizeAttackingZones(raw: unknown): { home: ZoneSplit; away: ZoneSplit } | null {
const z = raw as { home?: RawZoneSide; away?: RawZoneSide } | null | undefined;
const side = (s?: RawZoneSide): ZoneSplit | null => {
const v = s?.total;
if (!v) return null;
const nums = [v.left, v.center, v.right];
if (!nums.every((n): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0)) return null;
return { left: v.left!, center: v.center!, right: v.right! };
};
const home = side(z?.home);
const away = side(z?.away);
if (!home || !away) return null;
// A just-kicked-off match reports all zeros — nothing worth a card yet.
const empty = (s: ZoneSplit) => s.left + s.center + s.right === 0;
if (empty(home) && empty(away)) return null;
return { home, away };
}
/** Full-match stat rows from a raw FotMob `stats` payload. Any shape /** Full-match stat rows from a raw FotMob `stats` payload. Any shape
* surprise degrades to fewer rows (or none) — never a throw. */ * surprise degrades to fewer rows (or none) — never a throw. */
export function normalizeMatchStats(raw: unknown): MatchFullStat[] { export function normalizeMatchStats(raw: unknown): MatchFullStat[] {
+12 -2
View File
@@ -73,7 +73,7 @@ function playerIndex(state: TournamentState): Map<string, PlayerRef> {
} }
function assemble(nameParam: string, state: TournamentState): PlayerProfile | null { function assemble(nameParam: string, state: TournamentState): PlayerProfile | null {
const ref = resolvePlayer(nameParam, playerIndex(state)); const ref = resolvePlayer(nameParam, cachedPlayerIndex(state));
if (!ref) return null; if (!ref) return null;
const { name, team } = ref; const { name, team } = ref;
@@ -147,6 +147,16 @@ function assemble(nameParam: string, state: TournamentState): PlayerProfile | nu
const TTL = 60_000; const TTL = 60_000;
const memo = new Map<string, { at: number; profile: PlayerProfile | null }>(); const memo = new Map<string, { at: number; profile: PlayerProfile | null }>();
let listMemo: { at: number; players: PlayerRef[] } | null = null; let listMemo: { at: number; players: PlayerRef[] } | null = null;
let indexMemo: { at: number; index: Map<string, PlayerRef> } | null = null;
/** The resolution index is a full cross-fixture scan (~128 DB reads at
* tournament end); share one build across all profile + search requests. */
function cachedPlayerIndex(state: TournamentState): Map<string, PlayerRef> {
if (indexMemo && Date.now() - indexMemo.at < TTL) return indexMemo.index;
const index = playerIndex(state);
indexMemo = { at: Date.now(), index };
return index;
}
export function buildPlayerProfile(nameParam: string, state: TournamentState): PlayerProfile | null { export function buildPlayerProfile(nameParam: string, state: TournamentState): PlayerProfile | null {
const k = aliasKey(nameParam); const k = aliasKey(nameParam);
@@ -161,7 +171,7 @@ export function buildPlayerProfile(nameParam: string, state: TournamentState): P
/** The search palette's lightweight player list. */ /** The search palette's lightweight player list. */
export function playersIndex(state: TournamentState): PlayerRef[] { export function playersIndex(state: TournamentState): PlayerRef[] {
if (listMemo && Date.now() - listMemo.at < TTL) return listMemo.players; if (listMemo && Date.now() - listMemo.at < TTL) return listMemo.players;
const players = [...playerIndex(state).values()].sort((a, b) => a.name.localeCompare(b.name)); const players = [...cachedPlayerIndex(state).values()].sort((a, b) => a.name.localeCompare(b.name));
listMemo = { at: Date.now(), players }; listMemo = { at: Date.now(), players };
return players; return players;
} }
+19 -1
View File
@@ -1,6 +1,7 @@
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import { getMatchExt, injuriesFor, scorerPropsFor, snapshotFor } from './db/db'; import { getMatchExt, getMatchProvider, injuriesFor, scorerPropsFor, snapshotFor } from './db/db';
import { whoScores } from './ingest/propsNormalize'; import { whoScores } from './ingest/propsNormalize';
import { squadList } from './ingest/fifaLineupNormalize';
import { disciplineFor } from './stats'; import { disciplineFor } from './stats';
import type { TournamentState } from './tournament'; import type { TournamentState } from './tournament';
import type { ModelEngine } from './model'; import type { ModelEngine } from './model';
@@ -152,6 +153,7 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
videos: summary?.videos ?? [], videos: summary?.videos ?? [],
// The board disappears at kickoff — in-play lines drift too fast to mirror. // The board disappears at kickoff — in-play lines drift too fast to mirror.
scorerProps: fixture.status === 'scheduled' ? whoScores(scorerPropsFor(num)) : [], scorerProps: fixture.status === 'scheduled' ? whoScores(scorerPropsFor(num)) : [],
broadcasts: summary?.broadcasts ?? [],
dataNote: !home || !away dataNote: !home || !away
? 'Knockout participants are decided once the bracket resolves.' ? 'Knockout participants are decided once the bracket resolves.'
: ext : ext
@@ -182,5 +184,21 @@ export function buildTeamProfile(teamRaw: string, state: TournamentState, model:
qualifyOdds: odds?.qualify ?? null, qualifyOdds: odds?.qualify ?? null,
discipline: disciplineFor(teamRaw, state).filter((p) => p.yellows + p.reds > 0), discipline: disciplineFor(teamRaw, state).filter((p) => p.yellows + p.reds > 0),
injuries: injuriesFor(teamRaw), injuries: injuriesFor(teamRaw),
squad: teamSquad(teamRaw, fixtures),
}; };
} }
/** The team's 26-man FIFA squad from its most recent fixture that has a
* captured live payload (FIFA only covers matches from ~90min pre-kickoff). */
function teamSquad(team: string, fixtures: Fixture[]): TeamProfile['squad'] {
const candidates = fixtures
.filter((f) => f.status !== 'scheduled')
.sort((a, b) => b.kickoff.localeCompare(a.kickoff));
for (const f of candidates) {
const fifa = getMatchProvider<{ live?: unknown }>(f.num, 'fifa');
if (!fifa) continue;
const squad = squadList(fifa.data.live, f.home.team === team ? 'home' : 'away');
if (squad.length) return squad;
}
return [];
}
+7 -3
View File
@@ -17,17 +17,21 @@ if (pushEnabled) {
console.log('[push] web push enabled'); console.log('[push] web push enabled');
} }
/** Follow a team — or every match, with team '*'. Additive per device. */
export function subscribe(subscription: { endpoint: string }, team: string): void { export function subscribe(subscription: { endpoint: string }, team: string): void {
addPushSub(subscription.endpoint, JSON.stringify(subscription), team); addPushSub(subscription.endpoint, JSON.stringify(subscription), team);
} }
export function unsubscribe(endpoint: string): void { /** Unfollow one team, or the whole device when no team is given. */
removePushSub(endpoint); export function unsubscribe(endpoint: string, team?: string): void {
removePushSub(endpoint, team);
} }
async function send(team: string[], title: string, body: string, url: string): Promise<void> { async function send(team: string[], title: string, body: string, url: string): Promise<void> {
if (!pushEnabled) return; if (!pushEnabled) return;
const subs = pushSubsForTeams(team); // A device may follow both sides (or '*' plus a side) — one alert, not two.
const seen = new Set<string>();
const subs = pushSubsForTeams(team).filter((s) => !seen.has(s.endpoint) && !!seen.add(s.endpoint));
await Promise.allSettled( await Promise.allSettled(
subs.map(async (s) => { subs.map(async (s) => {
try { try {
+3 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, Outlet, useRouterState } from '@tanstack/react-router'; import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import { ChartColumn, Database, Gauge, GitMerge, Languages, Menu, Moon, Radio, Scale, Search, Sparkles, Sun, Swords, Table, TrendingUp, Trophy, Users } from 'lucide-react'; import { ChartColumn, Database, Gauge, GitMerge, Languages, MapPin, Menu, Moon, Radio, Scale, Search, Sparkles, Sun, Swords, Table, TrendingUp, Trophy, Users } from 'lucide-react';
import { SearchPalette } from '@/components/SearchPalette'; import { SearchPalette } from '@/components/SearchPalette';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
@@ -27,6 +27,7 @@ const MOBILE_PRIMARY: NavItem[] = NAV.slice(0, 4);
const MOBILE_MORE: NavItem[] = [ const MOBILE_MORE: NavItem[] = [
...NAV.slice(4), ...NAV.slice(4),
{ to: '/teams', label: (t) => t.nav.teams, exact: false, icon: Users }, { to: '/teams', label: (t) => t.nav.teams, exact: false, icon: Users },
{ to: '/venues', label: (t) => t.nav.venues, exact: false, icon: MapPin },
{ to: '/compare', label: (t) => t.nav.compare, exact: false, icon: Swords }, { to: '/compare', label: (t) => t.nav.compare, exact: false, icon: Swords },
{ to: '/data', label: (t) => t.nav.data, exact: false, icon: Database }, { to: '/data', label: (t) => t.nav.data, exact: false, icon: Database },
]; ];
@@ -247,6 +248,7 @@ export function RootLayout() {
<p> <p>
Cup26 · {t.footer.worldCup} · <Link to="/data" className="underline decoration-line underline-offset-2 hover:text-ink">{t.footer.allData}</Link> ·{' '} Cup26 · {t.footer.worldCup} · <Link to="/data" className="underline decoration-line underline-offset-2 hover:text-ink">{t.footer.allData}</Link> ·{' '}
<Link to="/methodology" className="underline decoration-line underline-offset-2 hover:text-ink">{t.footer.howModelWorks}</Link> ·{' '} <Link to="/methodology" className="underline decoration-line underline-offset-2 hover:text-ink">{t.footer.howModelWorks}</Link> ·{' '}
<Link to="/venues" className="underline decoration-line underline-offset-2 hover:text-ink">{t.nav.venues}</Link> ·{' '}
{t.footer.disclaimer} {t.footer.disclaimer}
</p> </p>
</footer> </footer>
+28 -1
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Radio } from 'lucide-react'; import { Bell, BellRing, Radio } from 'lucide-react';
import { usePushFollow } from '@/lib/usePushFollow';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { MatchCard } from '@/components/MatchCard'; import { MatchCard } from '@/components/MatchCard';
import { DailyDigest } from '@/components/DailyDigest'; import { DailyDigest } from '@/components/DailyDigest';
@@ -15,6 +16,30 @@ import type { Fixture } from '@/lib/types';
const UPCOMING_DAYS = 3; const UPCOMING_DAYS = 3;
/** One-tap goal alerts for EVERY match (push feed '*'). Hidden when the
* server has no push keys or the browser can't do Web Push. */
function AllMatchesBell() {
const { t } = useFormat();
const { avail, active, denied, toggle } = usePushFollow('*');
if (!avail) return null;
return (
<div className="mb-5 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void toggle()}
aria-pressed={active}
aria-label={active ? t.live.notifyAllOn : t.live.notifyAll}
title={denied ? t.live.notifyDenied : undefined}
className={`inline-flex min-h-9 items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${active ? 'border-accent-deep/40 bg-accent-glow text-accent' : 'border-line text-muted hover:text-ink'}`}
>
{active ? <BellRing size={13} /> : <Bell size={13} />}
{active ? t.live.notifyAllOn : t.live.notifyAll}
</button>
{denied && <span className="text-[11px] text-faint">{t.live.notifyDenied}</span>}
</div>
);
}
/** The headline live match: big scoreboard card with a live win-probability /** The headline live match: big scoreboard card with a live win-probability
* bar (in-play when the score is known, else the pre-match model). */ * bar (in-play when the score is known, else the pre-match model). */
function LiveHero({ f }: { f: Fixture }) { function LiveHero({ f }: { f: Fixture }) {
@@ -139,6 +164,8 @@ export function LivePage() {
subtitle={fmt(t.live.subtitle, { season: snapshot.season })} subtitle={fmt(t.live.subtitle, { season: snapshot.season })}
/> />
<AllMatchesBell />
<DailyDigest /> <DailyDigest />
{hero && <LiveHero f={hero} />} {hero && <LiveHero f={hero} />}
+45 -7
View File
@@ -192,6 +192,9 @@ export function MatchPreviewPage() {
const infoText = rich?.info && rich.info.stadium && rich.info.attendance != null && rich.info.referee && f.status === 'scheduled' const infoText = rich?.info && rich.info.stadium && rich.info.attendance != null && rich.info.referee && f.status === 'scheduled'
? fmt(t.match.attendanceLine, { stadium: rich.info.stadium, n: rich.info.attendance.toLocaleString(locale), ref: rich.info.referee }) ? fmt(t.match.attendanceLine, { stadium: rich.info.stadium, n: rich.info.attendance.toLocaleString(locale), ref: rich.info.referee })
: null; : null;
const tvText = !finished && preview.broadcasts.length > 0
? fmt(t.match.tvLine, { channels: preview.broadcasts.join(' · ') })
: null;
const heroChips: { label: string; value: string; gold?: boolean }[] = []; const heroChips: { label: string; value: string; gold?: boolean }[] = [];
if (hasScore) { if (hasScore) {
@@ -215,7 +218,7 @@ export function MatchPreviewPage() {
// Tabs only appear when their data exists (e.g. no Timeline before kickoff). // Tabs only appear when their data exists (e.g. no Timeline before kickoff).
const tabs: TabKey[] = ['summary']; const tabs: TabKey[] = ['summary'];
if (preview.events.length > 0) tabs.push('timeline'); if (preview.events.length > 0) tabs.push('timeline');
if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats'); if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.zones != null || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats');
tabs.push('lineups'); tabs.push('lineups');
const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary'; const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary';
@@ -250,7 +253,12 @@ export function MatchPreviewPage() {
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} {f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]}
<span className="text-line-strong"> · </span>{kickoffDay(f.kickoff)} <span className="text-line-strong"> · </span>{kickoffDay(f.kickoff)}
<span className="text-line-strong"> · </span>{kickoffTime(f.kickoff)} <span className="text-line-strong"> · </span>{kickoffTime(f.kickoff)}
<span className="text-line-strong"> · </span>{preview.venue} <span className="text-line-strong"> · </span>
{f.venue ? (
<Link to="/venue/$name" params={{ name: f.venue }} className="whitespace-nowrap transition-colors hover:text-accent">{preview.venue}</Link>
) : (
<span className="whitespace-nowrap">{preview.venue}</span>
)}
</div> </div>
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-2 md:gap-6"> <div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-2 md:gap-6">
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1.5 hover:opacity-80"> <Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1.5 hover:opacity-80">
@@ -278,7 +286,7 @@ export function MatchPreviewPage() {
{heroChips.map((c) => <HeroChip key={c.label} label={c.label} value={c.value} gold={c.gold ?? false} />)} {heroChips.map((c) => <HeroChip key={c.label} label={c.label} value={c.value} gold={c.gold ?? false} />)}
</div> </div>
)} )}
{(weatherText || infoText) && ( {(weatherText || infoText || tvText) && (
<div className="mt-5 space-y-1 border-t border-line pt-3 text-center text-xs text-muted"> <div className="mt-5 space-y-1 border-t border-line pt-3 text-center text-xs text-muted">
{weatherText && ( {weatherText && (
<div className="flex flex-wrap items-center justify-center gap-2"> <div className="flex flex-wrap items-center justify-center gap-2">
@@ -287,6 +295,7 @@ export function MatchPreviewPage() {
</div> </div>
)} )}
{infoText && <div>{infoText}</div>} {infoText && <div>{infoText}</div>}
{tvText && <div>{tvText}</div>}
</div> </div>
)} )}
</div> </div>
@@ -511,6 +520,35 @@ export function MatchPreviewPage() {
</CardBody> </CardBody>
</Card> </Card>
)} )}
{rich?.zones && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.zonesTitle}</span></CardHeader>
<CardBody className="space-y-3">
{([
['home', homeName, rich.zones.home, ['bg-accent/40', 'bg-accent/70', 'bg-accent']],
['away', awayName, rich.zones.away, ['bg-info/40', 'bg-info/70', 'bg-info']],
] as const).map(([side, name, z, tones]) => {
const total = z.left + z.center + z.right || 1;
return (
<div key={side}>
<div className="mb-1 text-sm font-medium text-ink">{name}</div>
<div className="flex h-3 overflow-hidden rounded-full bg-elevated">
<div className={tones[0]} style={{ width: `${(z.left / total) * 100}%` }} />
<div className={tones[1]} style={{ width: `${(z.center / total) * 100}%` }} />
<div className={tones[2]} style={{ width: `${(z.right / total) * 100}%` }} />
</div>
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
<span>{t.match.zones.left} {z.left}%</span>
<span>{t.match.zones.center} {z.center}%</span>
<span>{t.match.zones.right} {z.right}%</span>
</div>
</div>
);
})}
<p className="text-xs text-faint">{t.match.zonesNote}</p>
</CardBody>
</Card>
)}
{rich && rich.momentum.length >= 10 && ( {rich && rich.momentum.length >= 10 && (
<Card> <Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader> <CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
@@ -601,7 +639,7 @@ export function MatchPreviewPage() {
) : ( ) : (
<Card> <Card>
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? t.match.lineups : t.match.keyPlayers}</span></CardHeader> <CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? t.match.lineups : t.match.keyPlayers}</span></CardHeader>
<CardBody className="grid grid-cols-2 gap-6"> <CardBody className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6">
{preview.lineups ? ( {preview.lineups ? (
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></> <><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
) : ( ) : (
@@ -615,8 +653,8 @@ export function MatchPreviewPage() {
<Card> <Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.recentForm}</span></CardHeader> <CardHeader><span className="font-display font-bold text-ink">{t.match.recentForm}</span></CardHeader>
<CardBody className="space-y-3"> <CardBody className="space-y-3">
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span><FormChips form={preview.form.home} /></div> <div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2 text-sm font-medium text-ink"><span className="shrink-0">{f.home.team && teamFlag(f.home.team)}</span> <span className="truncate">{homeName}</span></span><FormChips form={preview.form.home} /></div>
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</span><FormChips form={preview.form.away} /></div> <div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2 text-sm font-medium text-ink"><span className="shrink-0">{f.away.team && teamFlag(f.away.team)}</span> <span className="truncate">{awayName}</span></span><FormChips form={preview.form.away} /></div>
</CardBody> </CardBody>
</Card> </Card>
)} )}
@@ -633,7 +671,7 @@ export function MatchPreviewPage() {
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">{t.match.h2h.recentMeetings}</div> <div className="mb-1 text-[11px] uppercase tracking-wide text-faint">{t.match.h2h.recentMeetings}</div>
<ul className="space-y-1 text-sm"> <ul className="space-y-1 text-sm">
{preview.h2h.last.map((m, i) => ( {preview.h2h.last.map((m, i) => (
<li key={i} className="flex items-center justify-between text-ink-soft"><span className="text-faint">{m.date.slice(0, 10)}</span><span>{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}{m.awayScore}</span> {m.away}</span></li> <li key={i} className="flex items-center justify-between gap-2 text-ink-soft"><span className="shrink-0 text-faint">{m.date.slice(0, 10)}</span><span className="min-w-0 truncate text-right">{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}{m.awayScore}</span> {m.away}</span></li>
))} ))}
</ul> </ul>
</div> </div>
+3 -1
View File
@@ -145,7 +145,9 @@ export function PlayerPage() {
<span aria-hidden>{teamFlag(m.opponent)}</span> <span aria-hidden>{teamFlag(m.opponent)}</span>
<span className="truncate">{m.opponent}</span> <span className="truncate">{m.opponent}</span>
</Link> </Link>
<span className="block text-[11px] text-faint">{kickoffDay(m.kickoff)} · {m.started ? t.player.started : t.player.cameOn}</span> <span className="block text-[11px] text-faint">
{kickoffDay(m.kickoff)} · {m.started ? t.player.started : m.minutes != null ? t.player.cameOn : t.player.benched}
</span>
</td> </td>
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
{us != null && them != null && ( {us != null && them != null && (
+49 -56
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router'; import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, Bell, BellRing, CalendarPlus, Route, Shield, Star, Stethoscope } from 'lucide-react'; import { ArrowLeft, Bell, BellRing, CalendarPlus, Route, Shield, Star, Stethoscope } from 'lucide-react';
import { EloHistoryChart } from '@/components/EloHistoryChart'; import { EloHistoryChart } from '@/components/EloHistoryChart';
import { pushSupported, serverPushKey, subscribeTeam, unsubscribePush } from '@/lib/push'; import { usePushFollow } from '@/lib/usePushFollow';
import { useFavoriteStore } from '@/stores/favoriteStore'; import { useFavoriteStore } from '@/stores/favoriteStore';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Card, CardBody, CardHeader } from '@/components/ui/Card';
@@ -73,7 +73,7 @@ function RoadToFinal({ team }: { team: string }) {
{p.opponent ? ( {p.opponent ? (
<> <>
<span aria-hidden>{teamFlag(p.opponent)}</span> <span aria-hidden>{teamFlag(p.opponent)}</span>
<Link to="/team/$name" params={{ name: p.opponent }} className="truncate font-medium text-ink hover:text-accent">{p.opponent}</Link> <Link to="/team/$name" params={{ name: p.opponent }} className="min-w-0 truncate font-medium text-ink hover:text-accent">{p.opponent}</Link>
</> </>
) : ( ) : (
<span className="italic text-faint">{t.team.road.tbd}</span> <span className="italic text-faint">{t.team.road.tbd}</span>
@@ -82,7 +82,7 @@ function RoadToFinal({ team }: { team: string }) {
</li> </li>
))} ))}
</ul> </ul>
<div className="mt-2 inline-flex rounded-full bg-gold/15 px-2.5 py-0.5 text-xs font-semibold text-gold"> <div className="mt-2 inline-flex w-fit rounded-full bg-gold/15 px-2.5 py-0.5 text-xs font-semibold text-gold">
{fmt(t.team.road.winItAll, { pct: pct(odds.champion) })} {fmt(t.team.road.winItAll, { pct: pct(odds.champion) })}
</div> </div>
<p className="mt-2 text-[11px] text-faint">{t.team.road.note}</p> <p className="mt-2 text-[11px] text-faint">{t.team.road.note}</p>
@@ -103,8 +103,8 @@ function FixtureRow({ f, team }: { f: Fixture; team: string }) {
<Link to="/match/$num" params={{ num: String(f.num) }} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-elevated"> <Link to="/match/$num" params={{ num: String(f.num) }} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-elevated">
<span className="w-10 text-xs text-faint">{f.group ? fmt(t.team.groupShort, { group: f.group }) : f.stage !== 'group' ? t.team.stageShort[f.stage] : f.round}</span> <span className="w-10 text-xs text-faint">{f.group ? fmt(t.team.groupShort, { group: f.group }) : f.stage !== 'group' ? t.team.stageShort[f.stage] : f.round}</span>
<span className="text-faint">{t.common.vs}</span> <span className="text-faint">{t.common.vs}</span>
<span aria-hidden className="grid w-5 place-items-center">{opp.team ? teamFlag(opp.team) : <Shield size={14} className="text-faint" />}</span> <span aria-hidden className="grid w-5 shrink-0 place-items-center">{opp.team ? teamFlag(opp.team) : <Shield size={14} className="text-faint" />}</span>
<span className="truncate text-ink">{opp.label}</span> <span className="min-w-0 truncate text-ink">{opp.label}</span>
<span <span
className="shrink-0 rounded bg-elevated px-1 py-px text-[10px] font-bold uppercase text-faint" className="shrink-0 rounded bg-elevated px-1 py-px text-[10px] font-bold uppercase text-faint"
title={isHome ? t.team.homeGame : t.team.awayGame} title={isHome ? t.team.homeGame : t.team.awayGame}
@@ -137,58 +137,24 @@ function FavoriteStar({ team }: { team: string }) {
); );
} }
const PUSH_TEAM_KEY = 'cup26:push-team';
/** Goal/kickoff alerts for this team — shown only when the server has push /** Goal/kickoff alerts for this team — shown only when the server has push
* enabled and the browser supports it. One followed team per device. */ * enabled and the browser supports it. Follow as many teams as you like. */
function NotifyBell({ team }: { team: string }) { function NotifyBell({ team }: { team: string }) {
const t = useT(); const t = useT();
const [avail, setAvail] = useState(false); const { avail, active, denied, toggle } = usePushFollow(team);
const [denied, setDenied] = useState(false);
const [subTeam, setSubTeam] = useState<string | null>(() => {
try { return localStorage.getItem(PUSH_TEAM_KEY); } catch { return null; }
});
useEffect(() => {
let alive = true;
void serverPushKey().then((k) => alive && setAvail(Boolean(k) && pushSupported()));
return () => { alive = false; };
}, []);
if (!avail) return null; if (!avail) return null;
const active = subTeam === team;
const toggle = async (): Promise<void> => {
if (active) {
await unsubscribePush();
try { localStorage.removeItem(PUSH_TEAM_KEY); } catch { /* ok */ }
setSubTeam(null);
return;
}
const res = await subscribeTeam(team);
if (res === 'subscribed') {
try { localStorage.setItem(PUSH_TEAM_KEY, team); } catch { /* ok */ }
setSubTeam(team);
setDenied(false);
} else if (res === 'denied') {
setDenied(true);
}
};
return ( return (
<span className="inline-flex items-center"> <button
<button type="button"
type="button" onClick={() => void toggle()}
onClick={() => void toggle()} aria-label={active ? t.team.notifyOff : t.team.notifyOn}
aria-label={active ? t.team.notifyOff : t.team.notifyOn} aria-pressed={active}
aria-pressed={active} title={denied ? t.team.notifyDenied : active ? t.team.notifyOff : t.team.notifyOn}
title={denied ? t.team.notifyDenied : active ? t.team.notifyOff : t.team.notifyOn} className={`grid h-9 w-9 shrink-0 place-items-center rounded-md transition-colors ${active ? 'text-accent' : denied ? 'text-loss' : 'text-faint hover:text-ink'}`}
className={`grid h-9 w-9 place-items-center rounded-md transition-colors ${active ? 'text-accent' : 'text-faint hover:text-ink'}`} >
> {active ? <BellRing size={18} /> : <Bell size={18} />}
{active ? <BellRing size={18} /> : <Bell size={18} />} </button>
</button>
{denied && <span className="text-[11px] text-faint">{t.team.notifyDenied}</span>}
</span>
); );
} }
@@ -221,10 +187,11 @@ export function TeamProfilePage() {
<Card> <Card>
<CardBody className="flex flex-wrap items-center gap-4"> <CardBody className="flex flex-wrap items-center gap-4">
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span> <div className="flex min-w-0 flex-1 items-center gap-4">
<div> <span aria-hidden className="shrink-0 text-5xl">{teamFlag(profile.team)}</span>
<h1 className="flex items-center gap-1 font-display text-2xl font-extrabold text-ink"> <div className="min-w-0">
<span className="mr-1">{profile.team}</span> <h1 className="flex flex-wrap items-center gap-1 font-display text-2xl font-extrabold text-ink">
<span className="mr-1 min-w-0 truncate">{profile.team}</span>
<FavoriteStar team={profile.team} /> <FavoriteStar team={profile.team} />
<NotifyBell team={profile.team} /> <NotifyBell team={profile.team} />
</h1> </h1>
@@ -242,8 +209,9 @@ export function TeamProfilePage() {
</a> </a>
</div> </div>
</div> </div>
</div>
{s && ( {s && (
<div className="ml-auto text-right text-sm"> <div className="w-full text-left text-sm sm:ml-auto sm:w-auto sm:text-right">
<div className="text-faint text-xs">{t.team.groupStanding}</div> <div className="text-faint text-xs">{t.team.groupStanding}</div>
<div className="tnum text-ink">{fmt(t.team.standingLine, { rank: s.rank, points: s.points, won: s.won, drawn: s.drawn, lost: s.lost, gd: s.gd > 0 ? `+${s.gd}` : s.gd })}</div> <div className="tnum text-ink">{fmt(t.team.standingLine, { rank: s.rank, points: s.points, won: s.won, drawn: s.drawn, lost: s.lost, gd: s.gd > 0 ? `+${s.gd}` : s.gd })}</div>
</div> </div>
@@ -330,6 +298,31 @@ export function TeamProfilePage() {
) : <p className="text-sm text-faint">{t.common.noData}</p>} ) : <p className="text-sm text-faint">{t.common.noData}</p>}
</CardBody> </CardBody>
</Card> </Card>
{profile.squad.length > 0 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.team.squadTitle}</span></CardHeader>
<CardBody className="space-y-3">
{([0, 1, 2, 3] as const).map((pos) => {
const group = profile.squad.filter((p) => p.pos === pos);
if (!group.length) return null;
return (
<div key={pos}>
<div className="mb-1 text-[10px] font-bold uppercase tracking-wide text-faint">{t.team.pos[pos]}</div>
<ul className="space-y-0.5">
{group.map((p) => (
<li key={p.name} className="flex items-center gap-2 text-sm">
<span className="tnum w-5 shrink-0 text-right text-xs text-faint">{p.num ?? ''}</span>
<PlayerLink name={p.name} className="truncate text-ink-soft transition-colors hover:text-accent" />
</li>
))}
</ul>
</div>
);
})}
<p className="text-[11px] text-faint">{t.team.squadNote}</p>
</CardBody>
</Card>
)}
</div> </div>
</div> </div>
</div> </div>
+5 -5
View File
@@ -79,15 +79,15 @@ export function TeamsPage() {
key={team.team} key={team.team}
to="/team/$name" to="/team/$name"
params={{ name: team.team }} params={{ name: team.team }}
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-4 py-3 transition-colors hover:border-line-strong hover:bg-panel-2" className="flex min-w-0 items-center gap-3 rounded-xl border border-line bg-panel px-4 py-3 transition-colors hover:border-line-strong hover:bg-panel-2"
> >
<span className="w-5 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span> <span className="w-5 shrink-0 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span>
<span aria-hidden className="text-2xl leading-none">{teamFlag(team.team)}</span> <span aria-hidden className="shrink-0 text-2xl leading-none">{teamFlag(team.team)}</span>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-semibold text-ink">{team.team}</div> <div className="truncate font-semibold text-ink">{team.team}</div>
<div className="text-[11px] text-faint">{team.group ? fmt(t.common.group, { group: team.group }) : ''}</div> <div className="truncate text-[11px] text-faint">{team.group ? fmt(t.common.group, { group: team.group }) : ''}</div>
</div> </div>
<div className="ml-auto text-right"> <div className="ml-auto shrink-0 text-right">
<div className="tnum text-sm font-bold text-gold">{pct(team.champion)}</div> <div className="tnum text-sm font-bold text-gold">{pct(team.champion)}</div>
<div className="text-[11px] text-faint">{fmt(t.teams.advanceShort, { pct: pct(team.qualify) })}</div> <div className="text-[11px] text-faint">{fmt(t.teams.advanceShort, { pct: pct(team.qualify) })}</div>
</div> </div>
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, Landmark, MapPin } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { teamFlag } from '@/lib/teams';
import { fmt, plural, useFormat } from '@/lib/i18n';
import { useTournamentStore } from '@/stores/tournamentStore';
import type { Fixture } from '@/lib/types';
// Venue hub + detail, assembled client-side: the schedule comes from the
// snapshot, altitude/timezone from the build-time venues.json geocode.
interface VenueInfo { lat: number; lon: number; elevation: number; tz: string }
type VenueMap = Record<string, VenueInfo>;
function useVenueInfo(): VenueMap {
const [info, setInfo] = useState<VenueMap>({});
useEffect(() => {
let alive = true;
fetch('/data/venues.json')
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: { venues?: VenueMap }) => { if (alive && d.venues) setInfo(d.venues); })
.catch(() => {});
return () => { alive = false; };
}, []);
return info;
}
interface VenueSummary {
name: string;
fixtures: Fixture[];
played: number;
goals: number;
next: Fixture | null;
}
function summarize(fixtures: Fixture[]): VenueSummary[] {
const byVenue = new Map<string, Fixture[]>();
for (const f of fixtures) {
if (!f.venue) continue;
const list = byVenue.get(f.venue) ?? [];
list.push(f);
byVenue.set(f.venue, list);
}
return [...byVenue.entries()]
.map(([name, fxs]) => {
const sorted = [...fxs].sort((a, b) => a.kickoff.localeCompare(b.kickoff));
const done = sorted.filter((f) => f.status === 'finished');
return {
name,
fixtures: sorted,
played: done.length,
goals: done.reduce((s, f) => s + (f.homeScore ?? 0) + (f.awayScore ?? 0), 0),
next: sorted.find((f) => f.status !== 'finished') ?? null,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
const altitudeBadge = (info: VenueInfo | undefined, label: string) =>
info && info.elevation >= 1000 ? <Badge tone="muted">{fmt(label, { m: Math.round(info.elevation) })}</Badge> : null;
function VenueRow({ f }: { f: Fixture }) {
const { t, kickoffDay, kickoffTime } = useFormat();
const done = f.status === 'finished' || f.status === 'live';
return (
<Link to="/match/$num" params={{ num: String(f.num) }} className="flex flex-col gap-0.5 rounded-md px-2 py-1.5 text-sm hover:bg-elevated">
<div className="flex items-center justify-between text-[11px] text-faint">
<span>{kickoffDay(f.kickoff)}</span>
<span className="shrink-0">{f.status === 'scheduled' ? kickoffTime(f.kickoff) : f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5">
<span aria-hidden className="shrink-0">{f.home.team ? teamFlag(f.home.team) : ''}</span>
<span className="min-w-0 flex-1 truncate text-ink">{f.home.label}</span>
{done && f.homeScore != null ? (
<span className="tnum shrink-0 font-semibold text-ink">{f.homeScore}{f.awayScore}</span>
) : (
<span className="shrink-0 text-faint">{t.common.vs}</span>
)}
<span className="min-w-0 flex-1 truncate text-right text-ink">{f.away.label}</span>
<span aria-hidden className="shrink-0">{f.away.team ? teamFlag(f.away.team) : ''}</span>
</div>
</Link>
);
}
export function VenuesPage() {
const t = useFormat().t;
const snapshot = useTournamentStore((s) => s.snapshot);
const info = useVenueInfo();
const venues = useMemo(() => summarize(snapshot?.fixtures ?? []), [snapshot]);
if (!venues.length) return <div className="h-96 animate-pulse rounded-2xl border border-line bg-panel" />;
return (
<div className="space-y-4">
<PageHeader title={t.venues.title} subtitle={t.venues.subtitle} />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{venues.map((v) => (
<Link key={v.name} to="/venue/$name" params={{ name: v.name }} className="block min-w-0">
<Card className="h-full transition-colors hover:border-line-strong">
<CardBody>
<div className="flex items-start gap-2">
<MapPin size={16} className="mt-0.5 shrink-0 text-accent" />
<div className="min-w-0">
<div className="truncate font-display font-bold text-ink">{v.name}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
<span>{plural(t.venues.matches, v.fixtures.length)}</span>
{v.played > 0 && <span>· {plural(t.venues.goalsShort, v.goals)}</span>}
{altitudeBadge(info[v.name], t.match.altitude)}
</div>
{v.next && (
<div className="mt-1.5 truncate text-xs text-ink-soft">
{fmt(t.venues.next, { match: `${v.next.home.label} ${v.next.away.label}` })}
</div>
)}
</div>
</div>
</CardBody>
</Card>
</Link>
))}
</div>
<p className="text-xs text-faint">{t.venues.note}</p>
</div>
);
}
export function VenueDetailPage() {
const { name: nameParam } = useParams({ strict: false }) as { name: string };
const t = useFormat().t;
const snapshot = useTournamentStore((s) => s.snapshot);
const info = useVenueInfo();
const venue = useMemo(
() => summarize(snapshot?.fixtures ?? []).find((v) => v.name === nameParam) ?? null,
[snapshot, nameParam],
);
if (!snapshot) return <div className="h-96 animate-pulse rounded-2xl border border-line bg-panel" />;
if (!venue) {
return (
<div className="py-16 text-center text-muted">
{t.venues.unknown}{' '}
<Link to="/venues" className="text-accent">{t.venues.all}</Link>
</div>
);
}
const vi = info[venue.name];
return (
<div className="space-y-4">
<Link to="/venues" className="inline-flex items-center gap-1 text-sm text-muted transition-colors hover:text-ink">
<ArrowLeft size={15} /> {t.venues.all}
</Link>
<div className="flex flex-wrap items-center gap-3">
<Landmark size={28} className="shrink-0 text-accent" />
<div>
<h1 className="font-display text-2xl font-extrabold text-ink">{venue.name}</h1>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-sm text-muted">
<span>{plural(t.venues.matches, venue.fixtures.length)}</span>
{venue.played > 0 && <span>· {fmt(t.venues.playedLine, { played: venue.played, goals: venue.goals })}</span>}
{vi && <span>· {fmt(t.venues.tzLine, { tz: vi.tz.replace(/_/g, ' ') })}</span>}
{altitudeBadge(vi, t.match.altitude)}
</div>
</div>
</div>
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.venues.schedule}</span></CardHeader>
<CardBody className="space-y-0.5">
{venue.fixtures.map((f) => <VenueRow key={f.num} f={f} />)}
</CardBody>
</Card>
</div>
);
}
+40 -1
View File
@@ -13,6 +13,7 @@ export const de: Dict = {
story: "Story", story: "Story",
teams: "Teams", teams: "Teams",
data: "Daten", data: "Daten",
venues: "Stadien",
compare: "Vergleich", compare: "Vergleich",
more: "Mehr", more: "Mehr",
}, },
@@ -64,6 +65,9 @@ export const de: Dict = {
title: "Live", title: "Live",
loadingSubtitle: "Turnier wird geladen…", loadingSubtitle: "Turnier wird geladen…",
subtitle: "{season} · 48 Teams · 104 Spiele", subtitle: "{season} · 48 Teams · 104 Spiele",
notifyAll: "Tor-Alarm für jedes Spiel",
notifyAllOn: "Alarm für alle Spiele aktiv — zum Stoppen tippen",
notifyDenied: "Benachrichtigungen sind in deinen Browser-Einstellungen blockiert.",
liveNowHeading: "Jetzt live", liveNowHeading: "Jetzt live",
todayHeading: "Spiele heute", todayHeading: "Spiele heute",
nextUp: "Als Nächstes", nextUp: "Als Nächstes",
@@ -152,6 +156,14 @@ export const de: Dict = {
title: "Wer trifft?", title: "Wer trifft?",
note: "DraftKings-Anytime-Quoten, Favoriten zuerst — nur Vergleichswert, nie Modell-Input.", note: "DraftKings-Anytime-Quoten, Favoriten zuerst — nur Vergleichswert, nie Modell-Input.",
}, },
tvLine: "US-TV: {channels}",
zonesTitle: "Angriffszonen",
zones: {
left: "Links",
center: "Mitte",
right: "Rechts",
},
zonesNote: "Anteil der Angriffe über links, Mitte und rechts — jeweils aus Angriffsrichtung des Teams. FotMob-Schätzungen.",
tabs: { tabs: {
summary: "Übersicht", summary: "Übersicht",
timeline: "Spielverlauf", timeline: "Spielverlauf",
@@ -581,6 +593,14 @@ export const de: Dict = {
tbd: "noch offen", tbd: "noch offen",
}, },
discipline: "Karten & Sperren", discipline: "Karten & Sperren",
squadTitle: "Turnierkader",
squadNote: "Der 26er-Kader, den die FIFA zum jüngsten Spiel veröffentlicht hat.",
pos: {
0: "Torhüter",
1: "Abwehr",
2: "Mittelfeld",
3: "Sturm",
},
suspChipNext: "fehlt im nächsten Spiel", suspChipNext: "fehlt im nächsten Spiel",
suspChipFor: "fehlt in Sp. {num}", suspChipFor: "fehlt in Sp. {num}",
atRiskChip: "eine Gelbe vor einer Sperre", atRiskChip: "eine Gelbe vor einer Sperre",
@@ -604,6 +624,25 @@ export const de: Dict = {
final: "F", final: "F",
}, },
}, },
venues: {
title: "Stadien",
subtitle: "Sechzehn Stadien in drei Gastgeberländern — alle Spiele, Höhenlage und Ortszeit auf einen Blick.",
matches: {
one: "{n} Spiel",
other: "{n} Spiele",
},
goalsShort: {
one: "bisher {n} Tor",
other: "bisher {n} Tore",
},
next: "Als Nächstes: {match}",
playedLine: "{played} gespielt · {goals} Tore",
tzLine: "Ortszeit {tz}",
schedule: "Spielplan",
unknown: "Unbekanntes Stadion.",
all: "Alle Stadien",
note: "Höhenlage aus der hinterlegten Geokodierung; Stadien über 1.000 m werden markiert — dünne Luft verändert Spiele.",
},
player: { player: {
unknown: "Keine Turnierdaten zu diesem Spieler.", unknown: "Keine Turnierdaten zu diesem Spieler.",
backToStats: "Zur Statistik", backToStats: "Zur Statistik",
@@ -622,7 +661,7 @@ export const de: Dict = {
minutesNote: "Minuten sind aus den Wechselzeiten geschätzt; Verlängerung zählt nicht mit.", minutesNote: "Minuten sind aus den Wechselzeiten geschätzt; Verlängerung zählt nicht mit.",
shotMapTitle: "Alle Schüsse in diesem Turnier", shotMapTitle: "Alle Schüsse in diesem Turnier",
shotMapNote: "Alle Schüsse zeigen nach rechts. Punktgröße entspricht dem xG; gefüllte Punkte sind Tore.", shotMapNote: "Alle Schüsse zeigen nach rechts. Punktgröße entspricht dem xG; gefüllte Punkte sind Tore.",
nextOdds: "Trifft gegen {opponent} (anytime): {odds}", nextOdds: "Treffer gegen {opponent} (irgendwann): {odds}",
oddsNote: "DraftKings-Quote — nur Vergleichswert, nie Modell-Input.", oddsNote: "DraftKings-Quote — nur Vergleichswert, nie Modell-Input.",
goalsLabel: "Tore", goalsLabel: "Tore",
}, },
+39
View File
@@ -13,6 +13,7 @@ export const en = {
teams: "Teams", teams: "Teams",
data: "Data", data: "Data",
compare: "Compare", compare: "Compare",
venues: "Venues",
more: "More", more: "More",
}, },
common: { common: {
@@ -63,6 +64,9 @@ export const en = {
title: "Live", title: "Live",
loadingSubtitle: "Loading the tournament…", loadingSubtitle: "Loading the tournament…",
subtitle: "{season} · 48 teams · 104 matches", subtitle: "{season} · 48 teams · 104 matches",
notifyAll: "Goal alerts for every match",
notifyAllOn: "Alerting every match — tap to stop",
notifyDenied: "Notifications are blocked in your browser settings.",
liveNowHeading: "Live now", liveNowHeading: "Live now",
todayHeading: "Today's matches", todayHeading: "Today's matches",
nextUp: "Next up", nextUp: "Next up",
@@ -151,6 +155,14 @@ export const en = {
title: "Who scores?", title: "Who scores?",
note: "DraftKings anytime-scorer lines, favorites first — a benchmark, never a model input.", note: "DraftKings anytime-scorer lines, favorites first — a benchmark, never a model input.",
}, },
tvLine: "US TV: {channels}",
zonesTitle: "Attack zones",
zones: {
left: "Left",
center: "Center",
right: "Right",
},
zonesNote: "Share of each side's attacking play down the left, center and right — from that team's attacking direction. FotMob estimates.",
tabs: { tabs: {
summary: "Summary", summary: "Summary",
timeline: "Timeline", timeline: "Timeline",
@@ -580,6 +592,14 @@ export const en = {
tbd: "to be decided", tbd: "to be decided",
}, },
discipline: "Cards & suspensions", discipline: "Cards & suspensions",
squadTitle: "Tournament squad",
squadNote: "The 26-man list FIFA published with the latest match.",
pos: {
0: "Goalkeepers",
1: "Defenders",
2: "Midfielders",
3: "Forwards",
},
suspChipNext: "out next match", suspChipNext: "out next match",
suspChipFor: "out for M{num}", suspChipFor: "out for M{num}",
atRiskChip: "one yellow from a ban", atRiskChip: "one yellow from a ban",
@@ -603,6 +623,25 @@ export const en = {
final: "F", final: "F",
}, },
}, },
venues: {
title: "Venues",
subtitle: "Sixteen stadiums across three host nations — every match, altitude and local kickoff in one place.",
matches: {
one: "{n} match",
other: "{n} matches",
},
goalsShort: {
one: "{n} goal so far",
other: "{n} goals so far",
},
next: "Next: {match}",
playedLine: "{played} played · {goals} goals",
tzLine: "local time {tz}",
schedule: "Schedule",
unknown: "Unknown venue.",
all: "All venues",
note: "Altitude from the build-time geocode; venues above 1,000 m get a badge — thin air changes matches.",
},
player: { player: {
unknown: "No tournament data for this player.", unknown: "No tournament data for this player.",
backToStats: "Back to stats", backToStats: "Back to stats",
+4 -2
View File
@@ -64,8 +64,10 @@ describe('playerMatchLine', () => {
expect(line?.minutes).toBeNull(); expect(line?.minutes).toBeNull();
}); });
it('an unused sub with no events is no row at all', () => { it('an unused sub gets a DNP row; a player absent from the squad gets none', () => {
expect(playerMatchLine('C', fx({ bench: [{ name: 'C', rating: null, subIn: null, subOut: null }] }))).toBeNull(); const dnp = playerMatchLine('C', fx({ bench: [{ name: 'C', rating: null, subIn: null, subOut: null }] }));
expect(dnp).toMatchObject({ started: false, minutes: null, rating: null, goals: 0 });
expect(playerMatchLine('Nobody', fx({ bench: [{ name: 'C', rating: null, subIn: null, subOut: null }] }))).toBeNull();
}); });
it('attributes goals, assists and cards from the event feed', () => { it('attributes goals, assists and cards from the event feed', () => {
+3 -3
View File
@@ -116,9 +116,9 @@ export function playerMatchLine(name: string, fx: PlayerFixtureData): PlayerMatc
} }
} }
const appeared = starter != null || sub?.subIn != null; // In the squad (even unused on the bench) or in the event feed → a row;
const involved = goals + assists + yellows + reds > 0; // completely absent from a fixture → none.
if (!appeared && !involved) return null; if (starter == null && sub == null && goals + assists + yellows + reds === 0) return null;
const perf = starter ?? sub; const perf = starter ?? sub;
return { return {
+14
View File
@@ -47,6 +47,20 @@ export async function subscribeTeam(team: string): Promise<'subscribed' | 'denie
} }
} }
/** Unfollow ONE team ('*' = the all-matches feed); the browser subscription
* stays alive for whatever else the device still follows. */
export async function unsubscribeTeam(team: string): Promise<void> {
try {
const sub = await currentSubscription();
if (!sub) return;
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint, team }),
});
} catch { /* best effort */ }
}
export async function unsubscribePush(): Promise<void> { export async function unsubscribePush(): Promise<void> {
try { try {
const sub = await currentSubscription(); const sub = await currentSubscription();
+36
View File
@@ -0,0 +1,36 @@
// The device's followed-teams list ('*' = every match). localStorage mirrors
// what the server believes; the server stays authoritative for sending.
const KEY = 'cup26:push-teams';
const LEGACY_KEY = 'cup26:push-team';
export function getPushTeams(): string[] {
try {
const legacy = localStorage.getItem(LEGACY_KEY);
if (legacy) {
// one-team era → seed the list once
localStorage.removeItem(LEGACY_KEY);
const seeded = [legacy];
localStorage.setItem(KEY, JSON.stringify(seeded));
return seeded;
}
const raw = localStorage.getItem(KEY);
const list: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
export function addPushTeam(team: string): string[] {
const list = getPushTeams();
if (!list.includes(team)) list.push(team);
try { localStorage.setItem(KEY, JSON.stringify(list)); } catch { /* private mode */ }
return list;
}
export function removePushTeam(team: string): string[] {
const list = getPushTeams().filter((t) => t !== team);
try { localStorage.setItem(KEY, JSON.stringify(list)); } catch { /* private mode */ }
return list;
}
+10
View File
@@ -189,6 +189,8 @@ export interface MatchPreview {
/** Bookmaker anytime-scorer board, favorites first — pre-match only, /** Bookmaker anytime-scorer board, favorites first — pre-match only,
* display only (never a model input). */ * display only (never a model input). */
scorerProps: { player: string; odds: number }[]; scorerProps: { player: string; odds: number }[];
/** US broadcast networks (ESPN lists its home market only). */
broadcasts: string[];
/** Honest note on which data layers are populated yet. */ /** Honest note on which data layers are populated yet. */
dataNote: string; dataNote: string;
updatedAt: number | null; updatedAt: number | null;
@@ -249,10 +251,15 @@ export interface MatchRich {
weather: { tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number; tz: string } | null; weather: { tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number; tz: string } | null;
/** Curated full-time stats (passes, duels, xGOT…); null until captured. */ /** Curated full-time stats (passes, duels, xGOT…); null until captured. */
fullStats: MatchFullStat[] | null; fullStats: MatchFullStat[] | null;
/** Attacking-zone share per side (left/center/right thirds, percent). */
zones: { home: ZoneSplit; away: ZoneSplit } | null;
/** Lineups are official (FIFA XI published / FotMob no longer "predicted"). */ /** Lineups are official (FIFA XI published / FotMob no longer "predicted"). */
lineupConfirmed: boolean; lineupConfirmed: boolean;
} }
/** Attack share by pitch third, from the attacking side's perspective. */
export interface ZoneSplit { left: number; center: number; right: number }
export interface TeamProfile { export interface TeamProfile {
team: string; team: string;
elo: number; elo: number;
@@ -266,6 +273,9 @@ export interface TeamProfile {
discipline: PlayerDiscipline[]; discipline: PlayerDiscipline[];
/** Unavailable/doubtful players (injury feed; empty until a key is set). */ /** Unavailable/doubtful players (injury feed; empty until a key is set). */
injuries: InjuryInfo[]; injuries: InjuryInfo[];
/** The FIFA tournament squad (from the latest captured live payload;
* empty until the team has played with FIFA coverage). pos: 0 GK…3 FW. */
squad: { name: string; num: number | null; pos: number | null }[];
} }
// ---- player profiles (/api/player) ---- // ---- player profiles (/api/player) ----
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { pushSupported, serverPushKey, subscribeTeam, unsubscribePush, unsubscribeTeam } from './push';
import { addPushTeam, getPushTeams, removePushTeam } from './pushPrefs';
/** Follow/unfollow one push feed (a team, or '*' for every match). The
* browser subscription is dropped only when the device's list empties. */
export function usePushFollow(team: string): {
avail: boolean;
active: boolean;
denied: boolean;
toggle: () => Promise<void>;
} {
const [avail, setAvail] = useState(false);
const [denied, setDenied] = useState(false);
const [teams, setTeams] = useState<string[]>(() => getPushTeams());
useEffect(() => {
let alive = true;
void serverPushKey().then((k) => alive && setAvail(Boolean(k) && pushSupported()));
return () => { alive = false; };
}, []);
const active = teams.includes(team);
const toggle = async (): Promise<void> => {
if (active) {
const rest = removePushTeam(team);
if (rest.length === 0) await unsubscribePush();
else await unsubscribeTeam(team);
setTeams(rest);
return;
}
const res = await subscribeTeam(team);
if (res === 'subscribed') {
setTeams(addPushTeam(team));
setDenied(false);
} else if (res === 'denied') {
setDenied(true);
}
};
return { avail, active, denied, toggle };
}
+3 -1
View File
@@ -24,13 +24,15 @@ const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story'
const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage }); const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage });
const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage }); const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage });
const playerRoute = createRoute({ getParentRoute: () => rootRoute, path: '/player/$name', component: lazyRouteComponent(() => import('./features/player/PlayerPage'), 'PlayerPage') }); const playerRoute = createRoute({ getParentRoute: () => rootRoute, path: '/player/$name', component: lazyRouteComponent(() => import('./features/player/PlayerPage'), 'PlayerPage') });
const venuesRoute = createRoute({ getParentRoute: () => rootRoute, path: '/venues', component: lazyRouteComponent(() => import('./features/venues/VenuesPage'), 'VenuesPage') });
const venueRoute = createRoute({ getParentRoute: () => rootRoute, path: '/venue/$name', component: lazyRouteComponent(() => import('./features/venues/VenuesPage'), 'VenueDetailPage') });
const methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/methodology', component: lazyRouteComponent(() => import('./features/methodology/MethodologyPage'), 'MethodologyPage') }); const methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/methodology', component: lazyRouteComponent(() => import('./features/methodology/MethodologyPage'), 'MethodologyPage') });
const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage }); const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage });
const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage }); const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage });
const dataRoute = createRoute({ getParentRoute: () => rootRoute, path: '/data', component: lazyRouteComponent(() => import('./features/data/DataPage'), 'DataPage') }); const dataRoute = createRoute({ getParentRoute: () => rootRoute, path: '/data', component: lazyRouteComponent(() => import('./features/data/DataPage'), 'DataPage') });
const compareRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compare', component: lazyRouteComponent(() => import('./features/compare/ComparePage'), 'ComparePage') }); const compareRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compare', component: lazyRouteComponent(() => import('./features/compare/ComparePage'), 'ComparePage') });
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, statsRoute, storyRoute, matchRoute, teamRoute, playerRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute, compareRoute]); const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, statsRoute, storyRoute, matchRoute, teamRoute, playerRoute, venuesRoute, venueRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute, compareRoute]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' }); export const router = createRouter({ routeTree, defaultPreload: 'intent' });