Phase 1: live dashboard — fixtures, scores, group tables, bracket

- Shared domain model (types, team metadata/flags/aliases, FIFA-tiebreak standings)
- buildFixtures.ts: openfootball 2026 → normalized fixtures.json (104 matches,
  ISO-UTC kickoffs, pretty knockout placeholders)
- Fastify live layer: seed load, football-data.org poller + SofaScore enhancement
  (feature-flagged, fallback), dynamic live-window polling, WS snapshot push,
  REST /api/snapshot, dev /api/dev/score injection
- Client: tournament store (REST + WS + static-file fallback), MatchCard,
  TeamLabel, GroupTable, Live/Groups/Bracket pages, live connection indicator
- Verified: WS pushes a dev-injected result; standings recompute live in-browser

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:04:31 +02:00
parent 4e4e75a1d8
commit 9a31e9f4db
18 changed files with 1461 additions and 21 deletions
+132
View File
@@ -0,0 +1,132 @@
// openfootball worldcup-2026.json → public/data/fixtures.json
// Normalizes the 104 matches into the app's Fixture shape: canonical team names,
// ISO-UTC kickoffs, stage tags, and pretty knockout placeholder labels.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { canonicalTeam } from '../src/lib/teams';
import type { Fixture, FixturesFile, Stage, TeamSlot } from '../src/lib/types';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const RAW = join(ROOT, 'data', 'raw', 'worldcup-2026.json');
const OUT_DIR = join(ROOT, 'public', 'data');
interface RawMatch {
round: string;
num?: number;
date: string;
time: string;
team1: string;
team2: string;
group?: string;
ground: string;
}
function stageOf(round: string): Stage {
if (round.startsWith('Matchday')) return 'group';
if (round.startsWith('Round of 32')) return 'r32';
if (round.startsWith('Round of 16')) return 'r16';
if (round.startsWith('Quarter')) return 'qf';
if (round.startsWith('Semi')) return 'sf';
if (round.startsWith('Match for third')) return 'third';
if (round.startsWith('Final')) return 'final';
throw new Error(`Unknown round: ${round}`);
}
/** "2026-06-11" + "13:00 UTC-6" → ISO 8601 UTC. */
function toIsoUtc(date: string, time: string): string {
const [y, mo, d] = date.split('-').map(Number);
const m = time.match(/^(\d{1,2}):(\d{2})\s*UTC([+-]\d{1,2})$/);
if (!m || y === undefined || mo === undefined || d === undefined) {
throw new Error(`Bad date/time: ${date} ${time}`);
}
const hh = Number(m[1]);
const mm = Number(m[2]);
const offset = Number(m[3]); // local = UTC + offset, so UTC hour = hh - offset
return new Date(Date.UTC(y, mo - 1, d, hh - offset, mm)).toISOString();
}
/** Knockout placeholder code → readable label. */
function placeholderLabel(code: string): string {
const pos = code.match(/^([123])([A-L])$/);
if (pos) {
const rank = pos[1] === '1' ? 'Winner' : pos[1] === '2' ? 'Runner-up' : '3rd';
return `${rank} Group ${pos[2]}`;
}
const third = code.match(/^3([A-L/]+)$/);
if (third) return `3rd: ${third[1]}`;
const wl = code.match(/^([WL])(\d{1,3})$/);
if (wl) return `${wl[1] === 'W' ? 'Winner' : 'Loser'} of M${wl[2]}`;
return code;
}
function slot(raw: string, isGroup: boolean): TeamSlot {
if (isGroup) {
const team = canonicalTeam(raw);
return { team, placeholder: null, label: team };
}
return { team: null, placeholder: raw, label: placeholderLabel(raw) };
}
function main(): void {
const raw = JSON.parse(readFileSync(RAW, 'utf8')) as { name: string; matches: RawMatch[] };
const matches = raw.matches;
// groupRound: order each group's matches by kickoff, 2 matches per round.
const groupOrder = new Map<string, number>(); // `${group}#${num}` → 0-based index
const byGroup = new Map<string, { num: number; iso: string }[]>();
const prelim = matches.map((m, i) => {
const stage = stageOf(m.round);
const num = m.num ?? i + 1;
const iso = toIsoUtc(m.date, m.time);
if (stage === 'group' && m.group) {
const g = m.group.replace('Group ', '');
if (!byGroup.has(g)) byGroup.set(g, []);
byGroup.get(g)!.push({ num, iso });
}
return { m, stage, num, iso };
});
for (const [g, list] of byGroup) {
list.sort((a, b) => a.iso.localeCompare(b.iso) || a.num - b.num);
list.forEach((x, idx) => groupOrder.set(`${g}#${x.num}`, idx));
}
const fixtures: Fixture[] = prelim.map(({ m, stage, num, iso }) => {
const group = stage === 'group' && m.group ? m.group.replace('Group ', '') : null;
const groupRound = group ? Math.floor((groupOrder.get(`${group}#${num}`) ?? 0) / 2) + 1 : null;
return {
num,
stage,
group,
round: m.round,
groupRound,
kickoff: iso,
venue: m.ground,
home: slot(m.team1, stage === 'group'),
away: slot(m.team2, stage === 'group'),
status: 'scheduled',
homeScore: null,
awayScore: null,
minute: null,
} satisfies Fixture;
});
fixtures.sort((a, b) => a.num - b.num);
const out: FixturesFile = {
season: raw.name,
generatedAt: new Date().toISOString(),
groups: [...new Set(fixtures.filter((f) => f.group).map((f) => f.group!))].sort(),
venues: [...new Set(fixtures.map((f) => f.venue))].sort(),
fixtures,
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'fixtures.json'), JSON.stringify(out));
console.log(
`fixtures → public/data/fixtures.json (${fixtures.length} matches, ${out.groups.length} groups, ${out.venues.length} venues)`,
);
}
main();