diff --git a/frontend/src/lib/timeline/EventPill.svelte b/frontend/src/lib/timeline/EventPill.svelte
index 55f274f3..b8573119 100644
--- a/frontend/src/lib/timeline/EventPill.svelte
+++ b/frontend/src/lib/timeline/EventPill.svelte
@@ -10,9 +10,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* Centered axis pill for a derived life-event or a curated PERSONAL event
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling
* (REQ-018). An edit affordance shows only for a curated event with an eventId
- * (never derived, never null — REQ-008).
+ * (never derived, never null — REQ-008) and only for a curator who holds
+ * WRITE_ALL (`canWrite`, gate-closed by default — #842 REQ-005/007/008). The
+ * gate is UX only; the real boundary is the #781 route guard + backend permission.
*/
-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,7 +26,7 @@ const provenance = $derived(
// Provenance always shows; the date is an optional prefix so an undated event
// still reads "abgeleitet"/"kuratiert" (REQ-007).
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
-const canEdit = $derived(!entry.derived && entry.eventId != null);
+const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null);
diff --git a/frontend/src/lib/timeline/EventPill.svelte.spec.ts b/frontend/src/lib/timeline/EventPill.svelte.spec.ts
index 9a9ed577..82d48d6e 100644
--- a/frontend/src/lib/timeline/EventPill.svelte.spec.ts
+++ b/frontend/src/lib/timeline/EventPill.svelte.spec.ts
@@ -51,8 +51,9 @@ describe('EventPill', () => {
expect(srOnly?.textContent).toBe('Geburt');
});
- it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => {
+ it('shows an edit affordance for a curated PERSONAL event when canWrite is true (REQ-005)', () => {
render(EventPill, {
+ canWrite: true,
entry: makeEntry({
kind: 'EVENT',
derived: false,
@@ -66,11 +67,45 @@ describe('EventPill', () => {
});
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
expect(edit).not.toBeNull();
- expect(edit?.getAttribute('href')).toContain(EVENT_ID);
+ expect(edit?.getAttribute('href')).toBe(`/zeitstrahl/events/${EVENT_ID}/edit`);
});
- it('shows no edit affordance when eventId is null (REQ-008)', () => {
+ it('renders no edit affordance for a curated PERSONAL event when canWrite is false (REQ-007)', () => {
render(EventPill, {
+ canWrite: false,
+ entry: makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'PERSONAL',
+ eventId: EVENT_ID,
+ title: 'Auswanderung',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ })
+ });
+ 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(EventPill, {
+ entry: makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'PERSONAL',
+ eventId: EVENT_ID,
+ title: 'Auswanderung',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ })
+ });
+ expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
+ });
+
+ it('shows no edit affordance when eventId is null even with canWrite (REQ-008)', () => {
+ render(EventPill, {
+ canWrite: true,
entry: makeEntry({
kind: 'EVENT',
derived: false,
@@ -85,8 +120,8 @@ describe('EventPill', () => {
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
- it('shows no edit affordance for a derived event (REQ-008)', () => {
- render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
+ it('shows no edit affordance for a derived event even with canWrite (REQ-008)', () => {
+ render(EventPill, { canWrite: true, entry: derived('MARRIAGE', 'Heirat') });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte
index a82a112a..ee37b682 100644
--- a/frontend/src/lib/timeline/TimelineView.svelte
+++ b/frontend/src/lib/timeline/TimelineView.svelte
@@ -19,7 +19,11 @@ type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
* for the per-person rail (issue #10) and is undefined here; it is not passed to
* leaf cards (REQ-025). Owns no
— the layout does.
*/
-let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props();
+let {
+ timeline,
+ personId = undefined,
+ canWrite = false
+}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props();
type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number };
@@ -50,7 +54,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
{#if row.t === 'band'}
-
+
{:else}
{/if}
@@ -77,7 +81,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
{#if entry.type === 'HISTORICAL'}
{:else}
-
+
{/if}
{:else}
diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts
index 6eccf324..f5f1187e 100644
--- a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts
+++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts
@@ -105,6 +105,7 @@ describe('TimelineView', () => {
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
render(TimelineView, {
+ canWrite: true,
timeline: makeTimelineDTO({
undated: [
makeEntry({
@@ -125,8 +126,8 @@ describe('TimelineView', () => {
// The event renders inside the undated section…
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
expect(document.body.textContent).toContain('Auswanderung');
- // …as an EventPill (its edit affordance), never as a letter card linking
- // to /documents/undefined with "Unbekannt → Unbekannt".
+ // …as an EventPill (its edit affordance, threaded canWrite), never as a
+ // letter card linking to /documents/undefined with "Unbekannt → Unbekannt".
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
expect(document.body.textContent).not.toContain('Unbekannt');
@@ -276,4 +277,39 @@ describe('TimelineView', () => {
);
expect(sides).toEqual(['left', 'right', 'left', 'right']);
});
+
+ // A curated PERSONAL event reachable through both dispatch paths: the year-band
+ // path (TimelineView → YearBand → EventPill) and the undated bucket
+ // (TimelineView → EventPill). canWrite must thread to both (REQ-009).
+ const curated = (eventId: string, title: string) =>
+ makeEntry({
+ kind: 'EVENT',
+ type: 'PERSONAL',
+ derived: false,
+ eventId,
+ title,
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ });
+
+ const bothPaths = () =>
+ makeTimelineDTO({
+ years: [makeYear(1924, [curated('banded', 'Umzug nach Berlin')])],
+ undated: [curated('undated', 'Auswanderung')]
+ });
+
+ it('threads canWrite to a curated event in both a year band and the undated bucket (REQ-009)', () => {
+ render(TimelineView, { canWrite: true, timeline: bothPaths() });
+ const hrefs = Array.from(document.querySelectorAll('[data-testid="event-edit"]')).map((a) =>
+ a.getAttribute('href')
+ );
+ expect(hrefs).toContain('/zeitstrahl/events/banded/edit');
+ expect(hrefs).toContain('/zeitstrahl/events/undated/edit');
+ });
+
+ it('renders no edit links in either path when canWrite is false (REQ-007/009)', () => {
+ render(TimelineView, { canWrite: false, timeline: bothPaths() });
+ expect(document.querySelectorAll('[data-testid="event-edit"]')).toHaveLength(0);
+ });
});
diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte
index 6efa30d8..7a62147f 100644
--- a/frontend/src/lib/timeline/YearBand.svelte
+++ b/frontend/src/lib/timeline/YearBand.svelte
@@ -16,7 +16,7 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that
* (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003).
*/
-let { year }: { year: TimelineYearDTO } = $props();
+let { year, canWrite = false }: { year: TimelineYearDTO; canWrite?: boolean } = $props();
type Row =
| { t: 'event'; entry: TimelineEntryDTO }
@@ -61,7 +61,7 @@ const rows = $derived.by(() => {
{#if row.entry.type === 'HISTORICAL'}
{:else}
-
+
{/if}
{:else if row.t === 'letter'}