9a31e9f4db
- 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>
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
// 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();
|