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: {
Date
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: {
DATE
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$/, "");