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
This commit is contained in:
parent
c1270f6c9c
commit
1ef37fa8db
3 changed files with 84 additions and 4 deletions
55
app/lib/__tests__/date-utils.test.ts
Normal file
55
app/lib/__tests__/date-utils.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
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$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the returned ISO string is incompatible with datetime-local input format", () => {
|
||||||
|
// This documents WHY we need a hidden input instead of setting the
|
||||||
|
// datetime-local input's value directly. datetime-local only accepts
|
||||||
|
// "YYYY-MM-DDTHH:MM" — a 'Z' suffix makes the value invalid and the
|
||||||
|
// browser silently clears the field.
|
||||||
|
const result = localDateTimeToUtcIso("2026-03-11T10:00");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result).toContain("Z");
|
||||||
|
// The value does NOT match the datetime-local format (no Z allowed)
|
||||||
|
expect(result).not.toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
app/lib/date-utils.ts
Normal file
18
app/lib/date-utils.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -728,13 +728,20 @@ export default function EventBracket({
|
||||||
{/* Add game form */}
|
{/* Add game form */}
|
||||||
<Form method="post" className="flex gap-2 items-end" onSubmit={(e) => {
|
<Form method="post" className="flex gap-2 items-end" onSubmit={(e) => {
|
||||||
const form = e.currentTarget;
|
const form = e.currentTarget;
|
||||||
const scheduledAtInput = form.elements.namedItem("scheduledAt") as HTMLInputElement;
|
// Read from the datetime-local display input (not submitted directly)
|
||||||
if (scheduledAtInput?.value) {
|
const scheduledAtLocal = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement;
|
||||||
scheduledAtInput.value = new Date(scheduledAtInput.value).toISOString();
|
// Write the UTC ISO string into the hidden input that IS submitted
|
||||||
|
const scheduledAtHidden = form.elements.namedItem("scheduledAt") as HTMLInputElement;
|
||||||
|
if (scheduledAtLocal?.value && scheduledAtHidden) {
|
||||||
|
// datetime-local inputs reject ISO strings with a 'Z' suffix and silently
|
||||||
|
// clear their value, so we use a separate hidden input for the UTC value.
|
||||||
|
scheduledAtHidden.value = new Date(scheduledAtLocal.value).toISOString();
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<input type="hidden" name="intent" value="add-game" />
|
<input type="hidden" name="intent" value="add-game" />
|
||||||
<input type="hidden" name="matchId" value={match.id} />
|
<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">
|
<div className="flex-1">
|
||||||
<Label className="text-xs">Game #</Label>
|
<Label className="text-xs">Game #</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -750,7 +757,7 @@ export default function EventBracket({
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-xs">Date & Time ({localTzAbbr})</Label>
|
<Label className="text-xs">Date & Time ({localTzAbbr})</Label>
|
||||||
<Input
|
<Input
|
||||||
name="scheduledAt"
|
name="scheduledAtLocal"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue