diff --git a/frontend/src/lib/timeline/eventFormServer.ts b/frontend/src/lib/timeline/eventFormServer.ts
new file mode 100644
index 00000000..13c8bae4
--- /dev/null
+++ b/frontend/src/lib/timeline/eventFormServer.ts
@@ -0,0 +1,88 @@
+import { fail } from '@sveltejs/kit';
+import { m } from '$lib/paraglide/messages.js';
+import type { components } from '$lib/generated/api';
+import type { DatePrecision } from '$lib/shared/utils/documentDate';
+
+type TimelineEventRequest = components['schemas']['TimelineEventRequest'];
+
+// Prevents open redirect: validate before constructing /persons/{id}. See OWASP CWE-601.
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/**
+ * Resolves the context-aware post-save / post-delete redirect target. Returns
+ * the originating person page only when `originPersonIdRaw` is a strict UUID;
+ * otherwise falls back to the timeline (open-redirect guard).
+ */
+export function resolveNavTarget(originPersonIdRaw: string): string {
+ return UUID_RE.test(originPersonIdRaw) ? `/persons/${originPersonIdRaw}` : '/zeitstrahl';
+}
+
+export interface ParsedEventForm {
+ title: string;
+ type: 'PERSONAL' | 'HISTORICAL';
+ eventDate: string;
+ precision: DatePrecision;
+ eventDateEnd: string | null;
+ description: string;
+ personIds: string[];
+ documentIds: string[];
+ originPersonId: string;
+}
+
+/** Reads the curator event form fields out of submitted FormData. */
+export function parseEventForm(formData: FormData): ParsedEventForm {
+ const rawType = formData.get('type')?.toString();
+ const type = rawType === 'HISTORICAL' ? 'HISTORICAL' : 'PERSONAL';
+ const precision = (formData.get('metaDatePrecision')?.toString() as DatePrecision) || 'DAY';
+ const endRaw = formData.get('eventDateEnd')?.toString().trim() ?? '';
+ // Off-RANGE submits an empty string → null so a stale end-date never persists.
+ const eventDateEnd = precision === 'RANGE' && endRaw ? endRaw : null;
+
+ return {
+ title: formData.get('title')?.toString().trim() ?? '',
+ type,
+ eventDate: formData.get('eventDate')?.toString().trim() ?? '',
+ precision,
+ eventDateEnd,
+ description: formData.get('description')?.toString().trim() ?? '',
+ personIds: formData.getAll('personIds').map((v) => v.toString()),
+ documentIds: formData.getAll('documentIds').map((v) => v.toString()),
+ originPersonId: formData.get('originPersonId')?.toString() ?? ''
+ };
+}
+
+/**
+ * Surfaces all failing required-field errors simultaneously (title + date) via a
+ * fail(400) payload that also preserves every entered value — including the
+ * picker arrays — so the form re-renders without losing state. Returns null when
+ * the form is valid.
+ */
+export function validateEventForm(parsed: ParsedEventForm) {
+ const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : '';
+ const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : '';
+ if (!titleError && !dateError) return null;
+ return fail(400, {
+ titleError,
+ dateError,
+ title: parsed.title,
+ description: parsed.description,
+ type: parsed.type,
+ personIds: parsed.personIds,
+ documentIds: parsed.documentIds
+ });
+}
+
+/** Builds the TimelineEventRequest write body from parsed form fields. */
+export function toEventRequest(parsed: ParsedEventForm, version?: number): TimelineEventRequest {
+ return {
+ title: parsed.title,
+ type: parsed.type,
+ eventDate: parsed.eventDate,
+ precision: parsed.precision,
+ eventDateEnd: parsed.eventDateEnd,
+ ...(parsed.description ? { description: parsed.description } : {}),
+ ...(parsed.personIds.length ? { personIds: parsed.personIds } : {}),
+ ...(parsed.documentIds.length ? { documentIds: parsed.documentIds } : {}),
+ ...(version !== undefined ? { version } : {})
+ } as TimelineEventRequest;
+}
diff --git a/frontend/src/routes/zeitstrahl/events/new/+page.server.ts b/frontend/src/routes/zeitstrahl/events/new/+page.server.ts
new file mode 100644
index 00000000..68d68665
--- /dev/null
+++ b/frontend/src/routes/zeitstrahl/events/new/+page.server.ts
@@ -0,0 +1,81 @@
+import { error, fail, redirect } from '@sveltejs/kit';
+import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
+import { getErrorMessage } from '$lib/shared/errors';
+import { hasWriteAll } from '$lib/shared/server/permissions';
+import {
+ parseEventForm,
+ validateEventForm,
+ toEventRequest,
+ resolveNavTarget
+} from '$lib/timeline/eventFormServer';
+import type { PersonOption } from '$lib/person/personOption';
+import type { DocumentOption } from '$lib/document/documentTypeahead';
+
+export async function load({
+ locals,
+ url,
+ fetch
+}: {
+ locals: App.Locals;
+ url: URL;
+ fetch: typeof globalThis.fetch;
+}) {
+ // Null-user guard first — avoids a TypeError on locals.user.groups for an
+ // unauthenticated request that reaches the route.
+ if (!locals.user) throw error(403, 'Forbidden');
+ // WRITE_ALL check mirrors Permission.WRITE_ALL — server-side gate; frontend
+ // canWrite flag is for hiding entry-point buttons only.
+ if (!hasWriteAll(locals)) throw error(403, 'Forbidden');
+
+ const api = createApiClient(fetch);
+ const personId = url.searchParams.get('personId');
+ const documentId = url.searchParams.get('documentId');
+
+ const [personResult, documentResult] = await Promise.all([
+ personId ? api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) : null,
+ documentId ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) : null
+ ]);
+
+ // Silently ignore 404/403 on prefill lookups to avoid leaking entity existence.
+ const initialPersons: PersonOption[] =
+ personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
+ const initialDocuments: DocumentOption[] =
+ documentResult && documentResult.response.ok && documentResult.data
+ ? [
+ {
+ id: documentResult.data.id,
+ title: documentResult.data.title,
+ documentDate: documentResult.data.documentDate,
+ metaDatePrecision: documentResult.data.metaDatePrecision,
+ metaDateEnd: documentResult.data.metaDateEnd
+ }
+ ]
+ : [];
+
+ return { initialPersons, initialDocuments, originPersonId: personId ?? '' };
+}
+
+export const actions = {
+ save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
+ const parsed = parseEventForm(await request.formData());
+
+ const invalid = validateEventForm(parsed);
+ if (invalid) return invalid;
+
+ const api = createApiClient(fetch);
+ const result = await api.POST('/api/timeline/events', { body: toEventRequest(parsed) });
+
+ if (!result.response.ok) {
+ return fail(result.response.status, {
+ error: getErrorMessage(extractErrorCode(result.error)),
+ title: parsed.title,
+ description: parsed.description,
+ type: parsed.type,
+ personIds: parsed.personIds,
+ documentIds: parsed.documentIds
+ });
+ }
+
+ throw redirect(303, resolveNavTarget(parsed.originPersonId));
+ }
+};
diff --git a/frontend/src/routes/zeitstrahl/events/new/+page.svelte b/frontend/src/routes/zeitstrahl/events/new/+page.svelte
new file mode 100644
index 00000000..a5967efa
--- /dev/null
+++ b/frontend/src/routes/zeitstrahl/events/new/+page.svelte
@@ -0,0 +1,13 @@
+
+
+
diff --git a/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts b/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts
new file mode 100644
index 00000000..accb4ae2
--- /dev/null
+++ b/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+vi.mock('$lib/shared/api.server', () => ({
+ createApiClient: vi.fn(),
+ extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
+}));
+
+import { createApiClient } from '$lib/shared/api.server';
+import { load, actions } from './+page.server';
+
+const mockFetch = vi.fn() as unknown as typeof fetch;
+
+beforeEach(() => vi.clearAllMocks());
+
+function localsWith(perms: string[] | null) {
+ if (perms === null) return { user: null };
+ return { user: { groups: [{ permissions: perms }] } };
+}
+
+function loadEvent(perms: string[] | null, search = '') {
+ const url = new URL(`http://localhost/zeitstrahl/events/new${search}`);
+ return {
+ locals: localsWith(perms),
+ url,
+ fetch: mockFetch,
+ request: new Request(url),
+ route: { id: '/zeitstrahl/events/new' },
+ params: {}
+ } as never;
+}
+
+function saveRequest(fields: Record): Request {
+ const fd = new FormData();
+ for (const [k, v] of Object.entries(fields)) {
+ if (Array.isArray(v)) v.forEach((x) => fd.append(k, x));
+ else fd.set(k, v);
+ }
+ return new Request('http://localhost/zeitstrahl/events/new', { method: 'POST', body: fd });
+}
+
+function saveEvent(fields: Record) {
+ return {
+ request: saveRequest(fields),
+ fetch: mockFetch,
+ route: { id: '/zeitstrahl/events/new' },
+ params: {}
+ } as never;
+}
+
+describe('zeitstrahl/events/new load — gating (REQ-002/003)', () => {
+ it('throws 403 for an unauthenticated (null) user', async () => {
+ await expect(load(loadEvent(null))).rejects.toMatchObject({ status: 403 });
+ });
+
+ it('throws 403 for an authenticated user without WRITE_ALL', async () => {
+ await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
+ });
+
+ it('allows a curator with WRITE_ALL', async () => {
+ vi.mocked(createApiClient).mockReturnValue({ GET: vi.fn() } as never);
+ const result = await load(loadEvent(['WRITE_ALL']));
+ expect(result.initialPersons).toEqual([]);
+ expect(result.initialDocuments).toEqual([]);
+ });
+});
+
+describe('zeitstrahl/events/new load — prefill (REQ-014)', () => {
+ it('preselects a valid person and ignores an unknown document', async () => {
+ const get = vi.fn((path: string) => {
+ if (path === '/api/persons/{id}')
+ return Promise.resolve({
+ response: { ok: true },
+ data: { id: 'p1', displayName: 'Anna' }
+ });
+ return Promise.resolve({ response: { ok: false }, data: null });
+ });
+ vi.mocked(createApiClient).mockReturnValue({ GET: get } as never);
+
+ const result = await load(loadEvent(['WRITE_ALL'], '?personId=p1&documentId=missing'));
+ expect(result.initialPersons).toHaveLength(1);
+ expect(result.initialDocuments).toEqual([]);
+ expect(result.originPersonId).toBe('p1');
+ });
+});
+
+describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => {
+ const validUuid = '11111111-1111-1111-1111-111111111111';
+
+ it('posts a TimelineEventRequest and redirects on success', async () => {
+ const post = vi
+ .fn()
+ .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
+ vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
+
+ await expect(
+ actions.save(saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' }))
+ ).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
+
+ expect(post).toHaveBeenCalledTimes(1);
+ expect(post.mock.calls[0][1].body).toMatchObject({
+ title: 'Umzug',
+ type: 'PERSONAL',
+ eventDate: '1925-04-01'
+ });
+ });
+
+ it('sends eventDateEnd: null when precision is not RANGE', async () => {
+ const post = vi
+ .fn()
+ .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
+ vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
+
+ try {
+ await actions.save(
+ saveEvent({
+ title: 'Umzug',
+ type: 'PERSONAL',
+ eventDate: '1925-04-01',
+ metaDatePrecision: 'YEAR',
+ eventDateEnd: '1925-05-01'
+ })
+ );
+ } catch {
+ // redirect throws on success
+ }
+ expect(post.mock.calls[0][1].body.eventDateEnd).toBeNull();
+ });
+
+ it('returns fail(400) with preserved picker arrays on blank title', async () => {
+ const post = vi.fn();
+ vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
+
+ const result = await actions.save(
+ saveEvent({
+ title: ' ',
+ type: 'PERSONAL',
+ eventDate: '1925-04-01',
+ personIds: ['p1', 'p2'],
+ documentIds: ['d1']
+ })
+ );
+
+ expect(post).not.toHaveBeenCalled();
+ expect(result).toMatchObject({ status: 400 });
+ expect(result.data.personIds).toEqual(['p1', 'p2']);
+ expect(result.data.documentIds).toEqual(['d1']);
+ expect(result.data.titleError).toBeTruthy();
+ });
+
+ it('surfaces both title and date errors when both blank (REQ-011)', async () => {
+ vi.mocked(createApiClient).mockReturnValue({ POST: vi.fn() } as never);
+ const result = await actions.save(saveEvent({ title: '', type: 'PERSONAL', eventDate: '' }));
+ expect(result.data.titleError).toBeTruthy();
+ expect(result.data.dateError).toBeTruthy();
+ });
+
+ it('redirects to /persons/{id} when originPersonId is a valid UUID', async () => {
+ const post = vi
+ .fn()
+ .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
+ vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
+
+ await expect(
+ actions.save(
+ saveEvent({
+ title: 'Umzug',
+ type: 'PERSONAL',
+ eventDate: '1925-04-01',
+ originPersonId: validUuid
+ })
+ )
+ ).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
+ });
+
+ it('defaults to /zeitstrahl when originPersonId is not a valid UUID (REQ-015)', async () => {
+ const post = vi
+ .fn()
+ .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
+ vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
+
+ await expect(
+ actions.save(
+ saveEvent({
+ title: 'Umzug',
+ type: 'PERSONAL',
+ eventDate: '1925-04-01',
+ originPersonId: '../evil'
+ })
+ )
+ ).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
+ });
+
+ it('maps the API error and does not redirect on a non-ok save (incl. 409)', async () => {
+ const post = vi.fn().mockResolvedValue({
+ response: { ok: false, status: 409 },
+ error: { code: 'CONFLICT' }
+ });
+ vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
+
+ const result = await actions.save(
+ saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
+ );
+ expect(result).toMatchObject({ status: 409 });
+ expect(result.data.error).toBeTruthy();
+ });
+});