diff --git a/.specify/rtm.md b/.specify/rtm.md index 12b8a524..7f9558dc 100644 --- a/.specify/rtm.md +++ b/.specify/rtm.md @@ -106,3 +106,20 @@ | REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined` | Done | | REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | `frontend/src/lib/shared/utils/monthBuckets.ts` | `monthBuckets.spec.ts` (relocated) + eslint boundary + `grep lib/document` → zero | Done | | REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/timelineDensity.ts` | `timelineDensity.spec.ts#monthHistogram` | Done | +| REQ-001 | curator with WRITE_ALL granted access to /zeitstrahl/events/new + /[id]/edit | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#allows a curator with WRITE_ALL`, `[id]/edit/page.server.spec.ts#seeds the form with the event on an ok GET` | Done | +| REQ-002 | unauthenticated (null user) → 403 (null-user guard before groups deref) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#throws 403 for an unauthenticated (null) user`, `[id]/edit/page.server.spec.ts#throws 403 for an unauthenticated (null) user` | Done | +| REQ-003 | authenticated without WRITE_ALL → 403 | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (hasWriteAll) | `new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done | +| REQ-004 | valid create → POST + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (save), `lib/timeline/eventFormServer.ts#toEventRequest` | `new/page.server.spec.ts#posts a TimelineEventRequest and redirects on success` | Done | +| REQ-005 | valid edit → PUT + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#updates via PUT (with version) and redirects on success` | Done | +| REQ-006 | confirmed delete → DELETE + redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete), `lib/timeline/EventForm.svelte` (getConfirmService) | `[id]/edit/page.server.spec.ts#deletes via DELETE and redirects to the resolved target on success` | Done | +| REQ-007 | non-ok DELETE → surface mapped error, no redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete) | `[id]/edit/page.server.spec.ts#returns fail(status) and does not redirect when DELETE is not ok` | Done | +| REQ-008 | precision = RANGE → end-date field visible | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/EventForm.svelte` | `EventForm.svelte.spec.ts#reveals the end-date field when precision is RANGE`, `WhoWhenSection.svelte.spec.ts#reveals the end-date field when precision is RANGE` | Done | +| REQ-009 | precision ≠ RANGE → end-date hidden, eventDateEnd submitted null | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/eventFormServer.ts#parseEventForm` | `EventForm.svelte.spec.ts#hides the end-date field when precision is YEAR`, `new/page.server.spec.ts#sends eventDateEnd: null when precision is not RANGE` | Done | +| REQ-010 | blank title → localized required error, no nav, picker values preserved | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm`, `EventForm.svelte` | `EventForm.svelte.spec.ts#shows a required-field error when title is blank`, `new/page.server.spec.ts#returns fail(400) with preserved picker arrays on blank title` | Done | +| REQ-011 | blank title + date → both errors via per-field aria-invalid | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm` | `new/page.server.spec.ts#surfaces both title and date errors when both blank` | Done | +| REQ-012 | unknown/derived event id (non-ok GET) → 404, never blank create form | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (load) | `[id]/edit/page.server.spec.ts#throws 404 when the GET is not ok (unknown or derived id)` | Done | +| REQ-013 | 409 Conflict → generic conflict message, no redirect (no merge UI) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#maps a 409 conflict and does not redirect`, `new/page.server.spec.ts#maps the API error and does not redirect on a non-ok save (incl. 409)` | Done | +| REQ-014 | valid ?personId/?documentId prefill pre-selected; unknown id silently ignored | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (load Promise.all), `EventForm.svelte` | `new/page.server.spec.ts#preselects a valid person and ignores an unknown document`, `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done | +| REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget` | `new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID`, `#redirects to /persons/{id} when originPersonId is a valid UUID` | Done | +| REQ-016 | title/description/chip labels via default `{...}` escaping, never `{@html}` (CWE-79) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/EventForm.svelte` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero | Done | +| REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | `frontend/src/lib/person/PersonMultiSelect.svelte`, `document/DocumentMultiSelect.svelte`, `EventForm.svelte` | `PersonMultiSelect.svelte.spec.ts`, `DocumentMultiSelect.svelte.spec.ts` (green post-44px fix), `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done | diff --git a/CLAUDE.md b/CLAUDE.md index 19998f96..e8f766aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,6 +208,7 @@ frontend/src/routes/ ├── geschichten/ Stories — list, [id], [id]/edit, new ├── stammbaum/ Family tree (Stammbaum) ├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode) +│ └── events/ Curator event editor (WRITE_ALL-gated) — new (create) + [id]/edit (edit + delete); reuses lib/timeline/EventForm ├── themen/ Topics directory — browsable tag index ├── enrich/ Enrichment workflow — [id], done ├── admin/ User, group, tag, OCR, system management diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml index 6ba983ff..cf0e2ffb 100644 --- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml +++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml @@ -15,6 +15,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.") Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.") Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.") + Component(zeitstrahlEvents, "/zeitstrahl/events/new and /zeitstrahl/events/[id]/edit", "SvelteKit Routes", "Curator event editor (WRITE_ALL-gated via server load, 403 error page). One lib/timeline/EventForm for both routes: title, EventTypeSelect (PERSONAL/HISTORICAL segmented radio), shared DatePrecisionField (RANGE reveals end date), plain-text description, PersonMultiSelect + DocumentMultiSelect. New: ?personId/?documentId prefill via Promise.all (404/403 swallowed), POST /api/timeline/events. Edit: load seeds from GET /api/timeline/events/{id} (404 on any non-ok — fails closed against derived events), PUT (optimistic-lock version) + DELETE behind ConfirmDialog. Context-aware redirect via UUID-validated originPersonId.") Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.") Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.") Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.") @@ -30,6 +31,7 @@ Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/g Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON") Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser") Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON") +Rel(zeitstrahlEvents, backend, "GET /api/timeline/events/{id}, POST /api/timeline/events, PUT/DELETE /api/timeline/events/{id}, GET /api/persons/{id} + /api/documents/{id} (prefill)", "HTTP / JSON") Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON") Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON") Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON") diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 3d367b4d..17823ad6 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -34,7 +34,7 @@ src/ │ ├── api/ # Internal API proxies (server-side only) │ ├── geschichten/ # Stories (list, [id], [id]/edit, new) │ ├── stammbaum/ # Family tree -│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline +│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline; events/new + events/[id]/edit curator editor (WRITE_ALL-gated) │ ├── enrich/ # Enrichment workflow ([id], done) │ ├── hilfe/transkription/ # Transcription help page │ ├── profile/ # User profile settings diff --git a/frontend/e2e/document-title-autosync.spec.ts b/frontend/e2e/document-title-autosync.spec.ts index 2f1bcb91..fc276151 100644 --- a/frontend/e2e/document-title-autosync.spec.ts +++ b/frontend/e2e/document-title-autosync.spec.ts @@ -26,7 +26,7 @@ test.describe('Document auto-title sync (#726)', () => { // 3. Add a YEAR-precision date WITHOUT touching the title, then save. await page.locator('#documentDate').fill('15.01.1928'); - await page.locator('#metaDatePrecision').selectOption('YEAR'); + await page.locator('#documentDatePrecision').selectOption('YEAR'); await page.getByRole('button', { name: 'Speichern', exact: true }).click(); // 4. The detail page shows the regenerated title carrying the new year. diff --git a/frontend/e2e/zeitstrahl-event-editor.spec.ts b/frontend/e2e/zeitstrahl-event-editor.spec.ts new file mode 100644 index 00000000..12b8c2d6 --- /dev/null +++ b/frontend/e2e/zeitstrahl-event-editor.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; + +/** + * Curator timeline event editor (#781) — intentionally thin. The component + + * server specs carry the real regression coverage (they run in CI's "Unit & + * Component Tests" job); ci.yml does NOT invoke test:e2e today, so this file + * runs only locally/manually against the full Docker Compose stack. + * + * Three checks: one critical create journey (→ HTTP 200 on /zeitstrahl; the full + * "sees the event card" assertion depends on #7), one security counterpart + * (logged-out → 403), and one 320px no-overflow guarantee for the 60+ author + * audience. + */ + +const stamp = () => new Date().toISOString().replace(/[^0-9]/g, ''); + +test.describe('Curator creates a timeline event', () => { + test('fills the create form with precision RANGE and lands on /zeitstrahl (HTTP 200)', async ({ + page + }) => { + await page.goto('/zeitstrahl/events/new'); + + await page.getByLabel(/Titel/i).fill(`E2E Ereignis ${stamp()}`); + await page.getByRole('radio', { name: /Historisch/i }).click(); + + // Date + RANGE end date via the shared German dd.mm.yyyy inputs. + await page.locator('#eventDate').fill('01.04.1925'); + await page.locator('#eventDatePrecision').selectOption('RANGE'); + await expect(page.getByLabel('Enddatum')).toBeVisible(); + await page.locator('#eventDateEnd').fill('01.05.1925'); + + // Submitting redirects to the resolved nav target (/zeitstrahl) — assert the + // route responds 200, not a DOM card (card rendering is #7's concern). + await Promise.all([ + page.waitForURL(/\/zeitstrahl$/), + page.getByRole('button', { name: 'Speichern' }).click() + ]); + const response = await page.goto('/zeitstrahl'); + expect(response?.status()).toBe(200); + }); +}); + +test.describe('Logged-out user is blocked from the curator route', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('navigating to /zeitstrahl/events/new is blocked with 403', async ({ page }) => { + await page.goto('/zeitstrahl/events/new'); + // The load guard throws 403 before any form renders. + await expect(page.getByLabel(/Titel/i)).not.toBeVisible({ timeout: 5000 }); + await expect(page.getByText(/403|Zugriff verweigert|Forbidden/i)).toBeVisible({ + timeout: 5000 + }); + }); +}); + +test.describe('Responsive — 60+ author audience', () => { + test('no horizontal overflow on the create form at 320px', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 900 }); + await page.goto('/zeitstrahl/events/new'); + await expect(page.getByLabel(/Titel/i)).toBeVisible(); + + const scrollWidth = await page.evaluate(() => document.body.scrollWidth); + expect(scrollWidth).toBe(320); + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index b7d3975b..925b4f9f 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -199,7 +199,12 @@ export default defineConfig( { from: { type: 'user' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'notification' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } }, - { from: { type: 'timeline' }, allow: { to: { type: ['shared'] } } }, + // Timeline curator event editor selects persons and documents by + // design (mirrors the geschichte editor) — #781. + { + from: { type: 'timeline' }, + allow: { to: { type: ['shared', 'person', 'document'] } } + }, { from: { type: 'shared' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'routes' }, diff --git a/frontend/messages/de.json b/frontend/messages/de.json index ec66eced..97f2ad4a 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1046,6 +1046,32 @@ "timeline_derived_birth": "Geburt", "timeline_derived_death": "Tod", "timeline_derived_marriage": "Heirat", + "event_editor_new_title": "Neues Ereignis", + "event_editor_edit_title": "Ereignis bearbeiten", + "event_editor_section_when": "Wann", + "event_editor_section_persons": "Beteiligte Personen", + "event_editor_section_documents": "Verknüpfte Briefe", + "event_editor_section_description": "Beschreibung", + "event_editor_title_label": "Titel", + "event_editor_title_placeholder": "Titel des Ereignisses", + "event_editor_title_required": "Bitte einen Titel eingeben.", + "event_editor_date_required": "Bitte ein Datum eingeben.", + "event_editor_end_date_required": "Bitte ein Enddatum eingeben.", + "event_editor_type_label": "Typ", + "event_editor_persons_label": "Personen", + "event_editor_documents_label": "Briefe", + "event_editor_description_label": "Beschreibung", + "event_editor_description_placeholder": "Optionale Beschreibung", + "event_editor_persons_empty": "Noch keine Person verknüpft", + "event_editor_documents_empty": "Noch kein Dokument verknüpft", + "event_type_PERSONAL": "Persönlich", + "event_type_HISTORICAL": "Historisch", + "event_editor_save": "Speichern", + "event_editor_save_hint": "Ereignisse erscheinen im Zeitstrahl.", + "event_editor_delete": "Löschen", + "event_editor_delete_confirm_title": "Ereignis löschen?", + "event_editor_delete_confirm_body": "Dieses Ereignis wird dauerhaft entfernt.", + "event_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?", "error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.", "error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.", "error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 234437ad..e0adb2ed 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1046,6 +1046,32 @@ "timeline_derived_birth": "Birth", "timeline_derived_death": "Death", "timeline_derived_marriage": "Marriage", + "event_editor_new_title": "New event", + "event_editor_edit_title": "Edit event", + "event_editor_section_when": "When", + "event_editor_section_persons": "People involved", + "event_editor_section_documents": "Linked letters", + "event_editor_section_description": "Description", + "event_editor_title_label": "Title", + "event_editor_title_placeholder": "Event title", + "event_editor_title_required": "Please enter a title.", + "event_editor_date_required": "Please enter a date.", + "event_editor_end_date_required": "Please enter an end date.", + "event_editor_type_label": "Type", + "event_editor_persons_label": "People", + "event_editor_documents_label": "Letters", + "event_editor_description_label": "Description", + "event_editor_description_placeholder": "Optional description", + "event_editor_persons_empty": "No person linked yet", + "event_editor_documents_empty": "No document linked yet", + "event_type_PERSONAL": "Personal", + "event_type_HISTORICAL": "Historical", + "event_editor_save": "Save", + "event_editor_save_hint": "Events appear on the timeline.", + "event_editor_delete": "Delete", + "event_editor_delete_confirm_title": "Delete event?", + "event_editor_delete_confirm_body": "This event will be permanently removed.", + "event_editor_unsaved_changes": "You have unsaved changes — really leave?", "error_geschichte_not_found": "The story was not found.", "error_journey_item_not_found": "The journey item was not found.", "error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index aab54c4d..e67a3578 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1046,6 +1046,32 @@ "timeline_derived_birth": "Nacimiento", "timeline_derived_death": "Fallecimiento", "timeline_derived_marriage": "Matrimonio", + "event_editor_new_title": "Nuevo evento", + "event_editor_edit_title": "Editar evento", + "event_editor_section_when": "Cuándo", + "event_editor_section_persons": "Personas involucradas", + "event_editor_section_documents": "Cartas vinculadas", + "event_editor_section_description": "Descripción", + "event_editor_title_label": "Título", + "event_editor_title_placeholder": "Título del evento", + "event_editor_title_required": "Por favor, introduzca un título.", + "event_editor_date_required": "Por favor, introduzca una fecha.", + "event_editor_end_date_required": "Por favor, introduzca una fecha de fin.", + "event_editor_type_label": "Tipo", + "event_editor_persons_label": "Personas", + "event_editor_documents_label": "Cartas", + "event_editor_description_label": "Descripción", + "event_editor_description_placeholder": "Descripción opcional", + "event_editor_persons_empty": "Aún no hay ninguna persona vinculada", + "event_editor_documents_empty": "Aún no hay ningún documento vinculado", + "event_type_PERSONAL": "Personal", + "event_type_HISTORICAL": "Histórico", + "event_editor_save": "Guardar", + "event_editor_save_hint": "Los eventos aparecen en la cronología.", + "event_editor_delete": "Eliminar", + "event_editor_delete_confirm_title": "¿Eliminar evento?", + "event_editor_delete_confirm_body": "Este evento se eliminará de forma permanente.", + "event_editor_unsaved_changes": "Tienes cambios sin guardar — ¿salir de todos modos?", "error_geschichte_not_found": "No se encontró la historia.", "error_journey_item_not_found": "No se encontró el elemento del viaje.", "error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.", diff --git a/frontend/src/lib/document/DocumentMultiSelect.svelte b/frontend/src/lib/document/DocumentMultiSelect.svelte index c95b9cff..e736b13d 100644 --- a/frontend/src/lib/document/DocumentMultiSelect.svelte +++ b/frontend/src/lib/document/DocumentMultiSelect.svelte @@ -11,12 +11,21 @@ interface Props { selectedDocuments?: DocumentOption[]; placeholder?: string; hiddenInputName?: string; + /** Empty-state text shown inside the chip container when nothing is selected. */ + emptyLabel?: string; + /** id of the search input so a