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