Fan Tier 2: venue pages, attack zones, broadcasts, push prefs, squads + UI/UX polish
Features (all from already-stored data):
- Venue pages: /venues hub + /venue/:name with each stadium's schedule,
altitude badge (>1000 m), local timezone. Linked from the match hero,
nav More sheet, and footer.
- Attacking-zones card on the match Stats tab (left/center/right share per
side, from the stored FotMob payload).
- US broadcast networks on the pre-match hero (SUMMARY_V 6).
- Notification preferences: follow multiple teams + an all-matches feed
('*'); push_subs keyed by (endpoint, team) with a safe one-time migration;
device drops its browser subscription only when the last follow is removed.
- FIFA tournament squad card on team pages (26-man list, position groups).
- Player match logs now show DNP rows for unused-squad appearances.
- OG/share meta for /player and /venue.
Polish (adversarial audit: 25 confirmed findings, all fixed):
- Mobile horizontal-scroll eliminated on /teams and /venues (grid-item
min-w-0 blowout); two-line venue fixture rows.
- Truncation actually fires now across team fixtures, road-to-final,
recent-form, H2H meetings, venue rows (min-w-0 on flex children).
- Team hero no longer detaches the standing block on wrap; bell denied-state
no longer overflows the h1.
- AllMatchesBell: 36px touch target, aria-label, own i18n key, even rhythm.
- Server hardening: cached player index (one cross-fixture scan, not per
request), migration rollback guard, 400 (not 500) on malformed name URLs,
all-zero zones render nothing, tidyName fixes shouted particles (DE→De).
- German polish: removed stray English ("anytime", "Build-Geocode"), clearer
squad note.
Gates: 170 tests, build, typecheck:server — all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+41
-8
@@ -97,10 +97,11 @@ CREATE TABLE IF NOT EXISTS scorer_props (
|
||||
CREATE INDEX IF NOT EXISTS idx_scorer_props ON scorer_props(fixture_num, market, athlete, captured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subs (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
team TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
team TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (endpoint, team)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inplay_history (
|
||||
@@ -147,6 +148,34 @@ export function db(): DatabaseSync {
|
||||
// Dead tables from abandoned plans — never written or read. (`injuries`
|
||||
// stays: the planned API-Football sweep will fill it.)
|
||||
d.exec('DROP TABLE IF EXISTS squad; DROP TABLE IF EXISTS goalscorers;');
|
||||
// push_subs grew from one team per device to one row per (endpoint, team);
|
||||
// a pre-existing table keeps its old single-column PK, so rebuild it once.
|
||||
const pushPk = d
|
||||
.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('push_subs') WHERE pk > 0")
|
||||
.get() as { c: number };
|
||||
if (pushPk.c === 1) {
|
||||
d.exec('DROP TABLE IF EXISTS push_subs_new;'); // clear any half-done prior attempt
|
||||
try {
|
||||
d.exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE push_subs_new (
|
||||
endpoint TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
team TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (endpoint, team)
|
||||
);
|
||||
INSERT INTO push_subs_new SELECT endpoint, json, COALESCE(team, '*'), created_at FROM push_subs;
|
||||
DROP TABLE push_subs;
|
||||
ALTER TABLE push_subs_new RENAME TO push_subs;
|
||||
COMMIT;
|
||||
`);
|
||||
console.log('[db] push_subs migrated to (endpoint, team) rows');
|
||||
} catch (e) {
|
||||
try { d.exec('ROLLBACK;'); } catch { /* no open txn */ }
|
||||
console.error('[db] push_subs migration failed, left as-is', e);
|
||||
}
|
||||
}
|
||||
_db = d;
|
||||
console.log(`[db] sqlite ready → ${file}`);
|
||||
return d;
|
||||
@@ -242,24 +271,28 @@ export function injuriesFor(team: string): { player: string; status: string | nu
|
||||
export interface PushSub {
|
||||
endpoint: string;
|
||||
json: string;
|
||||
team: string | null;
|
||||
/** Followed team, or '*' for the all-matches feed. */
|
||||
team: string;
|
||||
}
|
||||
|
||||
export function addPushSub(endpoint: string, json: string, team: string | null): void {
|
||||
export function addPushSub(endpoint: string, json: string, team: string): void {
|
||||
db()
|
||||
.prepare('INSERT OR REPLACE INTO push_subs (endpoint, json, team, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(endpoint, json, team, Date.now());
|
||||
}
|
||||
|
||||
export function removePushSub(endpoint: string): void {
|
||||
db().prepare('DELETE FROM push_subs WHERE endpoint = ?').run(endpoint);
|
||||
/** Drop one followed team, or the whole device when no team is given. */
|
||||
export function removePushSub(endpoint: string, team?: string): void {
|
||||
if (team != null) db().prepare('DELETE FROM push_subs WHERE endpoint = ? AND team = ?').run(endpoint, team);
|
||||
else db().prepare('DELETE FROM push_subs WHERE endpoint = ?').run(endpoint);
|
||||
}
|
||||
|
||||
/** Subscriptions following any of these teams — or every match ('*'). */
|
||||
export function pushSubsForTeams(teams: string[]): PushSub[] {
|
||||
if (teams.length === 0) return [];
|
||||
const marks = teams.map(() => '?').join(',');
|
||||
return db()
|
||||
.prepare(`SELECT endpoint, json, team FROM push_subs WHERE team IN (${marks})`)
|
||||
.prepare(`SELECT endpoint, json, team FROM push_subs WHERE team IN (${marks}) OR team = '*'`)
|
||||
.all(...teams) as unknown as PushSub[];
|
||||
}
|
||||
|
||||
|
||||
+31
-6
@@ -12,7 +12,7 @@ import { buildPreview, buildTeamProfile } from './preview';
|
||||
import { buildPlayerProfile, playersIndex } from './player';
|
||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
||||
import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats';
|
||||
import { normalizeMatchStats } from './ingest/fotmobStatsNormalize';
|
||||
import { normalizeAttackingZones, normalizeMatchStats } from './ingest/fotmobStatsNormalize';
|
||||
import { confirmedXI } from './ingest/fifaLineupNormalize';
|
||||
import { buildIcs } from './ics';
|
||||
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
|
||||
@@ -23,6 +23,12 @@ import { loadStatic } from './preview';
|
||||
import { canonicalTeam } from '../../src/lib/teams';
|
||||
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
|
||||
|
||||
/** decodeURIComponent that returns null on malformed input (a stray "%GG"
|
||||
* would otherwise throw a URIError → 500). */
|
||||
function safeDecode(s: string): string | null {
|
||||
try { return decodeURIComponent(s); } catch { return null; }
|
||||
}
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8787);
|
||||
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
|
||||
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||
@@ -120,9 +126,9 @@ export function buildServer() {
|
||||
});
|
||||
app.post('/api/push/unsubscribe', async (req, reply) => {
|
||||
if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' });
|
||||
const b = (req.body ?? {}) as { endpoint?: string };
|
||||
const b = (req.body ?? {}) as { endpoint?: string; team?: string };
|
||||
if (!b.endpoint) return reply.code(400).send({ error: 'endpoint required' });
|
||||
pushUnsubscribe(b.endpoint);
|
||||
pushUnsubscribe(b.endpoint, typeof b.team === 'string' ? b.team : undefined);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -143,6 +149,7 @@ export function buildServer() {
|
||||
homeTeamId?: number | null;
|
||||
shots?: FmShot[];
|
||||
stats?: unknown;
|
||||
attackingZones?: unknown;
|
||||
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
|
||||
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
|
||||
lineup?: { lineupType?: string; homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
|
||||
@@ -218,6 +225,7 @@ export function buildServer() {
|
||||
: null,
|
||||
weather: weather ? weather.data : null,
|
||||
fullStats: fullStats.length ? fullStats : null,
|
||||
zones: normalizeAttackingZones(fm?.data.attackingZones),
|
||||
// Official XI published by FIFA, or FotMob no longer calling its
|
||||
// lineup "predicted" — either way, stop labeling them as projections.
|
||||
lineupConfirmed: confirmedXI(fifa?.data.live) != null || fm?.data.lineup?.lineupType === 'standard',
|
||||
@@ -258,14 +266,16 @@ export function buildServer() {
|
||||
return preview ?? reply.code(404).send({ error: 'no such fixture' });
|
||||
});
|
||||
app.get('/api/team/:name', async (req, reply) => {
|
||||
const name = canonicalTeam(decodeURIComponent((req.params as { name: string }).name));
|
||||
const profile = buildTeamProfile(name, state, model);
|
||||
const raw = safeDecode((req.params as { name: string }).name);
|
||||
if (raw == null) return reply.code(400).send({ error: 'bad name' });
|
||||
const profile = buildTeamProfile(canonicalTeam(raw), state, model);
|
||||
return profile ?? reply.code(404).send({ error: 'no such team' });
|
||||
});
|
||||
// Player profiles, assembled from stored boards, lineups, shots and events.
|
||||
// Either source's spelling of a name resolves ("Hwang In-Beom" = "In-Beom Hwang").
|
||||
app.get('/api/player/:name', async (req, reply) => {
|
||||
const name = decodeURIComponent((req.params as { name: string }).name);
|
||||
const name = safeDecode((req.params as { name: string }).name);
|
||||
if (name == null) return reply.code(400).send({ error: 'bad name' });
|
||||
const profile = buildPlayerProfile(name, state);
|
||||
return profile ?? reply.code(404).send({ error: 'unknown player' });
|
||||
});
|
||||
@@ -392,6 +402,21 @@ export function buildServer() {
|
||||
`World Cup 2026 · title odds ${(odds.champion * 100).toFixed(1)}% by the Cup26 model`,
|
||||
);
|
||||
}
|
||||
const mPlayer = /^\/player\/([^/?]+)(?:\?|$)/.exec(url);
|
||||
if (mPlayer) {
|
||||
const p = buildPlayerProfile(decodeURIComponent(mPlayer[1]!), state);
|
||||
if (!p) return null;
|
||||
const goals = p.totals['goals']?.value ?? p.bootGoals;
|
||||
const goalsText = goals >= 1 ? ` · ${Math.round(goals)} goal${goals >= 2 ? 's' : ''}` : '';
|
||||
return withMeta(`${p.name} — Cup26`, `World Cup 2026 · ${p.team}${goalsText}`);
|
||||
}
|
||||
const mVenue = /^\/venue\/([^/?]+)(?:\?|$)/.exec(url);
|
||||
if (mVenue) {
|
||||
const venue = decodeURIComponent(mVenue[1]!);
|
||||
const n = state.allFixtures().filter((f) => f.venue === venue).length;
|
||||
if (!n) return null;
|
||||
return withMeta(`${venue} — Cup26`, `World Cup 2026 venue · ${n} matches`);
|
||||
}
|
||||
} catch { /* fall through to the plain shell */ }
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ export interface EspnTimelineEvent {
|
||||
shootout?: boolean;
|
||||
}
|
||||
/** Current normalizer schema — bump to make the boot backfill re-store. */
|
||||
export const SUMMARY_V = 5;
|
||||
export const SUMMARY_V = 6;
|
||||
|
||||
export interface EspnCommentaryLine { minute: number | null; text: string }
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface EspnSummary {
|
||||
report?: { headline: string; story: string } | null;
|
||||
/** Match video clips (highlights, reactions) — links open on ESPN. */
|
||||
videos?: EspnVideo[];
|
||||
/** US broadcast networks (ESPN only lists its home market). */
|
||||
broadcasts?: string[];
|
||||
}
|
||||
|
||||
export interface RawSummary {
|
||||
@@ -109,7 +111,10 @@ export interface RawSummary {
|
||||
commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[];
|
||||
article?: { headline?: string; story?: string };
|
||||
videos?: { headline?: string; duration?: number; thumbnail?: string; links?: { web?: { href?: string } } }[];
|
||||
header?: { competitions?: { competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[] }[] };
|
||||
header?: { competitions?: {
|
||||
competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[];
|
||||
broadcasts?: { media?: { shortName?: string } }[];
|
||||
}[] };
|
||||
headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[];
|
||||
boxscore?: {
|
||||
form?: { team?: { id: string }; events?: { gameResult?: string }[] }[];
|
||||
@@ -206,5 +211,11 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
|
||||
})
|
||||
.slice(0, 6);
|
||||
|
||||
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report, videos };
|
||||
const broadcasts = [...new Set(
|
||||
(raw.header?.competitions?.[0]?.broadcasts ?? [])
|
||||
.map((b) => b.media?.shortName?.trim() ?? '')
|
||||
.filter(Boolean),
|
||||
)].slice(0, 4);
|
||||
|
||||
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report, videos, broadcasts };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { confirmedXI } from './fifaLineupNormalize';
|
||||
import { confirmedXI, squadList, tidyName } from './fifaLineupNormalize';
|
||||
|
||||
const squad = (starters: number, bench = 15, prefix = 'P') => ({
|
||||
Players: [
|
||||
@@ -32,3 +32,33 @@ describe('confirmedXI', () => {
|
||||
expect(confirmedXI({ HomeTeam: { Players: 'nope' }, AwayTeam: squad(11) })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('squadList', () => {
|
||||
it('tidies the shouted surnames and orders by position group then shirt', () => {
|
||||
const live = {
|
||||
HomeTeam: {
|
||||
Players: [
|
||||
{ Status: 2, ShirtNumber: 9, Position: 3, ShortName: [{ Description: 'Raul JIMENEZ' }] },
|
||||
{ Status: 1, ShirtNumber: 1, Position: 0, ShortName: [{ Description: 'Raul RANGEL' }] },
|
||||
{ Status: 1, ShirtNumber: 3, Position: 1, ShortName: [{ Description: 'Cesar MONTES' }] },
|
||||
{ Status: 1, ShirtNumber: 6, Position: 2, ShortName: [{ Description: 'J. AL-HUSSAIN' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(squadList(live, 'home')).toEqual([
|
||||
{ name: 'Raul Rangel', num: 1, pos: 0 },
|
||||
{ name: 'Cesar Montes', num: 3, pos: 1 },
|
||||
{ name: 'J. Al-Hussain', num: 6, pos: 2 },
|
||||
{ name: 'Raul Jimenez', num: 9, pos: 3 },
|
||||
]);
|
||||
expect(squadList(live, 'away')).toEqual([]);
|
||||
expect(squadList(null, 'home')).toEqual([]);
|
||||
});
|
||||
|
||||
it('tidyName keeps initials, fixes short particles, leaves mixed-case', () => {
|
||||
expect(tidyName('J. GALLARDO')).toBe('J. Gallardo');
|
||||
expect(tidyName('Hirving Lozano')).toBe('Hirving Lozano');
|
||||
expect(tidyName('Kevin DE BRUYNE')).toBe('Kevin De Bruyne');
|
||||
expect(tidyName('EL YAMIQ')).toBe('El Yamiq');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Pure normalizer for the FIFA live payload's lineups — no network or DB.
|
||||
// Pure normalizers for the FIFA live payload — no network or DB.
|
||||
// FIFA publishes the official XI ~75–90 min before kickoff; Status === 1
|
||||
// marks starters within the 26-man squad list.
|
||||
|
||||
interface RawFifaPlayer { Status?: number; ShortName?: { Description?: string }[] }
|
||||
interface RawFifaPlayer {
|
||||
Status?: number;
|
||||
ShirtNumber?: number;
|
||||
/** 0 = GK, 1 = DF, 2 = MF, 3 = FW (verified against the match-1 squads). */
|
||||
Position?: number;
|
||||
ShortName?: { Description?: string }[];
|
||||
}
|
||||
interface RawFifaTeam { Players?: RawFifaPlayer[] }
|
||||
/** The `live` value stored under match_provider provider='fifa'. */
|
||||
export interface RawFifaLive { HomeTeam?: RawFifaTeam; AwayTeam?: RawFifaTeam }
|
||||
@@ -27,3 +33,30 @@ export function confirmedXI(raw: unknown): { home: string[]; away: string[] } |
|
||||
const away = side(live?.AwayTeam);
|
||||
return home && away ? { home, away } : null;
|
||||
}
|
||||
|
||||
/** "Cesar MONTES" → "Cesar Montes", "Kevin DE BRUYNE" → "Kevin De Bruyne"
|
||||
* (FIFA shouts surnames); initials like "J." and mixed-case words survive. */
|
||||
const tidyWord = (w: string): string =>
|
||||
w.includes('.') || w !== w.toUpperCase()
|
||||
? w
|
||||
: w.split('-').map((p) => (p ? p.charAt(0) + p.slice(1).toLowerCase() : p)).join('-');
|
||||
export const tidyName = (s: string): string => s.split(' ').map(tidyWord).join(' ');
|
||||
|
||||
/** One side's full tournament squad from a stored FIFA live payload,
|
||||
* position groups first (GK → FW), shirt order within. [] on any surprise. */
|
||||
export function squadList(raw: unknown, side: 'home' | 'away'): { name: string; num: number | null; pos: number | null }[] {
|
||||
const live = raw as RawFifaLive | null | undefined;
|
||||
const players = (side === 'home' ? live?.HomeTeam : live?.AwayTeam)?.Players;
|
||||
if (!Array.isArray(players)) return [];
|
||||
return players
|
||||
.flatMap((p) => {
|
||||
const n = p?.ShortName?.[0]?.Description;
|
||||
if (typeof n !== 'string' || !n.trim()) return [];
|
||||
return [{
|
||||
name: tidyName(n.trim()),
|
||||
num: typeof p.ShirtNumber === 'number' ? p.ShirtNumber : null,
|
||||
pos: typeof p.Position === 'number' ? p.Position : null,
|
||||
}];
|
||||
})
|
||||
.sort((a, b) => (a.pos ?? 9) - (b.pos ?? 9) || (a.num ?? 99) - (b.num ?? 99));
|
||||
}
|
||||
|
||||
@@ -72,6 +72,19 @@ describe('normalizeMatchStats', () => {
|
||||
expect(normalizeMatchStats({ Periods: {} })).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes attacking zones and rejects partial shapes', async () => {
|
||||
const { normalizeAttackingZones } = await import('./fotmobStatsNormalize');
|
||||
// real shape from the match-1 capture
|
||||
const zones = normalizeAttackingZones({
|
||||
home: { total: { left: 45, center: 25, right: 30 }, firstHalf: { left: 49, center: 24, right: 27 } },
|
||||
away: { total: { left: 27, center: 27, right: 46 } },
|
||||
});
|
||||
expect(zones).toEqual({ home: { left: 45, center: 25, right: 30 }, away: { left: 27, center: 27, right: 46 } });
|
||||
expect(normalizeAttackingZones(null)).toBeNull();
|
||||
expect(normalizeAttackingZones({ home: { total: { left: 45, center: 25 } }, away: { total: { left: 1, center: 1, right: 1 } } })).toBeNull();
|
||||
expect(normalizeAttackingZones({ home: { total: { left: '45', center: 25, right: 30 } }, away: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it('skips rows with missing or empty values', () => {
|
||||
const rows = normalizeMatchStats({
|
||||
Periods: { All: { stats: [{ stats: [
|
||||
|
||||
@@ -35,6 +35,28 @@ export interface RawMatchStats { Periods?: { All?: { stats?: RawStatGroup[] } }
|
||||
const usable = (v: unknown): v is string | number =>
|
||||
(typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && v.trim() !== '');
|
||||
|
||||
export interface ZoneSplit { left: number; center: number; right: number }
|
||||
interface RawZoneSide { total?: Partial<ZoneSplit> }
|
||||
/** Full-match attacking-zone percentages per side, or null on any shape
|
||||
* surprise (the payload is FotMob-unofficial like everything else here). */
|
||||
export function normalizeAttackingZones(raw: unknown): { home: ZoneSplit; away: ZoneSplit } | null {
|
||||
const z = raw as { home?: RawZoneSide; away?: RawZoneSide } | null | undefined;
|
||||
const side = (s?: RawZoneSide): ZoneSplit | null => {
|
||||
const v = s?.total;
|
||||
if (!v) return null;
|
||||
const nums = [v.left, v.center, v.right];
|
||||
if (!nums.every((n): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0)) return null;
|
||||
return { left: v.left!, center: v.center!, right: v.right! };
|
||||
};
|
||||
const home = side(z?.home);
|
||||
const away = side(z?.away);
|
||||
if (!home || !away) return null;
|
||||
// A just-kicked-off match reports all zeros — nothing worth a card yet.
|
||||
const empty = (s: ZoneSplit) => s.left + s.center + s.right === 0;
|
||||
if (empty(home) && empty(away)) return null;
|
||||
return { home, away };
|
||||
}
|
||||
|
||||
/** Full-match stat rows from a raw FotMob `stats` payload. Any shape
|
||||
* surprise degrades to fewer rows (or none) — never a throw. */
|
||||
export function normalizeMatchStats(raw: unknown): MatchFullStat[] {
|
||||
|
||||
+12
-2
@@ -73,7 +73,7 @@ function playerIndex(state: TournamentState): Map<string, PlayerRef> {
|
||||
}
|
||||
|
||||
function assemble(nameParam: string, state: TournamentState): PlayerProfile | null {
|
||||
const ref = resolvePlayer(nameParam, playerIndex(state));
|
||||
const ref = resolvePlayer(nameParam, cachedPlayerIndex(state));
|
||||
if (!ref) return null;
|
||||
const { name, team } = ref;
|
||||
|
||||
@@ -147,6 +147,16 @@ function assemble(nameParam: string, state: TournamentState): PlayerProfile | nu
|
||||
const TTL = 60_000;
|
||||
const memo = new Map<string, { at: number; profile: PlayerProfile | null }>();
|
||||
let listMemo: { at: number; players: PlayerRef[] } | null = null;
|
||||
let indexMemo: { at: number; index: Map<string, PlayerRef> } | null = null;
|
||||
|
||||
/** The resolution index is a full cross-fixture scan (~128 DB reads at
|
||||
* tournament end); share one build across all profile + search requests. */
|
||||
function cachedPlayerIndex(state: TournamentState): Map<string, PlayerRef> {
|
||||
if (indexMemo && Date.now() - indexMemo.at < TTL) return indexMemo.index;
|
||||
const index = playerIndex(state);
|
||||
indexMemo = { at: Date.now(), index };
|
||||
return index;
|
||||
}
|
||||
|
||||
export function buildPlayerProfile(nameParam: string, state: TournamentState): PlayerProfile | null {
|
||||
const k = aliasKey(nameParam);
|
||||
@@ -161,7 +171,7 @@ export function buildPlayerProfile(nameParam: string, state: TournamentState): P
|
||||
/** The search palette's lightweight player list. */
|
||||
export function playersIndex(state: TournamentState): PlayerRef[] {
|
||||
if (listMemo && Date.now() - listMemo.at < TTL) return listMemo.players;
|
||||
const players = [...playerIndex(state).values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const players = [...cachedPlayerIndex(state).values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
listMemo = { at: Date.now(), players };
|
||||
return players;
|
||||
}
|
||||
|
||||
+19
-1
@@ -1,6 +1,7 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { getMatchExt, injuriesFor, scorerPropsFor, snapshotFor } from './db/db';
|
||||
import { getMatchExt, getMatchProvider, injuriesFor, scorerPropsFor, snapshotFor } from './db/db';
|
||||
import { whoScores } from './ingest/propsNormalize';
|
||||
import { squadList } from './ingest/fifaLineupNormalize';
|
||||
import { disciplineFor } from './stats';
|
||||
import type { TournamentState } from './tournament';
|
||||
import type { ModelEngine } from './model';
|
||||
@@ -152,6 +153,7 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
|
||||
videos: summary?.videos ?? [],
|
||||
// The board disappears at kickoff — in-play lines drift too fast to mirror.
|
||||
scorerProps: fixture.status === 'scheduled' ? whoScores(scorerPropsFor(num)) : [],
|
||||
broadcasts: summary?.broadcasts ?? [],
|
||||
dataNote: !home || !away
|
||||
? 'Knockout participants are decided once the bracket resolves.'
|
||||
: ext
|
||||
@@ -182,5 +184,21 @@ export function buildTeamProfile(teamRaw: string, state: TournamentState, model:
|
||||
qualifyOdds: odds?.qualify ?? null,
|
||||
discipline: disciplineFor(teamRaw, state).filter((p) => p.yellows + p.reds > 0),
|
||||
injuries: injuriesFor(teamRaw),
|
||||
squad: teamSquad(teamRaw, fixtures),
|
||||
};
|
||||
}
|
||||
|
||||
/** The team's 26-man FIFA squad from its most recent fixture that has a
|
||||
* captured live payload (FIFA only covers matches from ~90min pre-kickoff). */
|
||||
function teamSquad(team: string, fixtures: Fixture[]): TeamProfile['squad'] {
|
||||
const candidates = fixtures
|
||||
.filter((f) => f.status !== 'scheduled')
|
||||
.sort((a, b) => b.kickoff.localeCompare(a.kickoff));
|
||||
for (const f of candidates) {
|
||||
const fifa = getMatchProvider<{ live?: unknown }>(f.num, 'fifa');
|
||||
if (!fifa) continue;
|
||||
const squad = squadList(fifa.data.live, f.home.team === team ? 'home' : 'away');
|
||||
if (squad.length) return squad;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
+7
-3
@@ -17,17 +17,21 @@ if (pushEnabled) {
|
||||
console.log('[push] web push enabled');
|
||||
}
|
||||
|
||||
/** Follow a team — or every match, with team '*'. Additive per device. */
|
||||
export function subscribe(subscription: { endpoint: string }, team: string): void {
|
||||
addPushSub(subscription.endpoint, JSON.stringify(subscription), team);
|
||||
}
|
||||
|
||||
export function unsubscribe(endpoint: string): void {
|
||||
removePushSub(endpoint);
|
||||
/** Unfollow one team, or the whole device when no team is given. */
|
||||
export function unsubscribe(endpoint: string, team?: string): void {
|
||||
removePushSub(endpoint, team);
|
||||
}
|
||||
|
||||
async function send(team: string[], title: string, body: string, url: string): Promise<void> {
|
||||
if (!pushEnabled) return;
|
||||
const subs = pushSubsForTeams(team);
|
||||
// A device may follow both sides (or '*' plus a side) — one alert, not two.
|
||||
const seen = new Set<string>();
|
||||
const subs = pushSubsForTeams(team).filter((s) => !seen.has(s.endpoint) && !!seen.add(s.endpoint));
|
||||
await Promise.allSettled(
|
||||
subs.map(async (s) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user