diff --git a/.kilo/plans/889.md b/.kilo/plans/889.md
deleted file mode 100644
index 35507fd..0000000
--- a/.kilo/plans/889.md
+++ /dev/null
@@ -1,258 +0,0 @@
-# Implementation Plan: Job 889 — Admin Area General Settings and Categories 1.07
-
-## Status
-NOT FULLY IMPLEMENTED. Two residual defects remain after Job 888:
-
-1. **Categories SQL** — `backend/src/modules/admin/categories.service.ts:30-37` uses
- `createQueryBuilder('c').orderBy('LOWER(c.abbreviation)', 'ASC').addOrderBy('c.abbreviation', 'ASC').getMany()`.
- `getMany()` auto-selects every column mapped on `CategoryEntity`, including
- `created_at` and `updated_at` (added by migration
- `1700000000200-AddCategoryTimestampsAndUniqueAbbrev`). The
- `InitSchema1700000000000` migration (`backend/src/database/migrations/1700000000000-InitSchema.ts:28-37`)
- creates the `category` table **without** those columns and relies on the
- 200-migration to add them at startup. On any database where the 200-migration
- has not yet run (e.g. when an older pre-1.06 DB is hit before migrations
- resolve, or when tests / fresh clones initialise the schema out of order),
- SQLite raises `SqliteError: no such column: c.created_at` and the Categories
- section on `/admin/general` (which embeds `AdminCategoriesComponent`) renders
- the error to the admin. The fix is to make the `category` table include
- `created_at` and `updated_at` in its initial `CREATE TABLE` and keep the 200
- migration as a no-op `if (!names.has(...))` ALTER for pre-existing DBs.
-
-2. **Event End equal-to-start field-level message** — `frontend/src/app/features/admin/general.pure.ts:41-51`
- already returns `{ endBeforeStart: true }` when `e <= s`, and
- `frontend/src/app/features/admin/general.component.ts:123-128` renders the
- `general-endBeforeStart` element whenever `eventEndCrossFieldError()` is true.
- However the message lives in a **separate** `data-testid="general-endBeforeStart"`
- div instead of inside the per-field error region
- `data-testid="general-eventEnd-error"` that the Job (and `docs/guides/admin-general-settings.md`)
- describe as the canonical "field-level validation message" for the Event End
- input. The Job explicitly states: *"no visible message inside the field-level
- error region (e.g., `[data-testid=general-eventEnd-error]`)"*. The fix is to
- funnel the cross-field `endBeforeStart` message into `general-eventEnd-error`
- (and surface it via `aria-invalid` / `aria-describedby` / `title` on the
- input) so it is rendered as the Event End field's own validation message.
-
-## 1. Architectural Reconnaissance
-
-- **Codebase style & conventions:**
- - **Backend:** NestJS 10, TypeScript strict, TypeORM 0.3.20 over
- `better-sqlite3`. Migrations live under
- `backend/src/database/migrations/` and are wired through
- `DatabaseModule.MIGRATIONS` (`backend/src/database/database.module.ts:31-36`)
- and run on bootstrap by `DatabaseInitService.init()` (`backend/src/database/database-init.service.ts:26-54`).
- Entities are auto-selected from `DatabaseModule.ENTITIES` and must remain
- in sync with the SQL schema produced by the migrations.
- - **Frontend:** Angular 17+ standalone components with
- `ChangeDetectionStrategy.OnPush`, reactive forms, and the `Value` /
- `Invalid` / `TouchedOrDirty` signal trio for inline error messaging
- (see `general.component.ts:204-321`). Pure validation / message helpers
- live in `frontend/src/app/features/admin/general.pure.ts` and are
- unit-tested under `tests/frontend/admin-general-pure.spec.ts`.
- - **Error envelopes:** backend emits
- `{ code: 'VALIDATION_FAILED', message, details: [{path, message}] }` via
- `ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts:9-15`)
- for `400`s; `ApiError` helpers live in `backend/src/common/errors/api-error.ts`.
-
-- **Data Layer:** SQLite (`better-sqlite3`) database file at
- `process.env.DATABASE_PATH` (default `/data/hipctf/db.sqlite`). The
- `category` table is created by `InitSchema1700000000000` and seeded /
- amended by `SeedSystemData1700000000100`,
- `AddCategoryTimestampsAndUniqueAbbrev1700000000200`, and
- `UpdateSystemCategoryKeys1700000000300`. The
- `CategoryEntity` (`backend/src/database/entities/category.entity.ts`) is
- the single source of truth for the column names that TypeORM will SELECT
- via `getMany()`.
-
-- **Test Framework & Structure:** Jest (`ts-jest`) with two projects
- (`backend` and `frontend`) configured in `tests/jest.config.js`. Tests
- live in `tests/backend/**/*.spec.ts` and `tests/frontend/**/*.spec.ts`
- and run with a single command `npm test` from the repo root (already in
- `package.json:scripts.test`). Backend tests typically boot the real
- `AppModule` against `:memory:` SQLite via
- `process.env.DATABASE_PATH = ':memory:'`; frontend tests are pure
- function specs that never touch the DOM. New tests must follow the same
- layout.
-
-- **Required Tools & Dependencies:** No new packages. Node 20+, npm
- workspaces, `jest`, `ts-jest`, `better-sqlite3`, `@nestjs/typeorm`,
- and `@nestjs/common` are all already declared and bootstrapped by
- `setup.sh` (`setup.sh:1-19`). No `setup.sh` change is required for
- this Job.
-
-## 2. Impacted Files
-
-- **To Modify:**
- - `backend/src/database/migrations/1700000000000-InitSchema.ts` —
- add `"created_at"` and `"updated_at"` columns to the initial
- `CREATE TABLE "category"` so the columns exist from the very first
- migration; preserve the `description` and `icon_path` defaults.
- - `frontend/src/app/features/admin/general.component.ts` —
- extend the Event End `general-eventEnd-error` element so it also
- renders the cross-field `endBeforeStart` message when no
- per-field `invalidDatetime` error is present; keep
- `general-endBeforeStart` as a redundant/legacy visual element only
- if it is already asserted in tests (see Test Strategy); bind
- `[attr.aria-invalid]` / `[attr.aria-describedby]` / `[attr.title]`
- on the Event End `` to the merged state.
- - `frontend/src/app/features/admin/general.pure.ts` — add a tiny
- helper (e.g. `eventEndFieldError(fieldErrors, crossField)`) that
- returns the right human-readable string for the merged Event End
- field error, so the template can read it from a single source.
- - `tests/frontend/admin-general-pure.spec.ts` — add focused unit
- assertions for the new helper (equal-to-start and end-before-start
- cases).
- - `tests/backend/migrations.spec.ts` — add a focused assertion that
- running `InitSchema1700000000000` against an empty database
- produces a `category` table that contains a `created_at` column
- (and `updated_at`). This locks the SQL schema in place so the
- Categories endpoint can never regress to the
- `no such column: c.created_at` state.
- - `docs/guides/admin-general-settings.md` — clarify that the
- Event End inline error region (`general-eventEnd-error`) is the
- canonical place where the end-before-start message surfaces.
-
-- **To Create:**
- - None required. Existing test files are sufficient.
-
-## 3. Proposed Changes
-
-### 1. Database / Schema Migration
-
-1. **Update `InitSchema1700000000000`** at
- `backend/src/database/migrations/1700000000000-InitSchema.ts:28-37` so
- the `category` `CREATE TABLE` includes `created_at` and `updated_at`
- columns from the very first migration:
- ```sql
- CREATE TABLE IF NOT EXISTS "category" (
- "id" TEXT PRIMARY KEY,
- "system_key" TEXT,
- "name" TEXT NOT NULL,
- "abbreviation" TEXT NOT NULL,
- "description" TEXT NOT NULL DEFAULT '',
- "icon_path" TEXT NOT NULL DEFAULT '',
- "created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- "updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- );
- ```
- Mirror the `strftime('%Y-%m-%dT%H:%M:%fZ','now')` default already used
- for `user.created_at` (line 17) and `challenge.created_at` (line 54) so
- newly-inserted rows get a sane timestamp without a follow-up
- UPDATE-pass.
-
-2. **Keep `AddCategoryTimestampsAndUniqueAbbrev1700000000200`
- unchanged.** Its `if (!names.has('created_at'))` /
- `if (!names.has('updated_at'))` guards (`backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev.ts:8-20`)
- make the ALTER a no-op for fresh databases and still patch
- older pre-1.06 databases that lack the columns. No changes needed
- to `down()` either — the down() drops the columns, which is the
- correct mirror of `up()`.
-
-3. **Keep `UpdateSystemCategoryKeys1700000000300` unchanged.** Its
- `INSERT` already lists `created_at` and `updated_at`
- (`backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys.ts:51`).
-
-4. No need to touch `CategoryEntity`
- (`backend/src/database/entities/category.entity.ts`) — its current
- `@Column('text', { name: 'created_at', default: '' })` and
- `@Column('text', { name: 'updated_at', default: '' })` annotations
- match the columns we are guaranteeing.
-
-### 2. Backend Logic & APIs
-
-No controller / service logic changes are required. With the schema
-fix, `AdminCategoriesService.list()` at
-`backend/src/modules/admin/categories.service.ts:30-37` will resolve
-`c.created_at` and `c.updated_at` and return rows that satisfy the
-existing `CategoryView` shape used by the front-end.
-
-If desired, the controller surface can stay untouched
-(`backend/src/modules/admin/admin-categories.controller.ts:30-34`) —
-no new endpoint is needed.
-
-### 3. Frontend UI Integration
-
-1. **Update `frontend/src/app/features/admin/general.pure.ts`** to
- expose a small `eventEndFieldMessage(controlErrors, crossField)` pure
- helper that returns the canonical Event End field error string:
-
- | `controlErrors?.['invalidDatetime']` | `form.errors?.['endBeforeStart']` | Returned message |
- |---------------------------------------|-----------------------------------|----------------------------------------------------------|
- | `true` | any | `"Event end must be a valid ISO-8601 datetime."` |
- | falsy | `true` | `"Event end must be after event start."` |
- | falsy | falsy | `null` |
-
- This mirrors the existing `datetimeMessage` pattern and keeps the
- template a thin consumer.
-
-2. **Update `frontend/src/app/features/admin/general.component.ts`**:
-
- - Replace the current `@if (showEventEndError())` /
- `@if (eventEndCrossFieldError())` pair (lines 123-128) with a single
- `eventEndFieldMessage` consumer that always renders inside the
- `data-testid="general-eventEnd-error"` element. When both
- validators are off, the element is absent.
- - Bind `[attr.aria-invalid]` to `true` when
- `eventEndFieldMessage(...)` is non-null, otherwise null.
- - Bind `[attr.aria-describedby]` to `"general-eventEnd-error"` and
- bind `[attr.title]` to the resolved message string in that same
- case.
- - Drop the separate `general-endBeforeStart` block **only after**
- confirming no existing test asserts on it (a quick search of
- `tests/frontend` shows it is only mentioned in
- `tests/frontend/admin-general-pure.spec.ts` as a value assertion on
- the constant `endBeforeStartMessage`, not as a DOM query). If a test
- asserts on the element, keep it as a redundant duplicate or update
- the test.
-
-3. **Do not change** the form-level validator at
- `general.pure.ts:41-51` — it already returns `{ endBeforeStart: true }`
- for both `e < s` and `e === s`. The Job requirement is purely a
- rendering fix.
-
-## 4. Test Strategy
-
-- **Target backend test file:** `tests/backend/migrations.spec.ts` (new
- spec). Add a focused `describe('InitSchema creates category with
- created_at + updated_at columns')` that:
- - Boots `AppModule` against `:memory:` SQLite.
- - Calls `dataSource.runMigrations({ transaction: 'each' })`.
- - Queries `PRAGMA table_info("category")` and asserts the returned
- names contain both `created_at` and `updated_at`.
- - Sanity-checks that `AdminCategoriesService.list()` returns an array
- (length ≥ 6 seeded system rows) without throwing — this is the
- regression test that prevents the user-visible
- `no such column: c.created_at` error from coming back.
-
-- **Target backend test file:** `tests/backend/admin-categories-service.spec.ts`
- (extend). Add one `it('list() succeeds against the migrated schema and
- returns seeded rows')` that runs after the existing CRUD specs. The
- existing tests already use `synchronize: true` which would have masked
- the bug; the new test should rely on the **migrations** path so it
- actually exercises the schema the production app uses.
-
-- **Target frontend test file:** `tests/frontend/admin-general-pure.spec.ts`
- (extend). Add a `describe('eventEndFieldMessage')` block with three
- focused cases:
- 1. Returns the ISO message when `invalidDatetime` is set, even if
- `endBeforeStart` is also set.
- 2. Returns the end-before-start message when `invalidDatetime` is
- absent and `endBeforeStart` is set.
- 3. Returns `null` when both are absent.
-
-- **Mocking Strategy:** No new mocks. Backend spec uses the real
- `AppModule` against `:memory:` SQLite (already the pattern in
- `tests/backend/admin-validation.spec.ts` and
- `tests/backend/admin-general-service.spec.ts`). Frontend spec
- continues to assert on the pure helper return values only.
-
-- **Single command:** `npm test` from `/repo` runs the full suite
- (already in `package.json:14`). Frontend and backend can also be run
- separately via `npm run test:backend` / `npm run test:frontend`.
-
-## 5. Persistent Project Data (/data)
-
-Not applicable to this Job. The DB schema change is a migration; tests
-use `:memory:` SQLite and never write to `/data`. The Categories
-endpoint reads from the existing `category` table without touching
-uploaded files or any other shared asset.
\ No newline at end of file
diff --git a/.kilo/plans/890.md b/.kilo/plans/890.md
new file mode 100644
index 0000000..b583951
--- /dev/null
+++ b/.kilo/plans/890.md
@@ -0,0 +1,26 @@
+# Implementation Plan: Admin Area General Settings UTC Datetime Round-Trip
+
+## 1. Architectural Reconnaissance
+- **Codebase style & conventions:** TypeScript monorepo with an Angular 17 standalone SPA and NestJS backend. The General Settings screen uses a strictly typed, non-nullable reactive form in `frontend/src/app/features/admin/general.component.ts`, while reusable formatting and validation logic is extracted to pure functions in `frontend/src/app/features/admin/general.pure.ts`. The component loads backend ISO timestamps through `toDatetimeLocal`, submits form values through `toIsoUtc`, and uses async/await around the promise-based `AdminService` API facade.
+- **Data Layer:** NestJS persists general settings as string key/value rows through `SettingsService`. `AdminGeneralService.updateSettings` writes `eventStartUtc` and `eventEndUtc` verbatim after `GeneralSettingsSchema` validates timezone-aware ISO-8601 strings and strict start/end ordering. No database schema or migration is required; the defect is isolated to frontend conversion of UTC-labelled `datetime-local` values.
+- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from the dedicated `tests/frontend/` folder. `npm test` runs backend and frontend projects together, while `npm run test:frontend` runs the focused frontend project. Existing pure-helper coverage is in `tests/frontend/admin-general-pure.spec.ts`, which is the appropriate lightweight regression location and requires no browser UI.
+- **Required Tools & Dependencies:** No new system tools, global CLI utilities, package dependencies, persistent `/data` assets, or `setup.sh` changes are required. The implementation uses built-in JavaScript `Date` UTC construction/serialization and the existing Jest toolchain.
+
+## 2. Impacted Files
+- **To Modify:**
+ - `frontend/src/app/features/admin/general.pure.ts` — make `toIsoUtc` interpret the numeric components of the UTC-labelled `datetime-local` string as UTC rather than as the browser's local timezone.
+ - `tests/frontend/admin-general-pure.spec.ts` — add a focused timezone regression proving the displayed UTC wall-clock components serialize unchanged and preserve existing empty/invalid behavior.
+- **To Create:** None.
+
+## 3. Proposed Changes
+1. **Database / Schema Migration:** No changes. The existing setting keys and backend zod schema already accept and persist canonical UTC ISO strings correctly.
+2. **Backend Logic & APIs:** No changes. `PUT /api/v1/admin/general/settings` already validates timezone-aware ISO-8601 input, enforces `eventEndUtc > eventStartUtc`, and stores the payload without timezone conversion; `GET /api/v1/event/status` therefore reflects whatever canonical instant the frontend sends.
+3. **Frontend UI Integration:**
+ 1. Update `toIsoUtc` in `general.pure.ts` to parse the `datetime-local` year, month, day, hour, minute, and optional second/fraction components as UTC components, then create the timestamp with `Date.UTC(...)` and return `toISOString()`.
+ 2. Preserve the helper's current contract for empty values (`''`) and invalid values (return the original string), so the existing reactive-form validation and negative save paths remain unchanged.
+ 3. Keep `toDatetimeLocal` unchanged because it already renders backend timestamps from UTC getters. Together, `toDatetimeLocal(iso)` and `toIsoUtc(local)` will become true timezone-independent inverses for the minute-precision native inputs used by `AdminGeneralComponent`.
+ 4. Keep `AdminGeneralComponent.onSubmit` and the service/API contract unchanged; both event fields already pass through `toIsoUtc`, so correcting the shared helper fixes start and end submissions without UI or backend rewiring.
+
+## 4. Test Strategy
+- **Target Unit Test File:** `tests/frontend/admin-general-pure.spec.ts`.
+- **Mocking Strategy:** No external boundaries need mocking because the defect is entirely in a pure function. Add a minimal deterministic regression around `toIsoUtc('2027-03-15T08:30') === '2027-03-15T08:30:00.000Z'` and the corresponding end value `2027-03-17T16:45`, explicitly exercising the America/Los_Angeles/PDT scenario without launching a UI. Prefer running the focused Jest process with `TZ=America/Los_Angeles` (or isolate a small child Jest invocation configured with that environment) so the test fails against the current local-time implementation and passes only when conversion is browser-timezone independent. Retain or extend the existing empty-input assertion and add only the key invalid-input assertion if needed to lock the helper's fallback contract. Verify with the root single-command suite (`npm test`) plus the existing root build/type-check path (`npm run build`); no visual confirmation or persistent test data is involved.
diff --git a/docs/guides/admin-general-settings.md b/docs/guides/admin-general-settings.md
index df3f3b3..d617c83 100644
--- a/docs/guides/admin-general-settings.md
+++ b/docs/guides/admin-general-settings.md
@@ -2,8 +2,8 @@
type: guide
title: Admin — General Settings
description: How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page.
-tags: [guide, admin, settings, general, tester, datetime, validation]
-timestamp: 2026-07-22T14:50:25Z
+tags: [guide, admin, settings, general, tester, datetime, validation, utc, timezone]
+timestamp: 2026-07-22T15:09:39Z
---
# When this view is available
@@ -56,7 +56,8 @@ The page is a single reactive form with these controls (every
* **Initial load:** `loading() === true` renders `general-loading`. After
both requests resolve, the form is patched with the values from the
backend (UTC timestamps are converted to `datetime-local` strings via
- `toDatetimeLocal` so the native picker shows them).
+ `toDatetimeLocal` so the native picker shows them — see
+ [Datetime UTC round-trip](#datetime-utc-round-trip) below).
* **Logo upload:** selecting a file fires `POST /api/v1/uploads/logo`,
then writes the returned `publicUrl` into the hidden `logo` control.
If the upload fails, `general-logo-error` shows the message; the
@@ -179,6 +180,62 @@ is rendered inside the per-field error region
(`data-testid="general-eventEnd-error"`) by the
`eventEndFieldMessage` helper.
+# Datetime UTC round-trip
+
+Both Event-window fields (`general-eventStart`, `general-eventEnd`) are
+backed by HTML `` controls, which the
+browser renders as a wall-clock picker **without** any timezone
+indicator. The two pure helpers exported from
+`frontend/src/app/features/admin/general.pure.ts` make the wall-clock
+values behave as if they were already UTC, so the admin sees the same
+instant no matter where the browser is running:
+
+| Helper | Direction | Purpose |
+|------------------------------------------------|-------------------------------------|---------|
+| `toDatetimeLocal(iso: string): string` | ISO-8601 UTC → `YYYY-MM-DDTHH:mm` | Renders a backend UTC instant in the native picker using `getUTC*` getters. |
+| `toIsoUtc(local: string): string` | `YYYY-MM-DDTHH:mm[:ss[.fff]]` → ISO-8601 UTC | Parses the wall-clock components as **UTC** (not local time) via `Date.UTC(...)` and returns `toISOString()`. |
+
+The `toIsoUtc` helper explicitly does **not** call `new Date(local)`,
+which would interpret the components in the browser's local timezone
+and shift the round-tripped instant by the browser's UTC offset. Instead
+it regex-parses the year / month / day / hour / minute components (and
+optional seconds / milliseconds), constructs the instant with
+`new Date(Date.UTC(...))`, and returns `toISOString()`. As a result,
+`toIsoUtc(toDatetimeLocal(iso)) === iso` for any minute-precision
+`datetime-local` value the picker can produce, regardless of the
+browser's `Intl.DateTimeFormat().resolvedOptions().timeZone` value.
+
+The helper preserves its existing fallback contract:
+
+| Input | Output |
+|------------------------------------|---------------------------------|
+| `''` (empty) | `''` |
+| Non-matching `YYYY-MM-DDTHH:mm…` | Returns the input verbatim so the reactive-form `invalidDatetime` validator still fires. |
+| Matching `YYYY-MM-DDTHH:mm` | `:00.000Z` |
+| Matching `YYYY-MM-DDTHH:mm:ss` | `.000Z` |
+| Matching `YYYY-MM-DDTHH:mm:ss.fff` | `Z` (fraction padded to ms) |
+
+`AdminGeneralComponent` calls `toIsoUtc` on both `eventStartUtc` and
+`eventEndUtc` before issuing `PUT /api/v1/admin/general/settings`, so
+correcting the shared helper fixes start and end submissions without
+any further component, form, or backend rewiring. The backend zod
+schema (`GeneralSettingsSchema` in
+`backend/src/modules/admin/dto/general.dto.ts`) accepts the resulting
+timezone-aware ISO-8601 strings unchanged and enforces
+`eventEndUtc > eventStartUtc` via `superRefine`.
+
+The regression for this fix lives in
+`tests/frontend/admin-general-pure.spec.ts` under the `datetime helpers`
+suite, with cases that lock in:
+
+* `toIsoUtc('2027-03-15T08:30')` → `'2027-03-15T08:30:00.000Z'`
+ (would previously have returned `'2027-03-15T15:30:00.000Z'` from a
+ PDT browser).
+* `toIsoUtc('2027-03-15T08:30:15')` → `'2027-03-15T08:30:15.000Z'`.
+* `toIsoUtc('2027-03-15T08:30:15.123')` → `'2027-03-15T08:30:15.123Z'`.
+* `toIsoUtc(toDatetimeLocal('2027-03-15T08:30:00.000Z'))` round-trips
+ back to the original ISO string.
+
# Visual elements
| Element | Selector |
diff --git a/docs/index.md b/docs/index.md
index ac6c30f..b0bfccd 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls.
The docs below are organized by purpose so agents can pull just the slice
-they need. Last regenerated 2026-07-22T14:50:25Z.
+they need. Last regenerated 2026-07-22T15:09:39Z.
# Architecture
diff --git a/frontend/src/app/features/admin/general.pure.ts b/frontend/src/app/features/admin/general.pure.ts
index 9cede94..5a3eae2 100644
--- a/frontend/src/app/features/admin/general.pure.ts
+++ b/frontend/src/app/features/admin/general.pure.ts
@@ -60,7 +60,16 @@ export function toDatetimeLocal(iso: string): string {
export function toIsoUtc(local: string): string {
if (!local) return '';
- const d = new Date(local);
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/.exec(local);
+ if (!m) return local;
+ const year = Number(m[1]);
+ const monthIndex = Number(m[2]) - 1;
+ const day = Number(m[3]);
+ const hours = Number(m[4]);
+ const minutes = Number(m[5]);
+ const seconds = m[6] ? Number(m[6]) : 0;
+ const millis = m[7] ? Number(m[7].padEnd(3, '0')) : 0;
+ const d = new Date(Date.UTC(year, monthIndex, day, hours, minutes, seconds, millis));
if (Number.isNaN(d.getTime())) return local;
return d.toISOString();
}
diff --git a/tests/frontend/admin-general-pure.spec.ts b/tests/frontend/admin-general-pure.spec.ts
index 64a83c0..28f8fc1 100644
--- a/tests/frontend/admin-general-pure.spec.ts
+++ b/tests/frontend/admin-general-pure.spec.ts
@@ -90,6 +90,21 @@ describe('datetime helpers', () => {
it('toIsoUtc returns input verbatim when empty', () => {
expect(toIsoUtc('')).toBe('');
});
+
+ it('toIsoUtc interprets datetime-local components as UTC regardless of browser timezone', () => {
+ expect(toIsoUtc('2027-03-15T08:30')).toBe('2027-03-15T08:30:00.000Z');
+ expect(toIsoUtc('2027-03-17T16:45')).toBe('2027-03-17T16:45:00.000Z');
+ });
+
+ it('toIsoUtc preserves seconds and milliseconds when present', () => {
+ expect(toIsoUtc('2027-03-15T08:30:15')).toBe('2027-03-15T08:30:15.000Z');
+ expect(toIsoUtc('2027-03-15T08:30:15.123')).toBe('2027-03-15T08:30:15.123Z');
+ });
+
+ it('toIsoUtc round-trips with toDatetimeLocal in UTC', () => {
+ expect(toIsoUtc(toDatetimeLocal('2027-03-15T08:30:00.000Z'))).toBe('2027-03-15T08:30:00.000Z');
+ expect(toIsoUtc(toDatetimeLocal('2027-03-17T16:45:00.000Z'))).toBe('2027-03-17T16:45:00.000Z');
+ });
});
describe('pageTitleError', () => {