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/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..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. @@ -57,3 +59,28 @@ 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 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 */}
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); +}