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:
+41
-8
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
+31
-6
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<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
|
||||
* surprise degrades to fewer rows (or none) — never a throw. */
|
||||
export function normalizeMatchStats(raw: unknown): MatchFullStat[] {
|
||||
|
||||
+12
-2
@@ -73,7 +73,7 @@ function playerIndex(state: TournamentState): Map<string, PlayerRef> {
|
||||
}
|
||||
|
||||
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<string, { at: number; profile: PlayerProfile | 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
+19
-1
@@ -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 [];
|
||||
}
|
||||
|
||||
+7
-3
@@ -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<void> {
|
||||
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(
|
||||
subs.map(async (s) => {
|
||||
try {
|
||||
|
||||
@@ -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() {
|
||||
<p>
|
||||
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="/venues" className="underline decoration-line underline-offset-2 hover:text-ink">{t.nav.venues}</Link> ·{' '}
|
||||
{t.footer.disclaimer}
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
@@ -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 (
|
||||
<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
|
||||
* 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 })}
|
||||
/>
|
||||
|
||||
<AllMatchesBell />
|
||||
|
||||
<DailyDigest />
|
||||
|
||||
{hero && <LiveHero f={hero} />}
|
||||
|
||||
@@ -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]}
|
||||
<span className="text-line-strong"> · </span>{kickoffDay(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 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">
|
||||
@@ -278,7 +286,7 @@ export function MatchPreviewPage() {
|
||||
{heroChips.map((c) => <HeroChip key={c.label} label={c.label} value={c.value} gold={c.gold ?? false} />)}
|
||||
</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">
|
||||
{weatherText && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
@@ -287,6 +295,7 @@ export function MatchPreviewPage() {
|
||||
</div>
|
||||
)}
|
||||
{infoText && <div>{infoText}</div>}
|
||||
{tvText && <div>{tvText}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -511,6 +520,35 @@ export function MatchPreviewPage() {
|
||||
</CardBody>
|
||||
</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 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
|
||||
@@ -601,7 +639,7 @@ export function MatchPreviewPage() {
|
||||
) : (
|
||||
<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>
|
||||
<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 ? (
|
||||
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
|
||||
) : (
|
||||
@@ -615,8 +653,8 @@ export function MatchPreviewPage() {
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.recentForm}</span></CardHeader>
|
||||
<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"><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.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 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>
|
||||
</Card>
|
||||
)}
|
||||
@@ -633,7 +671,7 @@ export function MatchPreviewPage() {
|
||||
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">{t.match.h2h.recentMeetings}</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,9 @@ export function PlayerPage() {
|
||||
<span aria-hidden>{teamFlag(m.opponent)}</span>
|
||||
<span className="truncate">{m.opponent}</span>
|
||||
</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 className="px-2 py-1.5">
|
||||
{us != null && them != null && (
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<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>
|
||||
@@ -82,7 +82,7 @@ function RoadToFinal({ team }: { team: string }) {
|
||||
</li>
|
||||
))}
|
||||
</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) })}
|
||||
</div>
|
||||
<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">
|
||||
<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 aria-hidden className="grid w-5 place-items-center">{opp.team ? teamFlag(opp.team) : <Shield size={14} className="text-faint" />}</span>
|
||||
<span className="truncate text-ink">{opp.label}</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="min-w-0 truncate text-ink">{opp.label}</span>
|
||||
<span
|
||||
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}
|
||||
@@ -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
|
||||
* 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 }) {
|
||||
const t = useT();
|
||||
const [avail, setAvail] = useState(false);
|
||||
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; };
|
||||
}, []);
|
||||
|
||||
const { avail, active, denied, toggle } = usePushFollow(team);
|
||||
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 (
|
||||
<span className="inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggle()}
|
||||
aria-label={active ? t.team.notifyOff : t.team.notifyOn}
|
||||
aria-pressed={active}
|
||||
title={denied ? t.team.notifyDenied : active ? t.team.notifyOff : t.team.notifyOn}
|
||||
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} />}
|
||||
</button>
|
||||
{denied && <span className="text-[11px] text-faint">{t.team.notifyDenied}</span>}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggle()}
|
||||
aria-label={active ? t.team.notifyOff : t.team.notifyOn}
|
||||
aria-pressed={active}
|
||||
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'}`}
|
||||
>
|
||||
{active ? <BellRing size={18} /> : <Bell size={18} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,10 +187,11 @@ export function TeamProfilePage() {
|
||||
|
||||
<Card>
|
||||
<CardBody className="flex flex-wrap items-center gap-4">
|
||||
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
|
||||
<div>
|
||||
<h1 className="flex items-center gap-1 font-display text-2xl font-extrabold text-ink">
|
||||
<span className="mr-1">{profile.team}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<span aria-hidden className="shrink-0 text-5xl">{teamFlag(profile.team)}</span>
|
||||
<div className="min-w-0">
|
||||
<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} />
|
||||
<NotifyBell team={profile.team} />
|
||||
</h1>
|
||||
@@ -242,8 +209,9 @@ export function TeamProfilePage() {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{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="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>
|
||||
@@ -330,6 +298,31 @@ export function TeamProfilePage() {
|
||||
) : <p className="text-sm text-faint">{t.common.noData}</p>}
|
||||
</CardBody>
|
||||
</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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span className="w-5 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 className="w-5 shrink-0 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span>
|
||||
<span aria-hidden className="shrink-0 text-2xl leading-none">{teamFlag(team.team)}</span>
|
||||
<div className="min-w-0">
|
||||
<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 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="text-[11px] text-faint">{fmt(t.teams.advanceShort, { pct: pct(team.qualify) })}</div>
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
try {
|
||||
const sub = await currentSubscription();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) ----
|
||||
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user