diff --git a/app/lib/__tests__/date-utils.test.ts b/app/lib/__tests__/date-utils.test.ts new file mode 100644 index 0000000..8b3133d --- /dev/null +++ b/app/lib/__tests__/date-utils.test.ts @@ -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$/); + }); + +}); diff --git a/app/lib/date-utils.ts b/app/lib/date-utils.ts new file mode 100644 index 0000000..5455009 --- /dev/null +++ b/app/lib/date-utils.ts @@ -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(); +} 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..52472fb 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -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 */}