Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions ui/src/pages/recording-selector/__tests__/dialogs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───
Expand Down Expand Up @@ -49,28 +52,28 @@ describe("EditModal", () => {
const onClose = vi.fn();
const onSave = vi.fn();

render(() => (
const { container } = render(() => (
<EditModal rec={mockRec} tags={[]} onClose={onClose} onSave={onSave} />
));

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,
});
});

it("shows tag buttons and allows selection", () => {
const onClose = vi.fn();
const onSave = vi.fn();

render(() => (
const { container } = render(() => (
<EditModal rec={mockRec} tags={[]} onClose={onClose} onSave={onSave} />
));

Expand All @@ -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(() => (
<EditModal rec={mockRec} tags={[]} onClose={onClose} onSave={onSave} />
));

// Submit without changing the date
fireEvent.submit(container.querySelector("form")!);

expect(onSave).toHaveBeenCalledWith("42", expect.objectContaining({
date: expectedDateUTC,
}));
});
});

// ─── DeleteConfirm ───
Expand Down
40 changes: 39 additions & 1 deletion ui/src/pages/recording-selector/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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$/);
});
});
22 changes: 12 additions & 10 deletions ui/src/pages/recording-selector/dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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()),
});
};

Expand Down Expand Up @@ -106,7 +108,7 @@ export function EditModal(props: {
<div class={styles.editField}>
<label class={styles.editLabel}>Date</label>
<input
type="date"
type="datetime-local"
value={date()}
onInput={(e) => setDate(e.currentTarget.value)}
class={ui.input}
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -273,7 +275,7 @@ export function UploadDialog(props: {
<div class={styles.editField}>
<label class={styles.editLabel}>DATE</label>
<input
type="date"
type="datetime-local"
value={date()}
onInput={(e) => setDate(e.currentTarget.value)}
class={ui.input}
Expand Down
17 changes: 17 additions & 0 deletions ui/src/pages/recording-selector/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/, "");
Expand Down
Loading