AI Implementation feature(890): Admin Area General Settings and Categories 1.08 #30

Merged
m0rph3us1987 merged 2 commits from feature-890-1784732452906 into dev 2026-07-22 15:11:02 +00:00
6 changed files with 112 additions and 263 deletions
-258
View File
@@ -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 `<input>` 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.
+26
View File
@@ -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.
+60 -3
View File
@@ -2,8 +2,8 @@
type: guide type: guide
title: Admin — General Settings title: Admin — General Settings
description: How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page. 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] tags: [guide, admin, settings, general, tester, datetime, validation, utc, timezone]
timestamp: 2026-07-22T14:50:25Z timestamp: 2026-07-22T15:09:39Z
--- ---
# When this view is available # 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 * **Initial load:** `loading() === true` renders `general-loading`. After
both requests resolve, the form is patched with the values from the both requests resolve, the form is patched with the values from the
backend (UTC timestamps are converted to `datetime-local` strings via 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`, * **Logo upload:** selecting a file fires `POST /api/v1/uploads/logo`,
then writes the returned `publicUrl` into the hidden `logo` control. then writes the returned `publicUrl` into the hidden `logo` control.
If the upload fails, `general-logo-error` shows the message; the 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 (`data-testid="general-eventEnd-error"`) by the
`eventEndFieldMessage` helper. `eventEndFieldMessage` helper.
# Datetime UTC round-trip
Both Event-window fields (`general-eventStart`, `general-eventEnd`) are
backed by HTML `<input type="datetime-local">` 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` | `<value>:00.000Z` |
| Matching `YYYY-MM-DDTHH:mm:ss` | `<value>.000Z` |
| Matching `YYYY-MM-DDTHH:mm:ss.fff` | `<value>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 # Visual elements
| Element | Selector | | Element | Selector |
+1 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice 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 # Architecture
@@ -60,7 +60,16 @@ export function toDatetimeLocal(iso: string): string {
export function toIsoUtc(local: string): string { export function toIsoUtc(local: string): string {
if (!local) return ''; 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; if (Number.isNaN(d.getTime())) return local;
return d.toISOString(); return d.toISOString();
} }
+15
View File
@@ -90,6 +90,21 @@ describe('datetime helpers', () => {
it('toIsoUtc returns input verbatim when empty', () => { it('toIsoUtc returns input verbatim when empty', () => {
expect(toIsoUtc('')).toBe(''); 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', () => { describe('pageTitleError', () => {