From 11bcaf7cdbba988f097b0b5c17993a71ea1541e8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 08:08:14 +0200 Subject: [PATCH] feat(timeline): add a WRITE_ALL-gated edit pencil to WorldBand A curated HISTORICAL band had no edit affordance at all. Mirror the EventPill pencil inline at the end of the band (data-testid="event-edit", /edit href, aria-hidden glyph + sr-only Bearbeiten), gated on canWrite && eventId != null. Thread canWrite to WorldBand through both the year-band path and the undated bucket. Refs #842 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/TimelineView.svelte | 2 +- .../lib/timeline/TimelineView.svelte.spec.ts | 29 +++++++++++++++++ frontend/src/lib/timeline/WorldBand.svelte | 19 ++++++++++-- .../src/lib/timeline/WorldBand.svelte.spec.ts | 31 +++++++++++++++++++ frontend/src/lib/timeline/YearBand.svelte | 2 +- 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte index ee37b682..c3008e90 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte +++ b/frontend/src/lib/timeline/TimelineView.svelte @@ -79,7 +79,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
  • {#if entry.kind === 'EVENT'} {#if entry.type === 'HISTORICAL'} - + {:else} {/if} diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts index f5f1187e..6e5b42c0 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts +++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts @@ -312,4 +312,33 @@ describe('TimelineView', () => { render(TimelineView, { canWrite: false, timeline: bothPaths() }); expect(document.querySelectorAll('[data-testid="event-edit"]')).toHaveLength(0); }); + + it('threads canWrite to a curated HISTORICAL world band in both paths (REQ-006/009)', () => { + const world = (eventId: string, title: string) => + makeEntry({ + kind: 'EVENT', + type: 'HISTORICAL', + derived: false, + eventId, + precision: 'YEAR', + eventDate: '1929-01-01', + eventDateEnd: undefined, + title, + senderName: '', + receiverName: '', + documentId: undefined + }); + render(TimelineView, { + canWrite: true, + timeline: makeTimelineDTO({ + years: [makeYear(1929, [world('wb', 'Weltwirtschaftskrise')])], + undated: [world('wu', 'Unbekanntes Weltereignis')] + }) + }); + const hrefs = Array.from(document.querySelectorAll('[data-testid="event-edit"]')).map((a) => + a.getAttribute('href') + ); + expect(hrefs).toContain('/zeitstrahl/events/wb/edit'); + expect(hrefs).toContain('/zeitstrahl/events/wu/edit'); + }); }); diff --git a/frontend/src/lib/timeline/WorldBand.svelte b/frontend/src/lib/timeline/WorldBand.svelte index 5347d352..9dbab74f 100644 --- a/frontend/src/lib/timeline/WorldBand.svelte +++ b/frontend/src/lib/timeline/WorldBand.svelte @@ -11,9 +11,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; * (REQ-009). A RANGE carries a visible span pill ("1914–1918") with a Zeitraum * aria-label; a RANGE with no end degrades to the start year, no pill (REQ-010). * The glyph is a redundant non-color cue with an sr-only label (REQ-018); text - * uses text-ink-2 to stay AA in both themes (REQ-019). + * uses text-ink-2 to stay AA in both themes (REQ-019). A curator (`canWrite`, + * gate-closed by default) gets an inline edit pencil for a curated event with an + * eventId — #842 REQ-006/007/008; UX gate only, the #781 route guard is the boundary. */ -let { entry }: { entry: TimelineEntryDTO } = $props(); +let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props(); const config = $derived(getAccentConfig(entry)); const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd)); @@ -24,6 +26,9 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe // Every WorldBand is a HISTORICAL band, so the visible "historisch" register // always trails the subtitle as plain text — never a second pill (REQ-009). const historical = $derived(m.timeline_layer_historical_suffix()); +// A HISTORICAL event is never derived, so the edit gate is just the curator +// flag plus a real eventId (#842 REQ-006/008). +const canEdit = $derived(canWrite && entry.eventId != null);
    @@ -46,4 +51,14 @@ const historical = $derived(m.timeline_layer_historical_suffix()); · {historical} + {#if canEdit} + + + {m.btn_edit()} + + {/if}
    diff --git a/frontend/src/lib/timeline/WorldBand.svelte.spec.ts b/frontend/src/lib/timeline/WorldBand.svelte.spec.ts index 6af574e6..f620a44a 100644 --- a/frontend/src/lib/timeline/WorldBand.svelte.spec.ts +++ b/frontend/src/lib/timeline/WorldBand.svelte.spec.ts @@ -73,4 +73,35 @@ describe('WorldBand', () => { expect(pill?.textContent).not.toContain(m.timeline_layer_historical_suffix()); expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix()); }); + + const HIST_EVENT_ID = '44444444-4444-4444-4444-444444444444'; + + it('shows an edit affordance for a curated HISTORICAL event when canWrite is true (REQ-006)', () => { + render(WorldBand, { canWrite: true, entry: historical({ eventId: HIST_EVENT_ID }) }); + const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null; + expect(edit).not.toBeNull(); + expect(edit?.getAttribute('href')).toBe(`/zeitstrahl/events/${HIST_EVENT_ID}/edit`); + }); + + it('mirrors the EventPill pencil: aria-hidden ✎ glyph + sr-only Bearbeiten label (REQ-006)', () => { + render(WorldBand, { canWrite: true, entry: historical({ eventId: HIST_EVENT_ID }) }); + const edit = document.querySelector('[data-testid="event-edit"]'); + expect(edit?.querySelector('[aria-hidden="true"]')?.textContent).toBe('✎'); + expect(edit?.querySelector('.sr-only')?.textContent).toBe(m.btn_edit()); + }); + + it('renders no edit affordance for a curated HISTORICAL event when canWrite is false (REQ-007)', () => { + render(WorldBand, { canWrite: false, entry: historical({ eventId: HIST_EVENT_ID }) }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('renders no edit affordance when the canWrite prop is omitted (gate-closed default) (REQ-007)', () => { + render(WorldBand, { entry: historical({ eventId: HIST_EVENT_ID }) }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('shows no edit affordance when eventId is null even with canWrite (REQ-008)', () => { + render(WorldBand, { canWrite: true, entry: historical({ eventId: undefined }) }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); }); diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index 7a62147f..fafa0b4c 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -59,7 +59,7 @@ const rows = $derived.by(() => { {#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))} {#if row.t === 'event'} {#if row.entry.type === 'HISTORICAL'} - + {:else} {/if}