diff --git a/server/src/db/db.ts b/server/src/db/db.ts index fe9faa0..327e46f 100644 --- a/server/src/db/db.ts +++ b/server/src/db/db.ts @@ -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 TABLE IF NOT EXISTS push_subs ( - endpoint TEXT PRIMARY KEY, + endpoint TEXT NOT NULL, json TEXT NOT NULL, - team TEXT, - created_at INTEGER NOT NULL + team TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (endpoint, team) ); 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` // stays: the planned API-Football sweep will fill it.) 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; console.log(`[db] sqlite ready → ${file}`); return d; @@ -242,24 +271,28 @@ export function injuriesFor(team: string): { player: string; status: string | nu export interface PushSub { endpoint: 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() .prepare('INSERT OR REPLACE INTO push_subs (endpoint, json, team, created_at) VALUES (?, ?, ?, ?)') .run(endpoint, json, team, Date.now()); } -export function removePushSub(endpoint: string): void { - db().prepare('DELETE FROM push_subs WHERE endpoint = ?').run(endpoint); +/** Drop one followed team, or the whole device when no team is given. */ +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[] { if (teams.length === 0) return []; const marks = teams.map(() => '?').join(','); 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[]; } diff --git a/server/src/index.ts b/server/src/index.ts index 2617046..3905246 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,7 +12,7 @@ import { buildPreview, buildTeamProfile } from './preview'; import { buildPlayerProfile, playersIndex } from './player'; import { buildScoreboard, snapshotCheck } from './scoreboard'; import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats'; -import { normalizeMatchStats } from './ingest/fotmobStatsNormalize'; +import { normalizeAttackingZones, normalizeMatchStats } from './ingest/fotmobStatsNormalize'; import { confirmedXI } from './ingest/fifaLineupNormalize'; import { buildIcs } from './ics'; 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 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 STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); const IS_PROD = process.env.NODE_ENV === 'production'; @@ -120,9 +126,9 @@ export function buildServer() { }); app.post('/api/push/unsubscribe', async (req, reply) => { 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' }); - pushUnsubscribe(b.endpoint); + pushUnsubscribe(b.endpoint, typeof b.team === 'string' ? b.team : undefined); return { ok: true }; }); @@ -143,6 +149,7 @@ export function buildServer() { homeTeamId?: number | null; shots?: FmShot[]; stats?: unknown; + attackingZones?: unknown; momentum?: { main?: { data?: { minute: number; value: number }[] } } | null; matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null; lineup?: { lineupType?: string; homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null; @@ -218,6 +225,7 @@ export function buildServer() { : null, weather: weather ? weather.data : null, fullStats: fullStats.length ? fullStats : null, + zones: normalizeAttackingZones(fm?.data.attackingZones), // Official XI published by FIFA, or FotMob no longer calling its // lineup "predicted" — either way, stop labeling them as projections. 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' }); }); app.get('/api/team/:name', async (req, reply) => { - const name = canonicalTeam(decodeURIComponent((req.params as { name: string }).name)); - const profile = buildTeamProfile(name, state, model); + const raw = safeDecode((req.params as { name: string }).name); + 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' }); }); // Player profiles, assembled from stored boards, lineups, shots and events. // Either source's spelling of a name resolves ("Hwang In-Beom" = "In-Beom Hwang"). 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); 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`, ); } + 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 */ } return null; }; diff --git a/server/src/ingest/espnNormalize.ts b/server/src/ingest/espnNormalize.ts index 7750ee9..7d83559 100644 --- a/server/src/ingest/espnNormalize.ts +++ b/server/src/ingest/espnNormalize.ts @@ -75,7 +75,7 @@ export interface EspnTimelineEvent { shootout?: boolean; } /** 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 } @@ -102,6 +102,8 @@ export interface EspnSummary { report?: { headline: string; story: string } | null; /** Match video clips (highlights, reactions) — links open on ESPN. */ videos?: EspnVideo[]; + /** US broadcast networks (ESPN only lists its home market). */ + broadcasts?: string[]; } export interface RawSummary { @@ -109,7 +111,10 @@ export interface RawSummary { commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[]; article?: { headline?: string; story?: 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 }[] }[]; boxscore?: { form?: { team?: { id: string }; events?: { gameResult?: string }[] }[]; @@ -206,5 +211,11 @@ export function normalizeSummary(raw: RawSummary): EspnSummary { }) .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 }; } diff --git a/server/src/ingest/fifaLineupNormalize.test.ts b/server/src/ingest/fifaLineupNormalize.test.ts index 6d8bca9..ae9cf18 100644 --- a/server/src/ingest/fifaLineupNormalize.test.ts +++ b/server/src/ingest/fifaLineupNormalize.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { confirmedXI } from './fifaLineupNormalize'; +import { confirmedXI, squadList, tidyName } from './fifaLineupNormalize'; const squad = (starters: number, bench = 15, prefix = 'P') => ({ Players: [ @@ -32,3 +32,33 @@ describe('confirmedXI', () => { 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'); + }); +}); diff --git a/server/src/ingest/fifaLineupNormalize.ts b/server/src/ingest/fifaLineupNormalize.ts index 405449e..7b27f21 100644 --- a/server/src/ingest/fifaLineupNormalize.ts +++ b/server/src/ingest/fifaLineupNormalize.ts @@ -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 ~75–90 min before kickoff; Status === 1 // 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[] } /** The `live` value stored under match_provider provider='fifa'. */ 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); 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)); +} diff --git a/server/src/ingest/fotmobStatsNormalize.test.ts b/server/src/ingest/fotmobStatsNormalize.test.ts index ef546eb..b526bc9 100644 --- a/server/src/ingest/fotmobStatsNormalize.test.ts +++ b/server/src/ingest/fotmobStatsNormalize.test.ts @@ -72,6 +72,19 @@ describe('normalizeMatchStats', () => { 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', () => { const rows = normalizeMatchStats({ Periods: { All: { stats: [{ stats: [ diff --git a/server/src/ingest/fotmobStatsNormalize.ts b/server/src/ingest/fotmobStatsNormalize.ts index 7414a69..aa6b1bc 100644 --- a/server/src/ingest/fotmobStatsNormalize.ts +++ b/server/src/ingest/fotmobStatsNormalize.ts @@ -35,6 +35,28 @@ export interface RawMatchStats { Periods?: { All?: { stats?: RawStatGroup[] } } const usable = (v: unknown): v is string | number => (typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && v.trim() !== ''); +export interface ZoneSplit { left: number; center: number; right: number } +interface RawZoneSide { total?: Partial } +/** 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 * surprise degrades to fewer rows (or none) — never a throw. */ export function normalizeMatchStats(raw: unknown): MatchFullStat[] { diff --git a/server/src/player.ts b/server/src/player.ts index 8a55d5f..cacb2ec 100644 --- a/server/src/player.ts +++ b/server/src/player.ts @@ -73,7 +73,7 @@ function playerIndex(state: TournamentState): Map { } function assemble(nameParam: string, state: TournamentState): PlayerProfile | null { - const ref = resolvePlayer(nameParam, playerIndex(state)); + const ref = resolvePlayer(nameParam, cachedPlayerIndex(state)); if (!ref) return null; const { name, team } = ref; @@ -147,6 +147,16 @@ function assemble(nameParam: string, state: TournamentState): PlayerProfile | nu const TTL = 60_000; const memo = new Map(); let listMemo: { at: number; players: PlayerRef[] } | null = null; +let indexMemo: { at: number; index: Map } | 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 { + 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 { const k = aliasKey(nameParam); @@ -161,7 +171,7 @@ export function buildPlayerProfile(nameParam: string, state: TournamentState): P /** The search palette's lightweight player list. */ export function playersIndex(state: TournamentState): PlayerRef[] { 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 }; return players; } diff --git a/server/src/preview.ts b/server/src/preview.ts index f135982..ab10a95 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -1,6 +1,7 @@ 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 { squadList } from './ingest/fifaLineupNormalize'; import { disciplineFor } from './stats'; import type { TournamentState } from './tournament'; import type { ModelEngine } from './model'; @@ -152,6 +153,7 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn videos: summary?.videos ?? [], // The board disappears at kickoff — in-play lines drift too fast to mirror. scorerProps: fixture.status === 'scheduled' ? whoScores(scorerPropsFor(num)) : [], + broadcasts: summary?.broadcasts ?? [], dataNote: !home || !away ? 'Knockout participants are decided once the bracket resolves.' : ext @@ -182,5 +184,21 @@ export function buildTeamProfile(teamRaw: string, state: TournamentState, model: qualifyOdds: odds?.qualify ?? null, discipline: disciplineFor(teamRaw, state).filter((p) => p.yellows + p.reds > 0), 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 []; +} diff --git a/server/src/push.ts b/server/src/push.ts index 9345ad0..a3eaaa9 100644 --- a/server/src/push.ts +++ b/server/src/push.ts @@ -17,17 +17,21 @@ if (pushEnabled) { 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 { addPushSub(subscription.endpoint, JSON.stringify(subscription), team); } -export function unsubscribe(endpoint: string): void { - removePushSub(endpoint); +/** Unfollow one team, or the whole device when no team is given. */ +export function unsubscribe(endpoint: string, team?: string): void { + removePushSub(endpoint, team); } async function send(team: string[], title: string, body: string, url: string): Promise { 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(); + const subs = pushSubsForTeams(team).filter((s) => !seen.has(s.endpoint) && !!seen.add(s.endpoint)); await Promise.allSettled( subs.map(async (s) => { try { diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 7918b48..e2747dd 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; 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 type { LucideIcon } from 'lucide-react'; import { useUiStore } from '@/stores/uiStore'; @@ -27,6 +27,7 @@ const MOBILE_PRIMARY: NavItem[] = NAV.slice(0, 4); const MOBILE_MORE: NavItem[] = [ ...NAV.slice(4), { 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: '/data', label: (t) => t.nav.data, exact: false, icon: Database }, ]; @@ -247,6 +248,7 @@ export function RootLayout() {

Cup26 · {t.footer.worldCup} · {t.footer.allData} ·{' '} {t.footer.howModelWorks} ·{' '} + {t.nav.venues} ·{' '} {t.footer.disclaimer}

diff --git a/src/features/live/LivePage.tsx b/src/features/live/LivePage.tsx index 4164984..e36b772 100644 --- a/src/features/live/LivePage.tsx +++ b/src/features/live/LivePage.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; 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 { MatchCard } from '@/components/MatchCard'; import { DailyDigest } from '@/components/DailyDigest'; @@ -15,6 +16,30 @@ import type { Fixture } from '@/lib/types'; 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 ( +
+ + {denied && {t.live.notifyDenied}} +
+ ); +} + /** 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). */ function LiveHero({ f }: { f: Fixture }) { @@ -139,6 +164,8 @@ export function LivePage() { subtitle={fmt(t.live.subtitle, { season: snapshot.season })} /> + + {hero && } diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index d9c5980..3ef1be3 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -192,6 +192,9 @@ export function MatchPreviewPage() { 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 }) : 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 }[] = []; if (hasScore) { @@ -215,7 +218,7 @@ export function MatchPreviewPage() { // Tabs only appear when their data exists (e.g. no Timeline before kickoff). const tabs: TabKey[] = ['summary']; 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'); 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]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} - · {preview.venue} + · + {f.venue ? ( + {preview.venue} + ) : ( + {preview.venue} + )}
@@ -278,7 +286,7 @@ export function MatchPreviewPage() { {heroChips.map((c) => )}
)} - {(weatherText || infoText) && ( + {(weatherText || infoText || tvText) && (
{weatherText && (
@@ -287,6 +295,7 @@ export function MatchPreviewPage() {
)} {infoText &&
{infoText}
} + {tvText &&
{tvText}
}
)} @@ -511,6 +520,35 @@ export function MatchPreviewPage() { )} + {rich?.zones && ( + + {t.match.zonesTitle} + + {([ + ['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 ( +
+
{name}
+
+
+
+
+
+
+ {t.match.zones.left} {z.left}% + {t.match.zones.center} {z.center}% + {t.match.zones.right} {z.right}% +
+
+ ); + })} +

{t.match.zonesNote}

+ + + )} {rich && rich.momentum.length >= 10 && ( {t.match.momentumTitle} @@ -601,7 +639,7 @@ export function MatchPreviewPage() { ) : ( {preview.lineups ? t.match.lineups : t.match.keyPlayers} - + {preview.lineups ? ( <> ) : ( @@ -615,8 +653,8 @@ export function MatchPreviewPage() { {t.match.recentForm} -
{f.home.team && teamFlag(f.home.team)} {homeName}
-
{f.away.team && teamFlag(f.away.team)} {awayName}
+
{f.home.team && teamFlag(f.home.team)} {homeName}
+
{f.away.team && teamFlag(f.away.team)} {awayName}
)} @@ -633,7 +671,7 @@ export function MatchPreviewPage() {
{t.match.h2h.recentMeetings}
    {preview.h2h.last.map((m, i) => ( -
  • {m.date.slice(0, 10)}{m.home} {m.homeScore}–{m.awayScore} {m.away}
  • +
  • {m.date.slice(0, 10)}{m.home} {m.homeScore}–{m.awayScore} {m.away}
  • ))}
diff --git a/src/features/player/PlayerPage.tsx b/src/features/player/PlayerPage.tsx index 5f832d7..fa79abb 100644 --- a/src/features/player/PlayerPage.tsx +++ b/src/features/player/PlayerPage.tsx @@ -145,7 +145,9 @@ export function PlayerPage() { {teamFlag(m.opponent)} {m.opponent} - {kickoffDay(m.kickoff)} · {m.started ? t.player.started : t.player.cameOn} + + {kickoffDay(m.kickoff)} · {m.started ? t.player.started : m.minutes != null ? t.player.cameOn : t.player.benched} + {us != null && them != null && ( diff --git a/src/features/team/TeamProfilePage.tsx b/src/features/team/TeamProfilePage.tsx index a1ebf85..5106491 100644 --- a/src/features/team/TeamProfilePage.tsx +++ b/src/features/team/TeamProfilePage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams } from '@tanstack/react-router'; import { ArrowLeft, Bell, BellRing, CalendarPlus, Route, Shield, Star, Stethoscope } from 'lucide-react'; import { EloHistoryChart } from '@/components/EloHistoryChart'; -import { pushSupported, serverPushKey, subscribeTeam, unsubscribePush } from '@/lib/push'; +import { usePushFollow } from '@/lib/usePushFollow'; import { useFavoriteStore } from '@/stores/favoriteStore'; import { useTournamentStore } from '@/stores/tournamentStore'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; @@ -73,7 +73,7 @@ function RoadToFinal({ team }: { team: string }) { {p.opponent ? ( <> {teamFlag(p.opponent)} - {p.opponent} + {p.opponent} ) : ( {t.team.road.tbd} @@ -82,7 +82,7 @@ function RoadToFinal({ team }: { team: string }) { ))} -
+
{fmt(t.team.road.winItAll, { pct: pct(odds.champion) })}

{t.team.road.note}

@@ -103,8 +103,8 @@ function FixtureRow({ f, team }: { f: Fixture; team: string }) { {f.group ? fmt(t.team.groupShort, { group: f.group }) : f.stage !== 'group' ? t.team.stageShort[f.stage] : f.round} {t.common.vs} - {opp.team ? teamFlag(opp.team) : } - {opp.label} + {opp.team ? teamFlag(opp.team) : } + {opp.label} (() => { - 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; }; - }, []); - + const { avail, active, denied, toggle } = usePushFollow(team); if (!avail) return null; - const active = subTeam === team; - - const toggle = async (): Promise => { - 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 ( - - - {denied && {t.team.notifyDenied}} - + ); } @@ -221,10 +187,11 @@ export function TeamProfilePage() { - {teamFlag(profile.team)} -
-

- {profile.team} +
+ {teamFlag(profile.team)} +
+

+ {profile.team}

@@ -242,8 +209,9 @@ export function TeamProfilePage() {
+

{s && ( -
+
{t.team.groupStanding}
{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 })}
@@ -330,6 +298,31 @@ export function TeamProfilePage() { ) :

{t.common.noData}

} + {profile.squad.length > 0 && ( + + {t.team.squadTitle} + + {([0, 1, 2, 3] as const).map((pos) => { + const group = profile.squad.filter((p) => p.pos === pos); + if (!group.length) return null; + return ( +
+
{t.team.pos[pos]}
+
    + {group.map((p) => ( +
  • + {p.num ?? ''} + +
  • + ))} +
+
+ ); + })} +

{t.team.squadNote}

+
+
+ )}
diff --git a/src/features/teams/TeamsPage.tsx b/src/features/teams/TeamsPage.tsx index ff83e58..c01f523 100644 --- a/src/features/teams/TeamsPage.tsx +++ b/src/features/teams/TeamsPage.tsx @@ -79,15 +79,15 @@ export function TeamsPage() { key={team.team} to="/team/$name" 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" > - {i + 1} - {teamFlag(team.team)} + {i + 1} + {teamFlag(team.team)}
{team.team}
-
{team.group ? fmt(t.common.group, { group: team.group }) : ''}
+
{team.group ? fmt(t.common.group, { group: team.group }) : ''}
-
+
{pct(team.champion)}
{fmt(t.teams.advanceShort, { pct: pct(team.qualify) })}
diff --git a/src/features/venues/VenuesPage.tsx b/src/features/venues/VenuesPage.tsx new file mode 100644 index 0000000..d6e1c5f --- /dev/null +++ b/src/features/venues/VenuesPage.tsx @@ -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; + +function useVenueInfo(): VenueMap { + const [info, setInfo] = useState({}); + 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(); + 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 ? {fmt(label, { m: Math.round(info.elevation) })} : null; + +function VenueRow({ f }: { f: Fixture }) { + const { t, kickoffDay, kickoffTime } = useFormat(); + const done = f.status === 'finished' || f.status === 'live'; + return ( + +
+ {kickoffDay(f.kickoff)} + {f.status === 'scheduled' ? kickoffTime(f.kickoff) : f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} +
+
+ {f.home.team ? teamFlag(f.home.team) : ''} + {f.home.label} + {done && f.homeScore != null ? ( + {f.homeScore}–{f.awayScore} + ) : ( + {t.common.vs} + )} + {f.away.label} + {f.away.team ? teamFlag(f.away.team) : ''} +
+ + ); +} + +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
; + + return ( +
+ +
+ {venues.map((v) => ( + + + +
+ +
+
{v.name}
+
+ {plural(t.venues.matches, v.fixtures.length)} + {v.played > 0 && · {plural(t.venues.goalsShort, v.goals)}} + {altitudeBadge(info[v.name], t.match.altitude)} +
+ {v.next && ( +
+ {fmt(t.venues.next, { match: `${v.next.home.label} – ${v.next.away.label}` })} +
+ )} +
+
+
+
+ + ))} +
+

{t.venues.note}

+
+ ); +} + +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
; + if (!venue) { + return ( +
+ {t.venues.unknown}{' '} + {t.venues.all} +
+ ); + } + + const vi = info[venue.name]; + return ( +
+ + {t.venues.all} + +
+ +
+

{venue.name}

+
+ {plural(t.venues.matches, venue.fixtures.length)} + {venue.played > 0 && · {fmt(t.venues.playedLine, { played: venue.played, goals: venue.goals })}} + {vi && · {fmt(t.venues.tzLine, { tz: vi.tz.replace(/_/g, ' ') })}} + {altitudeBadge(vi, t.match.altitude)} +
+
+
+ + {t.venues.schedule} + + {venue.fixtures.map((f) => )} + + +
+ ); +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 132a211..cc6d2a0 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -13,6 +13,7 @@ export const de: Dict = { story: "Story", teams: "Teams", data: "Daten", + venues: "Stadien", compare: "Vergleich", more: "Mehr", }, @@ -64,6 +65,9 @@ export const de: Dict = { title: "Live", loadingSubtitle: "Turnier wird geladen…", 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", todayHeading: "Spiele heute", nextUp: "Als Nächstes", @@ -152,6 +156,14 @@ export const de: Dict = { title: "Wer trifft?", 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: { summary: "Übersicht", timeline: "Spielverlauf", @@ -581,6 +593,14 @@ export const de: Dict = { tbd: "noch offen", }, 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", suspChipFor: "fehlt in Sp. {num}", atRiskChip: "eine Gelbe vor einer Sperre", @@ -604,6 +624,25 @@ export const de: Dict = { 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: { unknown: "Keine Turnierdaten zu diesem Spieler.", 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.", shotMapTitle: "Alle Schüsse in diesem Turnier", 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.", goalsLabel: "Tore", }, diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 43f6618..80aaadc 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -13,6 +13,7 @@ export const en = { teams: "Teams", data: "Data", compare: "Compare", + venues: "Venues", more: "More", }, common: { @@ -63,6 +64,9 @@ export const en = { title: "Live", loadingSubtitle: "Loading the tournament…", 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", todayHeading: "Today's matches", nextUp: "Next up", @@ -151,6 +155,14 @@ export const en = { title: "Who scores?", 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: { summary: "Summary", timeline: "Timeline", @@ -580,6 +592,14 @@ export const en = { tbd: "to be decided", }, 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", suspChipFor: "out for M{num}", atRiskChip: "one yellow from a ban", @@ -603,6 +623,25 @@ export const en = { 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: { unknown: "No tournament data for this player.", backToStats: "Back to stats", diff --git a/src/lib/playerProfile.test.ts b/src/lib/playerProfile.test.ts index 12239d6..81532db 100644 --- a/src/lib/playerProfile.test.ts +++ b/src/lib/playerProfile.test.ts @@ -64,8 +64,10 @@ describe('playerMatchLine', () => { expect(line?.minutes).toBeNull(); }); - it('an unused sub with no events is no row at all', () => { - expect(playerMatchLine('C', fx({ bench: [{ name: 'C', rating: null, subIn: null, subOut: null }] }))).toBeNull(); + it('an unused sub gets a DNP row; a player absent from the squad gets none', () => { + 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', () => { diff --git a/src/lib/playerProfile.ts b/src/lib/playerProfile.ts index 9553761..f7732c8 100644 --- a/src/lib/playerProfile.ts +++ b/src/lib/playerProfile.ts @@ -116,9 +116,9 @@ export function playerMatchLine(name: string, fx: PlayerFixtureData): PlayerMatc } } - const appeared = starter != null || sub?.subIn != null; - const involved = goals + assists + yellows + reds > 0; - if (!appeared && !involved) return null; + // In the squad (even unused on the bench) or in the event feed → a row; + // completely absent from a fixture → none. + if (starter == null && sub == null && goals + assists + yellows + reds === 0) return null; const perf = starter ?? sub; return { diff --git a/src/lib/push.ts b/src/lib/push.ts index c63e930..ad1d7e7 100644 --- a/src/lib/push.ts +++ b/src/lib/push.ts @@ -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 { + 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 { try { const sub = await currentSubscription(); diff --git a/src/lib/pushPrefs.ts b/src/lib/pushPrefs.ts new file mode 100644 index 0000000..88c18b4 --- /dev/null +++ b/src/lib/pushPrefs.ts @@ -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; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 3ee40e2..3c39982 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -189,6 +189,8 @@ export interface MatchPreview { /** Bookmaker anytime-scorer board, favorites first — pre-match only, * display only (never a model input). */ 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. */ dataNote: string; 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; /** Curated full-time stats (passes, duels, xGOT…); null until captured. */ 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"). */ 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 { team: string; elo: number; @@ -266,6 +273,9 @@ export interface TeamProfile { discipline: PlayerDiscipline[]; /** Unavailable/doubtful players (injury feed; empty until a key is set). */ 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) ---- diff --git a/src/lib/usePushFollow.ts b/src/lib/usePushFollow.ts new file mode 100644 index 0000000..be5f102 --- /dev/null +++ b/src/lib/usePushFollow.ts @@ -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; +} { + const [avail, setAvail] = useState(false); + const [denied, setDenied] = useState(false); + const [teams, setTeams] = useState(() => 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 => { + 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 }; +} diff --git a/src/router.tsx b/src/router.tsx index a23adf0..f050e49 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -24,13 +24,15 @@ const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story' const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage }); 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 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 teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage }); const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage }); 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 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' });