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:
@@ -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();
|
||||
Reference in New Issue
Block a user