From 24ca6622e40da03483040257ad7cedbf0fd818bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 22:58:32 +0000 Subject: [PATCH] Migrate existing group-match times and localize day headers Add a one-off data migration (scripts/fix-group-match-times-utc.mjs) that shifts group_stage_matches.scheduled_at +7h, converting the pre-fix local-wall-time-as-UTC (all entered in PDT) into true UTC. Scoped to group-stage matches only; playoff games were already stored correctly. Defaults to a dry-run preview and only writes with --apply. Run: npx dotenv -- node scripts/fix-group-match-times-utc.mjs [--apply] Fix GroupStageStandings day-header grouping/labels, which keyed and formatted by UTC date. With times now stored as true UTC, an evening PDT match rolled into the next UTC day and appeared under the wrong header. Group and label by the viewer's local date after hydration, keeping the UTC representation on SSR/first paint for deterministic markup. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0125nZohVD2Cpoq3Q9f4jeds --- .../sport-season/GroupStageStandings.tsx | 35 ++++++--- scripts/fix-group-match-times-utc.mjs | 78 +++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 scripts/fix-group-match-times-utc.mjs diff --git a/app/components/sport-season/GroupStageStandings.tsx b/app/components/sport-season/GroupStageStandings.tsx index 74733e8..00bc39a 100644 --- a/app/components/sport-season/GroupStageStandings.tsx +++ b/app/components/sport-season/GroupStageStandings.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import type { GroupStandingsRow } from "~/models/group-stage-match"; +import { utcIsoToLocalDateTime } from "~/lib/date-utils"; import { TeamOwnerBadge } from "~/components/ui/team-owner-badge"; interface GroupMatch { @@ -100,6 +101,12 @@ export function GroupStageStandings({ ownershipMap, showEmpty = false, }: GroupStageStandingsProps) { + // SSR (and first client paint) groups/labels by UTC date for deterministic markup; + // after hydration we switch to the viewer's local date so evening matches don't roll + // into the wrong day header. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + const visibleGroups = showEmpty ? groups : groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0); @@ -186,25 +193,35 @@ export function GroupStageStandings({ if (!b.scheduledAt) return -1; return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(); }); - // Use the UTC date portion as a stable key so server and client always - // produce the same Map structure regardless of the user's timezone. + // Key by UTC date on SSR/first paint (deterministic); after mount, key by + // the viewer's local date so the day headers match local wall-clock. const byDay = new Map(); for (const m of sorted) { - const key = m.scheduledAt ? m.scheduledAt.slice(0, 10) : "TBD"; + const key = m.scheduledAt + ? (mounted + ? utcIsoToLocalDateTime(m.scheduledAt).slice(0, 10) + : m.scheduledAt.slice(0, 10)) + : "TBD"; if (!byDay.has(key)) byDay.set(key, []); byDay.get(key)?.push(m); } return (
{[...byDay.entries()].map(([key, matches]) => { - // Format from the UTC date string so label is identical on server and client. + // Before mount the key is a UTC date (format in UTC); after mount it is + // a local date (format in local time) so the label matches the grouping. const dayLabel = key === "TBD" ? "TBD" - : new Date(key + "T12:00:00Z").toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: "UTC", - }); + : mounted + ? new Date(key + "T12:00:00").toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + : new Date(key + "T12:00:00Z").toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", + }); return (

diff --git a/scripts/fix-group-match-times-utc.mjs b/scripts/fix-group-match-times-utc.mjs new file mode 100644 index 0000000..cb3a29c --- /dev/null +++ b/scripts/fix-group-match-times-utc.mjs @@ -0,0 +1,78 @@ +/** + * One-off data migration: fix group-stage match times stored as local-wall-time-as-UTC. + * + * Before the schedule-form fix, the admin's local wall-clock time (PDT, UTC-7) was + * stored as if it were UTC, so every `group_stage_matches.scheduled_at` is 7 hours + * behind the true UTC instant. This shifts them +7 hours. + * + * Scope: ONLY `group_stage_matches.scheduled_at` (where not null). Playoff games + * (`playoff_match_games.scheduled_at`) were saved correctly and are left untouched. + * + * NOT idempotent — running with --apply twice double-shifts. Defaults to a dry-run; + * pass --apply to write. + * + * Usage (loads .env via dotenv-cli): + * npx dotenv -- node scripts/fix-group-match-times-utc.mjs # preview + * npx dotenv -- node scripts/fix-group-match-times-utc.mjs --apply # apply + */ + +import postgres from "postgres"; + +const SHIFT = "7 hours"; // PDT (UTC-7) wall-clock stored as UTC -> true UTC +const apply = process.argv.includes("--apply"); + +const DATABASE_URL = process.env.DATABASE_DIRECT_URL || process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error("ERROR: DATABASE_DIRECT_URL (or DATABASE_URL) is required"); + process.exit(1); +} + +const sql = postgres(DATABASE_URL, { max: 1, onnotice: () => {} }); + +try { + const [{ count }] = await sql` + SELECT COUNT(*)::int AS count + FROM group_stage_matches + WHERE scheduled_at IS NOT NULL + `; + console.log(`Group-stage matches with a scheduled time: ${count}`); + + if (count === 0) { + console.log("Nothing to do."); + await sql.end(); + process.exit(0); + } + + const samples = await sql` + SELECT id, + scheduled_at AS before, + scheduled_at + ${sql.unsafe(`interval '${SHIFT}'`)} AS after + FROM group_stage_matches + WHERE scheduled_at IS NOT NULL + ORDER BY scheduled_at + LIMIT 10 + `; + console.log(`\nSample before -> after (+${SHIFT}):`); + for (const r of samples) { + console.log(` ${r.id}: ${r.before.toISOString()} -> ${r.after.toISOString()}`); + } + + if (!apply) { + console.log(`\nDRY RUN — no changes written. Re-run with --apply to shift ${count} row(s) +${SHIFT}.`); + await sql.end(); + process.exit(0); + } + + const result = await sql` + UPDATE group_stage_matches + SET scheduled_at = scheduled_at + ${sql.unsafe(`interval '${SHIFT}'`)} + WHERE scheduled_at IS NOT NULL + `; + console.log(`\nApplied: shifted ${result.count} row(s) +${SHIFT}.`); + await sql.end(); + process.exit(0); +} catch (err) { + console.error("Migration failed:", err); + await sql.end().catch(() => {}); + process.exit(1); +}