diff --git a/.kilo/plans/886.md b/.kilo/plans/886.md
deleted file mode 100644
index 4b970a4..0000000
--- a/.kilo/plans/886.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Implementation Plan: Admin Area General Settings and Categories 1.04
-
-## 1. Architectural Reconnaissance
-- **Codebase style & conventions:** Node.js monorepo with a NestJS 10 REST backend and Angular 17 standalone frontend. Frontend components use `inject()`, Angular Signals, reactive forms, `ChangeDetectionStrategy.OnPush`, and async service methods backed by `HttpClient`/`firstValueFrom`. Global bootstrap data and CSS theme application are owned by the root-scoped `BootstrapService`; admin form persistence is owned by `AdminGeneralComponent` and `AdminService`.
-- **Current flow and root cause:** Application startup calls `BootstrapService.load()`, stores the `/api/v1/bootstrap` payload, and writes its theme tokens to `document.documentElement.style`. Saving `/admin/general` successfully persists `themeKey` through `PUT /api/v1/admin/general/settings`, and the backend later resolves the chosen theme in bootstrap. However, `AdminGeneralComponent.onSubmit()` only patches the returned settings form; it never asks `BootstrapService` to refresh or apply the newly selected theme. The backend's `general` SSE event contains only `themeKey`, while the frontend event-status stream treats every hub payload as event-state data and has no theme propagation path. Thus the save response changes persistence but not the current document tokens.
-- **Data Layer:** TypeORM with SQLite (`better-sqlite3`). The existing `setting` table stores `themeKey`; no schema or migration change is required. Persistent runtime data remains under `/data/hipctf` through existing setup conventions; the planned focused frontend tests require no persistent data.
-- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from `tests/frontend/**/*.spec.ts`, backend tests run in Node from `tests/backend/**/*.spec.ts`, and all tests are runnable with `npm test`. The fix should follow a minimal red-green-refactor path and test logic rather than visual appearance or browser automation.
-- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, or `/data` assets are required. Existing Node/npm, Angular, Jest, jsdom, TypeScript, and `setup.sh` are sufficient; `setup.sh` and lockfiles should remain unchanged.
-
-## 2. Impacted Files
-- **To Modify:**
- - `frontend/src/app/core/services/bootstrap.service.ts` — expose a typed, reusable live theme application/refresh path that updates both bootstrap client state and document CSS custom properties without being blocked by the startup request cache.
- - `frontend/src/app/features/admin/general.component.ts` — inject `BootstrapService` and invoke the live theme refresh only after `updateGeneralSettings()` succeeds, before reporting Save success.
-- **To Create:**
- - `tests/frontend/bootstrap-theme.spec.ts` — focused jsdom tests for applying all theme token fields and refreshing to a saved non-default theme.
-
-## 3. Proposed Changes
-1. **Database / Schema Migration:**
- - No migration or backend persistence change. `AdminGeneralService.updateSettings()` already saves `SETTINGS_KEYS.THEME_KEY`, and `SystemService.bootstrap()` already reads that key and returns the corresponding complete theme object.
-2. **Backend Logic & APIs:**
- - Keep the existing `PUT /api/v1/admin/general/settings` and `GET /api/v1/bootstrap` contracts unchanged. The save response intentionally contains settings rather than theme tokens; reuse bootstrap as the canonical theme-token source instead of duplicating the backend theme model in the admin response.
- - Do not add backend endpoints, dependencies, SSE handling, or theme-token duplication. The reported same-tab Save issue can be resolved deterministically from the successful save path, while existing persistence guarantees future reloads use the selected theme.
-3. **Frontend UI Integration:**
- - Replace the frontend `theme: any` shape with local typed `Theme`/`ThemeTokens` interfaces matching the bootstrap contract, including colors, font family, radii, and spacing scale. Type the theme application method accordingly and preserve the defensive no-token guard for malformed/absent payloads.
- - Make theme application a reusable service operation rather than startup-only private behavior. It must continue setting all existing CSS variables (`--color-primary`, `--color-secondary`, `--color-accent`, `--color-surface`, `--color-text`, `--color-success`, `--color-warning`, `--color-danger`, `--font-family`, and all three radius properties), including valid zero-valued radius strings such as Monochrome's `0`.
- - Add a force-refresh method in `BootstrapService` that performs a fresh `/api/v1/bootstrap` request instead of reusing `loadPromise`, validates HTTP success before consuming the payload, updates `payload` and `initialized`, and applies the returned theme to the live document. Keep startup `load()`/`ready()` request coalescing intact so guard/bootstrap race behavior does not regress.
- - Inject `BootstrapService` into `AdminGeneralComponent`. After `AdminService.updateGeneralSettings()` resolves and the form is patched, await the force-refresh method before setting `saveOk`. This orders persistence before re-fetch, applies the canonical saved theme immediately, and also synchronizes other global bootstrap-backed settings in the current tab.
- - Treat a failed refresh as a Save error rather than showing a false success: retain the persisted form response, surface the existing inline error path, and leave `submitting` cleanup in `finally`. Do not mutate theme tokens before the PUT succeeds, so failed saves leave the current live theme unchanged.
- - No template or categories changes are needed; the Global Theme select already includes the server-provided canonical choices, and category behavior is unrelated to this regression.
-
-## 4. Test Strategy
-- **Target Unit Test File:** `tests/frontend/bootstrap-theme.spec.ts`, executed by the existing root `npm test` command (or selectively through the frontend Jest project during development). Do not add visual, browser, or persistence-volume tests.
-- **Mocking Strategy:** Use jsdom's real `document.documentElement.style` and mock only global `fetch`. Reset CSS properties and the fetch mock between tests to avoid shared state. Provide small typed bootstrap payload fixtures rather than Angular TestBed or a backend/database instance.
-- **Core success path:** Start with Classic CSS values, have the mocked refresh return a Monochrome bootstrap payload, call the refresh method, and assert the payload signal plus representative computed CSS variables change immediately to `--color-primary: #000000`, `--color-text: #0a0a0a`, and `--radius-md: 0`; also verify all token mappings in one compact table-driven assertion so every canonical theme token category is covered.
-- **Key error condition:** Mock a non-OK bootstrap refresh response and assert the method rejects without replacing the existing payload or applying partial/new CSS tokens. This supports the component's existing save-error handling while keeping the suite minimal.
-- **Implementation verification:** Run the focused frontend test first to prove the regression, then `npm test`, `npm run build`, and the repository's available TypeScript builds. No lint script is defined in the root or workspace package manifests, so no additional lint dependency or setup change should be introduced.
diff --git a/.kilo/plans/887.md b/.kilo/plans/887.md
new file mode 100644
index 0000000..bbe4e62
--- /dev/null
+++ b/.kilo/plans/887.md
@@ -0,0 +1,173 @@
+# Implementation Plan: Admin General Settings — Event Start/End Validation (Job 887)
+
+## Status
+
+NOT already implemented. Two distinct gaps are described in the Job:
+
+1. **Backend (`PUT /api/v1/admin/general/settings`)** accepts any string for
+ `eventStartUtc` / `eventEndUtc` and persists it. Only the cross-field
+ `end > start` rule is enforced. Garbage payloads like
+ `eventStartUtc='not-a-date'` return HTTP 200 and overwrite the stored
+ schedule.
+2. **Frontend (`/admin/general`)** has no per-field validation feedback for the
+ two `datetime-local` inputs. The cross-field rule fires correctly
+ (`form.invalid`, Save disabled), but the inputs render no `title`,
+ `aria-invalid`, `aria-describedby`, `validationMessage`, or `[data-testid=...-error]`
+ element when either field is empty/invalid individually.
+
+## 1. Architectural Reconnaissance
+
+- **Codebase style & conventions:**
+ - Backend: NestJS controllers, Zod schemas in `*.dto.ts`, validated by the
+ shared `ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts`)
+ which throws `ApiError.validation('Request validation failed', details)`
+ mapped to HTTP 400 `VALIDATION_FAILED`.
+ - Frontend: Angular 17+ standalone components, signal-based state, `OnPush`,
+ `ReactiveFormsModule`, pure helper module at
+ `frontend/src/app/features/admin/general.pure.ts`. Inline error display
+ pattern is already used for the `pageTitle` field
+ (`general-pageTitle-error` + `pageTitleMessage(...)` computed signal +
+ `showPageTitleError()` computed) — replicate this exact pattern for the
+ two date inputs.
+- **Data Layer:** SQLite via `better-sqlite3`. `SettingsService` stores every
+ general-settings key as a single string row in the `setting` table. No
+ schema migration is required for this job — we are tightening input
+ validation, not changing storage.
+- **Test Framework & Structure:**
+ - Jest 29 with `ts-jest`, root config at `tests/jest.config.js`. Two
+ projects: `backend` (`tests/backend/`) and `frontend`
+ (`tests/frontend/`). Tests live ONLY in `/repo/tests/`.
+ - Single command `npm test` runs everything.
+- **Required Tools & Dependencies:** None. We use the already-installed
+ `zod` (datetime check is built into zod ≥ 3.20) and the existing
+ Angular reactive-forms stack. No `setup.sh` changes are required.
+
+## 2. Impacted Files
+
+- **To Modify:**
+ - `backend/src/modules/admin/dto/general.dto.ts` — tighten
+ `eventStartUtc` / `eventEndUtc` to require real ISO-8601 datetimes.
+ - `frontend/src/app/features/admin/general.component.ts` — add per-field
+ datetime error rendering and inline error elements under both inputs.
+ - `frontend/src/app/features/admin/general.pure.ts` — add pure helpers
+ (`isoDatetimeValidator`, `datetimeMessage`) so the same logic is
+ unit-testable in isolation.
+ - `tests/backend/admin-general-service.spec.ts` — add negative cases for
+ non-datetime payloads.
+ - `tests/frontend/admin-general-pure.spec.ts` — add tests for the new
+ validators + message helpers.
+- **To Create:** None. All changes fit inside existing files.
+
+## 3. Proposed Changes
+
+### 3.1 Backend — strict ISO-8601 datetime validation
+
+In `backend/src/modules/admin/dto/general.dto.ts`:
+
+- Replace the two loose `z.string()` declarations with zod's built-in
+ `.datetime({ offset: false })` check (or
+ `z.string().datetime({ message: '...' })` with explicit messages), so that
+ `GeneralSettingsSchema` rejects non-ISO-8601 strings with a clear
+ `INVALID_DATETIME` message before the request reaches
+ `SettingsService.set(...)`. This guarantees:
+ - The previously valid schedule is unchanged when validation fails (the
+ service is never called).
+ - The error is surfaced via the standard
+ `ApiError.validation('Request validation failed', details)` envelope
+ produced by `ZodValidationPipe` (HTTP 400 `VALIDATION_FAILED`).
+- Keep the existing `superRefine` end > start rule; it now runs only on
+ values that already passed the per-field datetime check.
+- Empty string handling: the current UI sends `''` when the user clears an
+ input, and the current code accepts `''`. To avoid regressing the
+ "unconfigured event" state, allow `z.union([z.string().datetime(...), z.literal('')])`
+ (an empty string is a valid "no event scheduled" value). Anything that
+ is neither empty nor a valid ISO datetime is rejected.
+
+### 3.2 Frontend — per-field datetime error rendering
+
+In `frontend/src/app/features/admin/general.component.ts`:
+
+- Apply `isoDatetimeValidator` (new, see 3.3) as a per-control validator
+ on both `eventStartUtc` and `eventEndUtc` (alongside the existing
+ group-level `endAfterStartValidator`).
+- For each input, add the `aria-describedby` / `aria-invalid` attributes
+ and an inline `
…
`
+ (and the matching `general-eventEnd-error`) that renders when the
+ control is touched/dirty AND invalid, driven by a `computed` signal —
+ exactly mirroring the existing `showPageTitleError` /
+ `general-pageTitle-error` pattern in this same file.
+- The message text comes from the new `datetimeMessage(value, errors)`
+ pure helper (see 3.3). Messages:
+ - non-empty value that fails `Date.parse` →
+ `"Event start must be a valid ISO-8601 datetime."` (or "Event end").
+ - empty value → no message (empty is the intentional "unconfigured"
+ state; the `endBeforeStart` group validator already stays quiet when
+ one side is empty).
+- The existing `general-endBeforeStart` element stays put; it now
+ coexists with the two new per-field error nodes.
+
+### 3.3 Frontend pure helpers
+
+In `frontend/src/app/features/admin/general.pure.ts`, add:
+
+```ts
+export function isoDatetimeValidator(ctrl: { value: string | null }) {
+ const v = ctrl?.value ?? '';
+ if (v === '') return null; // unconfigured is allowed
+ const t = Date.parse(v);
+ return Number.isFinite(t) ? null : { invalidDatetime: true };
+}
+
+export function datetimeMessage(
+ fieldLabel: string,
+ value: string | null | undefined,
+ controlErrors: { [k: string]: unknown } | null | undefined,
+): string | null {
+ if (controlErrors?.['invalidDatetime']) {
+ return `${fieldLabel} must be a valid ISO-8601 datetime.`;
+ }
+ // also catch the case where the user typed something unparseable but
+ // the validator did not run yet (defensive)
+ const v = value ?? '';
+ if (v !== '' && !Number.isFinite(Date.parse(v))) {
+ return `${fieldLabel} must be a valid ISO-8601 datetime.`;
+ }
+ return null;
+}
+```
+
+These are exported from the existing pure file so the component can call
+them directly and the spec file can exercise them in isolation.
+
+### 3.4 No persistence side-effects on rejection
+
+This is already enforced by the existing `ZodValidationPipe` (the
+service is never invoked when validation fails), so no further change is
+needed there.
+
+## 4. Test Strategy
+
+- **Target Unit Test Files:**
+ - `tests/backend/admin-general-service.spec.ts` — extend the existing
+ `GeneralSettingsSchema - validation rules` describe block with two
+ cases:
+ - `eventStartUtc = 'not-a-date'` → schema returns `success: false`.
+ - `eventStartUtc = '2026-08-15T10:30:00.000Z'` and
+ `eventEndUtc = 'also-not-a-date'` → schema returns
+ `success: false`, and the issue message includes `'eventEndUtc'`
+ / contains the word `datetime` (to assert the new error is the
+ source of the rejection, not the cross-field rule).
+ - `tests/frontend/admin-general-pure.spec.ts` — extend with:
+ - `isoDatetimeValidator` returns `null` for empty input.
+ - `isoDatetimeValidator` returns `null` for a valid ISO string.
+ - `isoDatetimeValidator` returns `{ invalidDatetime: true }` for
+ `'not-a-date'`.
+ - `datetimeMessage` returns the correct per-field message for
+ `'Event start'` / `'Event end'` and returns `null` for empty /
+ valid input.
+- **Mocking Strategy:** Pure-function tests need no mocks. The existing
+ backend spec already constructs the schema in-process; we just feed
+ it bad payloads and inspect `safeParse` results. No DB, no HTTP
+ server, no Angular `TestBed` are required.
+- **Command:** `npm test` (or the narrower
+ `npm run test:backend` / `npm run test:frontend`).
diff --git a/backend/src/modules/admin/dto/general.dto.ts b/backend/src/modules/admin/dto/general.dto.ts
index 95dc5f3..22e1bf0 100644
--- a/backend/src/modules/admin/dto/general.dto.ts
+++ b/backend/src/modules/admin/dto/general.dto.ts
@@ -7,8 +7,8 @@ export const GeneralSettingsSchema = z
logo: z.string().max(2048),
welcomeMarkdown: z.string().max(64_000),
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
- eventStartUtc: z.string(),
- eventEndUtc: z.string(),
+ eventStartUtc: z.union([z.literal(''), z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' })]),
+ eventEndUtc: z.union([z.literal(''), z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' })]),
defaultChallengeIp: z.string().min(1).max(255),
registrationsEnabled: z.boolean(),
})
diff --git a/docs/api/admin.md b/docs/api/admin.md
index 445e7ce..93d388c 100644
--- a/docs/api/admin.md
+++ b/docs/api/admin.md
@@ -46,9 +46,11 @@ removes the row.
* `logo` up to 2048 chars (a public URL — uploaded separately)
* `welcomeMarkdown` up to 64 000 chars
* `themeKey` is one of `THEME_IDS`
-* `eventStartUtc` / `eventEndUtc` parseable dates; `superRefine`
- enforces `eventEndUtc > eventStartUtc` and reports the issue on
- `eventEndUtc`
+* `eventStartUtc` / `eventEndUtc` are validated as ISO-8601 datetimes
+ (zod `string().datetime(...)`) — empty strings are explicitly allowed
+ to keep the event window unconfigured. `superRefine` then enforces
+ `eventEndUtc > eventStartUtc` and reports the issue on `eventEndUtc`
+ when both fields are non-empty.
* `defaultChallengeIp` 1–255 chars
* `registrationsEnabled` boolean
diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md
index 283b5da..8f7484f 100644
--- a/docs/architecture/key-files.md
+++ b/docs/architecture/key-files.md
@@ -25,6 +25,7 @@ timestamp: 2026-07-22T13:40:00Z
| `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. |
| `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. |
| `backend/src/modules/uploads/uploads.controller.ts` | Registers admin-only multipart uploads, including Sharp-backed site-logo format validation. |
+| `backend/src/modules/admin/dto/general.dto.ts` | Zod schema for `PUT /api/v1/admin/general/settings` — string-length rules, theme-key enum, ISO-8601 datetime check on the event-window fields (empty string allowed), and the `endAfterStart` super-refine. |
# Frontend
@@ -45,6 +46,8 @@ timestamp: 2026-07-22T13:40:00Z
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. |
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. |
+| `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent` reactive form for `/admin/general` — per-field inline error rendering (page-title + event-start + event-end), logo upload wiring, welcome Markdown preview, event-state derivation, and SSE `general` event handling. |
+| `frontend/src/app/features/admin/general.pure.ts` | Pure helpers used by `AdminGeneralComponent` — `deriveEventState`, `endAfterStartValidator`, `normalizePageTitle`, `toIsoUtc`, `toDatetimeLocal`, `pageTitleError` / `pageTitleMessage`, `isoDatetimeValidator`, and `datetimeMessage`. |
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. |
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
diff --git a/docs/guides/admin-general-settings.md b/docs/guides/admin-general-settings.md
index d2a6ccb..39cdc30 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 global platform settings (page title, logo, theme, event window, default challenge IP, registrations, welcome Markdown) from the /admin/general page.
-tags: [guide, admin, settings, general, tester]
-timestamp: 2026-07-22T12:44:45Z
+tags: [guide, admin, settings, general, tester, datetime, validation]
+timestamp: 2026-07-22T14:07:58Z
---
# When this view is available
@@ -42,8 +42,8 @@ The page is a single reactive form with these controls (every
| Logo (file picker) | `general-logo-file` | `logo` (public URL) | Uploads via `POST /api/v1/uploads/logo`; the returned `publicUrl` is bound to a hidden input `general-logo`. |
| Logo upload status | `general-logo-uploading` / `general-logo-error` / `general-logo-current` | — | Inline status text under the file picker. |
| Global theme | `general-themeKey` | `themeKey` | `