diff --git a/app/lib/__tests__/date-utils.test.ts b/app/lib/__tests__/date-utils.test.ts new file mode 100644 index 0000000..8d5f6ff --- /dev/null +++ b/app/lib/__tests__/date-utils.test.ts @@ -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}$/); + }); +}); diff --git a/app/lib/date-utils.ts b/app/lib/date-utils.ts new file mode 100644 index 0000000..70702b7 --- /dev/null +++ b/app/lib/date-utils.ts @@ -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(); +} 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 613084e..3cb06cc 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -728,13 +728,20 @@ export default function EventBracket({ {/* Add game form */}