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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125nZohVD2Cpoq3Q9f4jeds
This commit is contained in:
Claude 2026-06-17 22:58:32 +00:00 committed by chrisp
parent 516b4e7131
commit 746bbcf531
2 changed files with 104 additions and 9 deletions

View file

@ -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<string, GroupMatch[]>();
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 (
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
{[...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 (
<div key={key} className="space-y-0.5">
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">

View file

@ -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);
}