Fix datetime-local to UTC conversion for game scheduling (#138)

* fix: use hidden input for UTC date value in add-game form

The onSubmit handler was setting the datetime-local input's value to an
ISO UTC string (e.g. "2026-03-11T17:00:00.000Z"). datetime-local inputs
only accept the format "YYYY-MM-DDTHH:MM" — values with a 'Z' timezone
suffix are invalid and the browser silently clears the field to "". This
caused the form to submit an empty scheduledAt, which the server treated
as null, so games were always saved without a date.

Fix by keeping the datetime-local input for user interaction (renamed
scheduledAtLocal, not submitted) and writing the converted UTC ISO string
into a separate hidden input named scheduledAt in the onSubmit handler.

Also adds app/lib/date-utils.ts with a localDateTimeToUtcIso helper and
matching tests to document and verify the datetime-local ↔ ISO conversion
behaviour.

https://claude.ai/code/session_0148fgZiXpyvGX8ZX3BFRGCs

* refactor: use localDateTimeToUtcIso in component and clean up tests

- Import and call localDateTimeToUtcIso in the add-game onSubmit handler
  instead of duplicating the conversion inline; also applies the null/
  invalid-date safety from the utility to the component
- Remove the redundant "incompatible with datetime-local" test case (its
  intent is better expressed as a code comment than a test assertion)
- Move the datetime-local incompatibility explanation into a NOTE comment
  on the utility function's JSDoc

https://claude.ai/code/session_0148fgZiXpyvGX8ZX3BFRGCs

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-11 16:22:29 -07:00 committed by GitHub
parent c1270f6c9c
commit ed4e99b40e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 78 additions and 4 deletions

View file

@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { localDateTimeToUtcIso } from "../date-utils";
describe("localDateTimeToUtcIso", () => {
it("returns null for empty string", () => {
expect(localDateTimeToUtcIso("")).toBeNull();
});
it("returns null for null", () => {
expect(localDateTimeToUtcIso(null)).toBeNull();
});
it("returns null for undefined", () => {
expect(localDateTimeToUtcIso(undefined)).toBeNull();
});
it("returns null for an invalid date string", () => {
expect(localDateTimeToUtcIso("not-a-date")).toBeNull();
});
it("converts a valid datetime-local value and round-trips correctly", () => {
// The input matches the datetime-local format "YYYY-MM-DDTHH:MM".
// Regardless of timezone, the resulting ISO string should parse back
// to the same moment in time.
const input = "2026-03-11T17:00";
const result = localDateTimeToUtcIso(input);
expect(result).not.toBeNull();
expect(new Date(result!).getTime()).toBe(new Date(input).getTime());
});
it("returns a string ending with 'Z' (UTC designator)", () => {
const result = localDateTimeToUtcIso("2026-03-11T10:00");
expect(result).not.toBeNull();
expect(result!.endsWith("Z")).toBe(true);
});
it("returns a full ISO-8601 string with milliseconds", () => {
const result = localDateTimeToUtcIso("2026-06-15T08:30");
expect(result).not.toBeNull();
// toISOString() always produces "YYYY-MM-DDTHH:MM:SS.mmmZ"
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
});

24
app/lib/date-utils.ts Normal file
View file

@ -0,0 +1,24 @@
/**
* 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.
*/
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();
}

View file

@ -1,5 +1,6 @@
import { Form, Link } from "react-router";
import { Fragment, useState, useEffect, useMemo } from "react";
import { localDateTimeToUtcIso } 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";
@ -728,13 +729,18 @@ export default function EventBracket({
{/* Add game form */}
<Form method="post" className="flex gap-2 items-end" onSubmit={(e) => {
const form = e.currentTarget;
const scheduledAtInput = form.elements.namedItem("scheduledAt") as HTMLInputElement;
if (scheduledAtInput?.value) {
scheduledAtInput.value = new Date(scheduledAtInput.value).toISOString();
// Read from the datetime-local display input (not submitted directly)
const scheduledAtLocal = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement;
// Write the UTC ISO string into the hidden input that IS submitted
const scheduledAtHidden = form.elements.namedItem("scheduledAt") as HTMLInputElement;
if (scheduledAtHidden) {
scheduledAtHidden.value = localDateTimeToUtcIso(scheduledAtLocal?.value) ?? "";
}
}}>
<input type="hidden" name="intent" value="add-game" />
<input type="hidden" name="matchId" value={match.id} />
{/* Hidden field holds the UTC ISO string written by onSubmit */}
<input type="hidden" name="scheduledAt" defaultValue="" />
<div className="flex-1">
<Label className="text-xs">Game #</Label>
<Input
@ -750,7 +756,7 @@ export default function EventBracket({
<div className="flex-1">
<Label className="text-xs">Date &amp; Time ({localTzAbbr})</Label>
<Input
name="scheduledAt"
name="scheduledAtLocal"
type="datetime-local"
className="h-8"
/>