diff --git a/frontend/src/lib/timeline/EventForm.svelte b/frontend/src/lib/timeline/EventForm.svelte
index 1653d20e..755a2863 100644
--- a/frontend/src/lib/timeline/EventForm.svelte
+++ b/frontend/src/lib/timeline/EventForm.svelte
@@ -149,6 +149,11 @@ async function confirmDelete(e: SubmitEvent) {
}}
>
+ {#if event}
+
+
+ {/if}
diff --git a/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts b/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts
new file mode 100644
index 00000000..20bad3b7
--- /dev/null
+++ b/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts
@@ -0,0 +1,107 @@
+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';
+
+export async function load({
+ locals,
+ params,
+ url,
+ fetch
+}: {
+ locals: App.Locals;
+ params: { id: string };
+ 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 result = await api.GET('/api/timeline/events/{id}', {
+ params: { path: { id: params.id } }
+ });
+
+ // Fail closed: derived person-events (Geburt/Tod/Heirat) are not persisted and
+ // have no UUID, so the API 404s for them. Any non-ok response → 404; never
+ // render a blank editable form that silently POSTs a new event.
+ if (!result.response.ok) throw error(404, 'Not found');
+
+ return { event: result.data!, originPersonId: url.searchParams.get('personId') ?? '' };
+}
+
+export const actions = {
+ save: async ({
+ request,
+ params,
+ fetch
+ }: {
+ request: Request;
+ params: { id: string };
+ fetch: typeof globalThis.fetch;
+ }) => {
+ const formData = await request.formData();
+ const parsed = parseEventForm(formData);
+
+ const invalid = validateEventForm(parsed);
+ if (invalid) return invalid;
+
+ const versionRaw = formData.get('version')?.toString();
+ const version = versionRaw ? Number(versionRaw) : undefined;
+
+ const api = createApiClient(fetch);
+ const result = await api.PUT('/api/timeline/events/{id}', {
+ params: { path: { id: params.id } },
+ body: toEventRequest(parsed, version)
+ });
+
+ 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));
+ },
+
+ delete: async ({
+ request,
+ params,
+ fetch
+ }: {
+ request: Request;
+ params: { id: string };
+ fetch: typeof globalThis.fetch;
+ }) => {
+ const formData = await request.formData();
+ const originPersonId = formData.get('originPersonId')?.toString() ?? '';
+
+ const api = createApiClient(fetch);
+ const result = await api.DELETE('/api/timeline/events/{id}', {
+ params: { path: { id: params.id } }
+ });
+
+ if (!result.response.ok) {
+ return fail(result.response.status, {
+ error: getErrorMessage(extractErrorCode(result.error))
+ });
+ }
+
+ throw redirect(303, resolveNavTarget(originPersonId));
+ }
+};
diff --git a/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.svelte b/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.svelte
new file mode 100644
index 00000000..45f9348c
--- /dev/null
+++ b/frontend/src/routes/zeitstrahl/events/[id]/edit/+page.svelte
@@ -0,0 +1,8 @@
+
+
+
diff --git a/frontend/src/routes/zeitstrahl/events/[id]/edit/page.server.spec.ts b/frontend/src/routes/zeitstrahl/events/[id]/edit/page.server.spec.ts
new file mode 100644
index 00000000..8ae60729
--- /dev/null
+++ b/frontend/src/routes/zeitstrahl/events/[id]/edit/page.server.spec.ts
@@ -0,0 +1,155 @@
+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, id = 'e1') {
+ const url = new URL(`http://localhost/zeitstrahl/events/${id}/edit`);
+ return {
+ locals: localsWith(perms),
+ url,
+ params: { id },
+ fetch: mockFetch,
+ request: new Request(url),
+ route: { id: '/zeitstrahl/events/[id]/edit' }
+ } as never;
+}
+
+function actionEvent(
+ method: 'save' | 'delete',
+ fields: Record,
+ id = 'e1'
+) {
+ 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 {
+ request: new Request(`http://localhost/zeitstrahl/events/${id}/edit?/${method}`, {
+ method: 'POST',
+ body: fd
+ }),
+ params: { id },
+ fetch: mockFetch,
+ route: { id: '/zeitstrahl/events/[id]/edit' }
+ } as never;
+}
+
+const EVENT_VIEW = {
+ id: 'e1',
+ title: 'Umzug',
+ type: 'PERSONAL',
+ eventDate: '1925-04-01',
+ precision: 'DAY',
+ version: 2,
+ createdBy: 'u1',
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedBy: 'u1',
+ updatedAt: '2026-01-01T00:00:00Z',
+ persons: [],
+ documents: []
+};
+
+describe('zeitstrahl/events/[id]/edit 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 a user without WRITE_ALL', async () => {
+ await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
+ });
+});
+
+describe('zeitstrahl/events/[id]/edit load — fail closed (REQ-012)', () => {
+ it('throws 404 when the GET is not ok (unknown or derived id)', async () => {
+ vi.mocked(createApiClient).mockReturnValue({
+ GET: vi.fn().mockResolvedValue({ response: { ok: false, status: 404 }, data: undefined })
+ } as never);
+ await expect(load(loadEvent(['WRITE_ALL']))).rejects.toMatchObject({ status: 404 });
+ });
+
+ it('seeds the form with the event on an ok GET', async () => {
+ vi.mocked(createApiClient).mockReturnValue({
+ GET: vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW })
+ } as never);
+ const result = await load(loadEvent(['WRITE_ALL']));
+ expect(result.event).toMatchObject({ id: 'e1', title: 'Umzug' });
+ });
+});
+
+describe('zeitstrahl/events/[id]/edit save action (REQ-005/013)', () => {
+ it('updates via PUT (with version) and redirects on success', async () => {
+ const put = vi
+ .fn()
+ .mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW });
+ vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
+
+ await expect(
+ actions.save(
+ actionEvent('save', {
+ title: 'Umzug II',
+ type: 'PERSONAL',
+ eventDate: '1925-04-01',
+ version: '2'
+ })
+ )
+ ).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
+
+ expect(put).toHaveBeenCalledTimes(1);
+ expect(put.mock.calls[0][1].params.path.id).toBe('e1');
+ expect(put.mock.calls[0][1].body).toMatchObject({ title: 'Umzug II', version: 2 });
+ });
+
+ it('maps a 409 conflict and does not redirect', async () => {
+ const put = vi
+ .fn()
+ .mockResolvedValue({ response: { ok: false, status: 409 }, error: { code: 'CONFLICT' } });
+ vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
+
+ const result = await actions.save(
+ actionEvent('save', { title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
+ );
+ expect(result).toMatchObject({ status: 409 });
+ expect(result.data.error).toBeTruthy();
+ });
+});
+
+describe('zeitstrahl/events/[id]/edit delete action (REQ-006/007)', () => {
+ const validUuid = '22222222-2222-2222-2222-222222222222';
+
+ it('deletes via DELETE and redirects to the resolved target on success', async () => {
+ const del = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 } });
+ vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
+
+ await expect(
+ actions.delete(actionEvent('delete', { originPersonId: validUuid }))
+ ).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
+ expect(del.mock.calls[0][1].params.path.id).toBe('e1');
+ });
+
+ it('returns fail(status) and does not redirect when DELETE is not ok', async () => {
+ const del = vi
+ .fn()
+ .mockResolvedValue({ response: { ok: false, status: 500 }, error: { code: 'INTERNAL' } });
+ vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
+
+ const result = await actions.delete(actionEvent('delete', {}));
+ expect(result).toMatchObject({ status: 500 });
+ expect(result.data.error).toBeTruthy();
+ });
+});