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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125nZohVD2Cpoq3Q9f4jeds
This commit is contained in:
Claude 2026-06-17 20:42:49 +00:00
parent 496ecba464
commit 74f117f1c1
No known key found for this signature in database
3 changed files with 122 additions and 21 deletions

View file

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

View file

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

View file

@ -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<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.value = utcIsoToLocalDateTime(match.scheduledAt);
}
}, [match.scheduledAt]);
return (
<Form
method="post"
className="flex items-center gap-1"
onSubmit={(e) => {
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) ?? "";
}}
>
<input type="hidden" name="intent" value="update-group-match-schedule" />
<input type="hidden" name="matchId" value={match.id} />
{/* Hidden field holds the UTC ISO string written by onSubmit */}
<input type="hidden" name="scheduledAt" defaultValue="" />
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
<Input
ref={inputRef}
type="datetime-local"
name="scheduledAtLocal"
title={`Time zone: ${localTzAbbr}`}
className="h-6 text-xs px-1 py-0"
/>
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
Save
</Button>
</Form>
);
}
export { loader, action };
export default function EventBracket({
@ -806,24 +858,7 @@ export default function EventBracket({
{groupMatches.map((match) => (
<div key={match.id} className="text-xs space-y-1">
{/* Schedule row */}
<Form method="post" className="flex items-center gap-1">
<input type="hidden" name="intent" value="update-group-match-schedule" />
<input type="hidden" name="matchId" value={match.id} />
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
<Input
type="datetime-local"
name="scheduledAt"
defaultValue={
match.scheduledAt
? new Date(match.scheduledAt).toISOString().slice(0, 16)
: ""
}
className="h-6 text-xs px-1 py-0"
/>
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
Save
</Button>
</Form>
<GroupMatchScheduleForm match={match} localTzAbbr={localTzAbbr} />
{/* Score row */}
<Form method="post" className="flex items-center gap-1">
<input type="hidden" name="intent" value="update-group-match" />