brackt/app/lib/date-utils.ts
Claude 516b4e7131 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
2026-06-18 02:19:31 +00:00

85 lines
3.5 KiB
TypeScript

/**
* Converts a datetime-local input value (local time, no timezone info)
* to a UTC ISO string for server submission.
*
* `datetime-local` inputs use the format "YYYY-MM-DDTHH:MM" and do NOT accept
* ISO strings with a timezone designator (e.g. "2026-03-11T17:00:00.000Z").
* Setting an ISO UTC string directly on a datetime-local input causes the browser
* to silently clear the field value. Use this function with a separate hidden input
* to hold the converted UTC value before form submission.
*
* Returns null when value is empty, nullish, or not a valid date.
*
* NOTE: The returned ISO string (e.g. "2026-03-11T17:00:00.000Z") is intentionally
* incompatible with datetime-local inputs, which only accept "YYYY-MM-DDTHH:MM".
* Setting a Z-suffixed string on a datetime-local input causes browsers to silently
* clear the field. Always write the result to a separate hidden input, not back to
* the datetime-local input itself.
*/
import { format } from "date-fns";
/**
* 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.
*/
export function formatEventDate(dateStr: string | null | undefined): string | null {
if (!dateStr) return null;
try {
const [year, month, day] = dateStr.split("-").map(Number);
return format(new Date(year, month - 1, day), "MMM d");
} catch {
return null;
}
}
/**
* Produces a stable sort key for upcoming calendar events.
*
* Bracket events carry a full ISO game timestamp in `earliestGameTime`; all-compete
* events (auto racing, golf) use the ISO timestamp in `earliestGameTime` when a start
* time has been set, or fall back to the date-only `eventDate` string. Events with
* neither sort to the far future so they appear last.
*
* The mixed comparison of ISO timestamps and "YYYY-MM-DD" strings works correctly via
* lexicographic order because both share the same date prefix; a date-only string always
* sorts before a same-day timestamp (shorter string < longer string with "T" suffix).
*/
export function toEventSortKey(e: {
eventDate: string | null;
earliestGameTime: string | null;
}): string {
return e.earliestGameTime ?? e.eventDate ?? "9999-12-31";
}
export function localDateTimeToUtcIso(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
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}`;
}