diff --git a/ui/src/pages/recording-selector/__tests__/dialogs.test.tsx b/ui/src/pages/recording-selector/__tests__/dialogs.test.tsx index 5642a57a..2bb207fb 100644 --- a/ui/src/pages/recording-selector/__tests__/dialogs.test.tsx +++ b/ui/src/pages/recording-selector/__tests__/dialogs.test.tsx @@ -8,12 +8,15 @@ const mockRec: Recording = { worldName: "Altis", missionName: "Op Thunder", missionDuration: 3600, - date: "2024-01-15", + date: "2000-01-01T01:01:01.000+09:00", tag: "TvT", storageFormat: "protobuf", conversionStatus: "completed", }; +// Input date converted to UTC: 2000-01-01T01:01:01+09:00 = 1999-12-31T16:01:01Z +const expectedDateUTC = "1999-12-31T16:01:01.000Z"; + afterEach(() => { cleanup(); vi.restoreAllMocks(); }); // ─── EditModal ─── @@ -49,20 +52,20 @@ describe("EditModal", () => { const onClose = vi.fn(); const onSave = vi.fn(); - render(() => ( + const { container } = render(() => ( )); const nameInput = screen.getByDisplayValue("Op Thunder"); fireEvent.input(nameInput, { target: { value: "Op Lightning" } }); - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.submit(container.querySelector("form")!); expect(onSave).toHaveBeenCalledTimes(1); expect(onSave).toHaveBeenCalledWith("42", { missionName: "Op Lightning", tag: "TvT", - date: "2024-01-15", + date: expectedDateUTC, }); }); @@ -70,7 +73,7 @@ describe("EditModal", () => { const onClose = vi.fn(); const onSave = vi.fn(); - render(() => ( + const { container } = render(() => ( )); @@ -85,13 +88,29 @@ describe("EditModal", () => { fireEvent.click(screen.getByText("COOP")); // Submit and verify the new tag is sent - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.submit(container.querySelector("form")!); expect(onSave).toHaveBeenCalledWith("42", { missionName: "Op Thunder", tag: "COOP", - date: "2024-01-15", + date: expectedDateUTC, }); }); + + it("round-trips ISO date with timezone offset as UTC", () => { + const onClose = vi.fn(); + const onSave = vi.fn(); + + const { container } = render(() => ( + + )); + + // Submit without changing the date + fireEvent.submit(container.querySelector("form")!); + + expect(onSave).toHaveBeenCalledWith("42", expect.objectContaining({ + date: expectedDateUTC, + })); + }); }); // ─── DeleteConfirm ─── diff --git a/ui/src/pages/recording-selector/__tests__/helpers.test.ts b/ui/src/pages/recording-selector/__tests__/helpers.test.ts index 5360c9d8..df6b98bb 100644 --- a/ui/src/pages/recording-selector/__tests__/helpers.test.ts +++ b/ui/src/pages/recording-selector/__tests__/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { formatDuration, formatDate, relativeDate, getMapColor, hashColor, FALLBACK_PALETTE } from "../helpers"; +import { formatDuration, formatDate, relativeDate, getMapColor, hashColor, FALLBACK_PALETTE, isoToLocalInput, localInputToIso } from "../helpers"; describe("formatDuration", () => { it("returns zero for non-positive values", () => { @@ -142,3 +142,41 @@ describe("relativeDate", () => { expect(result.toLowerCase()).toContain("month"); }); }); + +describe("isoToLocalInput", () => { + it("returns empty string for undefined", () => { + expect(isoToLocalInput(undefined)).toBe(""); + }); + + it("returns empty string for invalid date", () => { + expect(isoToLocalInput("not-a-date")).toBe(""); + }); + + it("returns a valid datetime-local string", () => { + const result = isoToLocalInput("2024-06-15T12:00:00Z"); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + }); + + it("round-trips with localInputToIso to preserve the same instant", () => { + const original = "2000-01-01T01:01:01.000+09:00"; + const expectedUtc = new Date(original).toISOString(); + const local = isoToLocalInput(original); + const result = localInputToIso(local); + expect(result).toBe(expectedUtc); + }); +}); + +describe("localInputToIso", () => { + it("returns undefined for empty string", () => { + expect(localInputToIso("")).toBeUndefined(); + }); + + it("returns undefined for invalid date", () => { + expect(localInputToIso("not-a-date")).toBeUndefined(); + }); + + it("returns a UTC ISO string", () => { + const result = localInputToIso("2024-06-15T12:00:00"); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/); + }); +}); diff --git a/ui/src/pages/recording-selector/dialogs.tsx b/ui/src/pages/recording-selector/dialogs.tsx index 0687833f..9b165a67 100644 --- a/ui/src/pages/recording-selector/dialogs.tsx +++ b/ui/src/pages/recording-selector/dialogs.tsx @@ -2,7 +2,7 @@ import { createSignal, Show, For } from "solid-js"; import type { JSX } from "solid-js"; import type { Recording } from "../../data/types"; import { EditIcon, XIcon, CheckIcon, UploadIcon, FilePlusIcon, RefreshCwIcon, AlertTriangleIcon, TrashIcon } from "../../components/Icons"; -import { formatDuration, formatDate, stripRecordingExtension } from "./helpers"; +import { formatDuration, formatDate, stripRecordingExtension, isoToLocalInput, localInputToIso } from "./helpers"; import { TAG_OPTIONS } from "./constants"; import ui from "../../components/ui.module.css"; import styles from "./dialogs.module.css"; @@ -15,17 +15,19 @@ export function EditModal(props: { onClose: () => void; onSave: (id: string, data: { missionName?: string; tag?: string; date?: string }) => void; }): JSX.Element { - const rec = () => props.rec; - const [name, setName] = createSignal(rec().missionName); - const [tag, setTag] = createSignal(rec().tag ?? ""); - const [date, setDate] = createSignal(rec().date?.slice(0, 10) ?? ""); + // eslint-disable-next-line solid/reactivity -- intentional one-time init for form state + const [name, setName] = createSignal(props.rec.missionName); + // eslint-disable-next-line solid/reactivity -- intentional one-time init for form state + const [tag, setTag] = createSignal(props.rec.tag ?? ""); + // eslint-disable-next-line solid/reactivity -- intentional one-time init for form state + const [date, setDate] = createSignal(isoToLocalInput(props.rec.date)); const handleSubmit = (e: Event) => { e.preventDefault(); props.onSave(props.rec.id, { missionName: name(), tag: tag() || undefined, - date: date() || undefined, + date: localInputToIso(date()), }); }; @@ -106,7 +108,7 @@ export function EditModal(props: {
setDate(e.currentTarget.value)} class={ui.input} @@ -138,7 +140,7 @@ export function UploadDialog(props: { const [name, setName] = createSignal(""); const [map, setMap] = createSignal(""); const [tag, setTag] = createSignal(""); - const [date, setDate] = createSignal(new Date().toISOString().split("T")[0]); + const [date, setDate] = createSignal(isoToLocalInput(new Date().toISOString())); let fileInputRef: HTMLInputElement | undefined; @@ -159,7 +161,7 @@ export function UploadDialog(props: { const handleSubmit = () => { const f = file(); if (!f || !name()) return; - props.onUpload({ file: f, name: name(), map: map(), tag: tag(), date: date() }); + props.onUpload({ file: f, name: name(), map: map(), tag: tag(), date: localInputToIso(date()) ?? "" }); }; const canSubmit = () => !!file() && !!name() && !props.uploading; @@ -273,7 +275,7 @@ export function UploadDialog(props: {
setDate(e.currentTarget.value)} class={ui.input} diff --git a/ui/src/pages/recording-selector/helpers.ts b/ui/src/pages/recording-selector/helpers.ts index a1934b44..81b1cb6d 100644 --- a/ui/src/pages/recording-selector/helpers.ts +++ b/ui/src/pages/recording-selector/helpers.ts @@ -88,6 +88,23 @@ export function isRecordingReady(rec: Recording): boolean { return getStatusInfo(rec).key === "ready"; } +/** Format an ISO/RFC 3339 date string as a local datetime-local value (YYYY-MM-DDThh:mm:ss). */ +export function isoToLocalInput(isoStr: string | undefined): string { + if (!isoStr) return ""; + const d = new Date(isoStr); + if (isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +/** Convert a datetime-local value (interpreted as local time) back to a UTC ISO string. */ +export function localInputToIso(localStr: string): string | undefined { + if (!localStr) return undefined; + const d = new Date(localStr); + if (isNaN(d.getTime())) return undefined; + return d.toISOString(); +} + /** Strip .json.gz / .json / .gz extensions from a recording filename. */ export function stripRecordingExtension(filename: string): string { return filename.replace(/\.json\.gz$/, "").replace(/\.json$/, "").replace(/\.gz$/, "");