From 516b4e713141089fef48e7836e5dae304c149dc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 20:42:49 +0000 Subject: [PATCH 1/3] Fix group-stage match times being stored as local-wall-time-as-UTC The admin group-stage schedule form submitted the raw datetime-local string, which the server parsed with new Date() in the server's timezone (UTC), so an admin's local wall-clock time was stored as if it were UTC. Downstream displays then correctly converted UTC->local, shifting times by the admin's UTC offset (e.g. 7 PM PDT showed as 12 PM). Mirror the existing playoff "add-game" form: convert local->UTC on the client via localDateTimeToUtcIso into a hidden field on submit, and render the stored UTC value back into the input in the browser's local timezone via a client-only effect (avoids SSR hydration mismatch). Adds utcIsoToLocalDateTime helper (inverse of localDateTimeToUtcIso) with unit tests covering nullish/invalid input and a tz-independent round-trip. Note: matches saved before this fix must be re-saved once in admin to correct their stored values. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0125nZohVD2Cpoq3Q9f4jeds --- app/lib/__tests__/date-utils.test.ts | 42 ++++++++++- app/lib/date-utils.ts | 26 +++++++ ...ts-seasons.$id.events.$eventId.bracket.tsx | 75 ++++++++++++++----- 3 files changed, 122 insertions(+), 21 deletions(-) diff --git a/app/lib/__tests__/date-utils.test.ts b/app/lib/__tests__/date-utils.test.ts index 036934a..0d7c99e 100644 --- a/app/lib/__tests__/date-utils.test.ts +++ b/app/lib/__tests__/date-utils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { format, parseISO } from "date-fns"; -import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils"; +import { localDateTimeToUtcIso, toEventSortKey, utcIsoToLocalDateTime } from "../date-utils"; describe("localDateTimeToUtcIso", () => { it("returns null for empty string", () => { @@ -118,6 +118,46 @@ describe("event date display — UTC midnight rollover bug", () => { }); }); +describe("utcIsoToLocalDateTime", () => { + it("returns empty string for empty string", () => { + expect(utcIsoToLocalDateTime("")).toBe(""); + }); + + it("returns empty string for null", () => { + expect(utcIsoToLocalDateTime(null)).toBe(""); + }); + + it("returns empty string for undefined", () => { + expect(utcIsoToLocalDateTime(undefined)).toBe(""); + }); + + it("returns empty string for an invalid date string", () => { + expect(utcIsoToLocalDateTime("not-a-date")).toBe(""); + }); + + it("produces a datetime-local formatted string", () => { + expect(utcIsoToLocalDateTime("2026-06-17T19:00:00.000Z")).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/ + ); + }); + + it("accepts a Date instance", () => { + const result = utcIsoToLocalDateTime(new Date("2026-06-17T19:00:00.000Z")); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + }); + + it("round-trips with localDateTimeToUtcIso to the same instant", () => { + // utcIsoToLocalDateTime renders a UTC instant in local time; feeding that + // back through localDateTimeToUtcIso (which interprets local time) must + // yield the original instant, regardless of the runtime timezone. + const utc = "2026-06-17T19:00:00.000Z"; + const local = utcIsoToLocalDateTime(utc); + const backToUtc = localDateTimeToUtcIso(local); + expect(backToUtc).not.toBeNull(); + expect(new Date(backToUtc ?? "").getTime()).toBe(new Date(utc).getTime()); + }); +}); + describe("toEventSortKey", () => { it("prefers earliestGameTime over eventDate", () => { const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" }); diff --git a/app/lib/date-utils.ts b/app/lib/date-utils.ts index 1c69328..8eea878 100644 --- a/app/lib/date-utils.ts +++ b/app/lib/date-utils.ts @@ -57,3 +57,29 @@ export function localDateTimeToUtcIso(value: string | null | undefined): string if (isNaN(date.getTime())) return null; return date.toISOString(); } + +/** + * Converts a stored UTC value (ISO string or Date) to the "YYYY-MM-DDTHH:MM" + * string a `datetime-local` input expects, expressed in the **browser's local** + * timezone. This is the inverse of `localDateTimeToUtcIso`. + * + * Built from local date getters (not `toISOString`, which is UTC) so the input + * shows the viewer's wall-clock time. Because it depends on the runtime + * timezone, call it on the client (e.g. inside `useEffect`) to avoid SSR + * hydration mismatches. + * + * Returns "" for nullish or unparseable input so it can be assigned directly to + * an input value. + */ +export function utcIsoToLocalDateTime(value: string | Date | null | undefined): string { + if (!value) return ""; + const date = value instanceof Date ? value : new Date(value); + if (isNaN(date.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index e652362..3cbf772 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -1,6 +1,6 @@ import { Form, Link } from "react-router"; -import { Fragment, useState, useEffect, useMemo } from "react"; -import { localDateTimeToUtcIso } from "~/lib/date-utils"; +import { Fragment, useState, useEffect, useMemo, useRef } from "react"; +import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils"; import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server"; @@ -39,6 +39,58 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }]; } +/** + * Schedule editor for a single group-stage match. + * + * The admin enters a local wall-clock time; on submit it is converted to a UTC + * ISO string (in the hidden `scheduledAt` field) so the DB stores a true UTC + * instant. The stored UTC value is rendered back into the input in the + * browser's local timezone via a client-only effect (avoids SSR hydration + * mismatch, since the server runs in UTC). + */ +function GroupMatchScheduleForm({ + match, + localTzAbbr, +}: { + match: { id: string; scheduledAt: string | Date | null | undefined }; + localTzAbbr: string; +}) { + const inputRef = useRef(null); + useEffect(() => { + if (inputRef.current) { + inputRef.current.value = utcIsoToLocalDateTime(match.scheduledAt); + } + }, [match.scheduledAt]); + return ( +
{ + const form = e.currentTarget; + const local = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement; + const hidden = form.elements.namedItem("scheduledAt") as HTMLInputElement; + if (hidden) hidden.value = localDateTimeToUtcIso(local?.value) ?? ""; + }} + > + + + {/* Hidden field holds the UTC ISO string written by onSubmit */} + + + + + + ); +} + export { loader, action }; export default function EventBracket({ @@ -806,24 +858,7 @@ export default function EventBracket({ {groupMatches.map((match) => (
{/* Schedule row */} -
- - - - - - + {/* Score row */}
-- 2.45.3 From 746bbcf53173274b5e02457c24132f1de91ddca1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 22:58:32 +0000 Subject: [PATCH 2/3] 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); +} -- 2.45.3 From edae04057d92a24fee1f0a560824e9af4e98d4e9 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 17 Jun 2026 18:58:03 -0700 Subject: [PATCH 3/3] Fix lint: move pad helper to module scope in date-utils Co-Authored-By: Claude Sonnet 4.6 --- app/lib/date-utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/lib/date-utils.ts b/app/lib/date-utils.ts index 8eea878..9ee07f1 100644 --- a/app/lib/date-utils.ts +++ b/app/lib/date-utils.ts @@ -18,6 +18,8 @@ */ import { format } from "date-fns"; +const pad = (n: number) => String(n).padStart(2, "0"); + /** * Format a YYYY-MM-DD date string for display (e.g. "Apr 9"). * Returns null when the value is missing or unparseable — callers decide how to handle that case. @@ -75,7 +77,6 @@ export function utcIsoToLocalDateTime(value: string | Date | null | undefined): if (!value) return ""; const date = value instanceof Date ? value : new Date(value); if (isNaN(date.getTime())) return ""; - const pad = (n: number) => String(n).padStart(2, "0"); const year = date.getFullYear(); const month = pad(date.getMonth() + 1); const day = pad(date.getDate()); -- 2.45.3