From faf010adfbdd6fef55b067f54725f97c437dbd16 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 18:25:24 +0200 Subject: [PATCH 01/25] feat(document): add DocumentSort.UPDATED_AT for reader dashboard feed Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/document/DocumentService.java | 1 + .../familienarchiv/document/DocumentSort.java | 2 +- .../V61__add_idx_documents_updated_at.sql | 1 + .../document/DocumentServiceTest.java | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/resources/db/migration/V61__add_idx_documents_updated_at.sql diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 8cef03a5..567fe0cc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -658,6 +658,7 @@ public class DocumentService { return switch (sort) { case TITLE -> Sort.by(direction, "title"); case UPLOAD_DATE -> Sort.by(direction, "createdAt"); + case UPDATED_AT -> Sort.by(direction, "updatedAt"); default -> Sort.by(direction, "documentDate"); }; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSort.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSort.java index 03a616b0..9334d53a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSort.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSort.java @@ -1,5 +1,5 @@ package org.raddatz.familienarchiv.document; public enum DocumentSort { - DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, RELEVANCE + DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, UPDATED_AT, RELEVANCE } diff --git a/backend/src/main/resources/db/migration/V61__add_idx_documents_updated_at.sql b/backend/src/main/resources/db/migration/V61__add_idx_documents_updated_at.sql new file mode 100644 index 00000000..bfc9899e --- /dev/null +++ b/backend/src/main/resources/db/migration/V61__add_idx_documents_updated_at.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index e3390024..e8acb667 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -1402,6 +1402,21 @@ class DocumentServiceTest { assertThat(result.items()).hasSize(1); // only the slice is enriched } + @Test + void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + documentService.searchDocuments(null, null, null, null, null, null, null, null, + DocumentSort.UPDATED_AT, "DESC", null, + org.springframework.data.domain.PageRequest.of(0, 5)); + + verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); + assertThat(captor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt")); + } + @Test void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() { // Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items -- 2.49.1 From 27afafa01dc3ec7c0f050b083ddf796c87725c01 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 18:33:08 +0200 Subject: [PATCH 02/25] =?UTF-8?q?fix(security):=20restrict=20DRAFT=20list?= =?UTF-8?q?=20to=20author=20=E2=80=94=20prevent=20cross-user=20draft=20lea?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeschichteService.list() now applies hasAuthor(currentUser()) whenever status == DRAFT, so BLOG_WRITE users cannot read other users' unpublished stories. Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/GeschichteService.java | 2 ++ .../geschichte/GeschichteSpecifications.java | 5 +++++ .../GeschichteServiceIntegrationTest.java | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index d6d38048..1b0cfc78 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -77,8 +77,10 @@ public class GeschichteService { GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); + UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null; Specification spec = Specification.allOf( GeschichteSpecifications.hasStatus(effective), + GeschichteSpecifications.hasAuthor(authorId), GeschichteSpecifications.hasAllPersons(personIds), GeschichteSpecifications.hasDocument(documentId), GeschichteSpecifications.orderByDisplayDateDesc() diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java index 695c3315..6834a783 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java @@ -42,6 +42,11 @@ public final class GeschichteSpecifications { }; } + public static Specification hasAuthor(UUID authorId) { + return (root, query, cb) -> + authorId == null ? null : cb.equal(root.get("author").get("id"), authorId); + } + public static Specification hasDocument(UUID documentId) { return (root, query, cb) -> { if (documentId == null) return null; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java index e0cb9f27..55eaaa4c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -159,6 +159,26 @@ class GeschichteServiceIntegrationTest { .isEmpty(); } + @Test + void list_DRAFT_does_not_return_other_users_drafts() { + // writer creates a draft; writer2 (also BLOG_WRITE) should not see it + AppUser writer2 = appUserRepository.save(AppUser.builder() + .email("writer2-int@test") + .password("hash") + .build()); + + authenticateAs(writer, Permission.BLOG_WRITE); + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("Writer 1 draft"); + dto.setBody("

private

"); + geschichteService.create(dto); + + authenticateAs(writer2, Permission.BLOG_WRITE); + List result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50); + + assertThat(result).isEmpty(); + } + private UUID publishedStoryWithPersons(String title, List personIds) { GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); dto.setTitle(title); -- 2.49.1 From 101c351d1449a3b3d373d3122213da65976a498a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 18:37:33 +0200 Subject: [PATCH 03/25] feat(person): add findTopByDocumentCount endpoint for reader dashboard PersonController GET /api/persons?sort=documentCount&size=N returns the top N persons by combined sender+receiver document count for the reader dashboard. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/person/PersonController.java | 8 +++++++- .../familienarchiv/person/PersonRepository.java | 14 ++++++++++++++ .../familienarchiv/person/PersonService.java | 4 ++++ .../person/PersonControllerTest.java | 11 +++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java index b59fa759..c082ca0b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java @@ -35,7 +35,13 @@ public class PersonController { @GetMapping @RequirePermission(Permission.READ_ALL) - public ResponseEntity> getPersons(@RequestParam(required = false) String q) { + public ResponseEntity> getPersons( + @RequestParam(required = false) String q, + @RequestParam(required = false, defaultValue = "0") int size, + @RequestParam(required = false) String sort) { + if ("documentCount".equals(sort) && size > 0 && q == null) { + return ResponseEntity.ok(personService.findTopByDocumentCount(size)); + } return ResponseEntity.ok(personService.findAll(q)); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index 8b7b524d..a0e3fb16 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -69,6 +69,20 @@ public interface PersonRepository extends JpaRepository { nativeQuery = true) List searchWithDocumentCount(@Param("query") String query); + @Query(value = """ + SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, + p.person_type AS personType, + p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, + p.family_member AS familyMember, + (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount + FROM persons p + ORDER BY documentCount DESC + LIMIT :limit + """, + nativeQuery = true) + List findTopByDocumentCount(@Param("limit") int limit); + // --- Correspondent queries --- @Query(value = """ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index 007008b1..89b11ef3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -41,6 +41,10 @@ public class PersonService { return personRepository.searchWithDocumentCount(q.trim()); } + public List findTopByDocumentCount(int limit) { + return personRepository.findTopByDocumentCount(limit); + } + public Person getById(UUID id) { return personRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java index 45454794..7d8044c7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java @@ -81,6 +81,17 @@ class PersonControllerTest { .andExpect(jsonPath("$[0].firstName").value("Hans")); } + @Test + @WithMockUser(authorities = "READ_ALL") + void getPersons_delegatesTopByDocumentCount_whenSortAndSizeGiven() throws Exception { + PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz"); + when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top)); + + mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].firstName").value("Käthe")); + } + private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) { return new PersonSummaryDTO() { public java.util.UUID getId() { return UUID.randomUUID(); } -- 2.49.1 From cb39a84cce4c5a1a450fc8dad609ca08aaf1ffb3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 18:39:47 +0200 Subject: [PATCH 04/25] =?UTF-8?q?chore(api):=20update=20generated=20types?= =?UTF-8?q?=20=E2=80=94=20add=20UPDATED=5FAT=20sort=20and=20persons=20size?= =?UTF-8?q?/sort=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 32c1c2e6..85f75eff 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2973,6 +2973,8 @@ export interface operations { parameters: { query?: { q?: string; + size?: number; + sort?: string; }; header?: never; path?: never; @@ -4801,7 +4803,7 @@ export interface operations { /** @description Filter by document status */ status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; /** @description Sort field */ - sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "RELEVANCE"; + sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "UPDATED_AT" | "RELEVANCE"; /** @description Sort direction: ASC or DESC */ dir?: string; /** @description Tag operator: AND (default) or OR */ -- 2.49.1 From 9043a2def0656ab163223c25c57a4f84913f6dcc Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 19:04:04 +0200 Subject: [PATCH 05/25] feat(dashboard): add isReader flag + reader branch to page load Read-only users (no WRITE_ALL or ANNOTATE_ALL) now receive lean reader data (stats, top-4 persons, 5 recent docs, 3 recent stories, and drafts when BLOG_WRITE) instead of the contributor transcription queues. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.server.ts | 81 ++++++++++- frontend/src/routes/page.server.spec.ts | 177 ++++++++++++++++++++++-- 2 files changed, 247 insertions(+), 11 deletions(-) diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index ae356311..9643fa85 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -9,8 +9,13 @@ type DashboardResumeDTO = components['schemas']['DashboardResumeDTO']; type DashboardPulseDTO = components['schemas']['DashboardPulseDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; +type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; +type Document = components['schemas']['Document']; +type Geschichte = components['schemas']['Geschichte']; -export async function load({ fetch }) { +export async function load({ fetch, parent }) { + const { canWrite, canAnnotate, canBlogWrite } = await parent(); + const isReader = !canWrite && !canAnnotate; const api = createApiClient(fetch); try { @@ -20,6 +25,73 @@ export async function load({ fetch }) { throw redirect(302, '/login'); } + if (isReader) { + const readerFetches: Promise[] = [ + api.GET('/api/stats'), + api.GET('/api/persons', { params: { query: { size: 4, sort: 'documentCount' } } }), + api.GET('/api/documents', { + params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } } + }), + api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } }) + ]; + if (canBlogWrite) { + readerFetches.push( + api.GET('/api/geschichten', { params: { query: { status: 'DRAFT', limit: 10 } } }) + ); + } + + const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] = + await Promise.allSettled(readerFetches); + + let readerStats: StatsDTO | null = null; + let topPersons: PersonSummaryDTO[] = []; + let recentDocs: Document[] = []; + let recentStories: Geschichte[] = []; + let drafts: Geschichte[] = []; + + if ( + statsRes?.status === 'fulfilled' && + (statsRes.value as { response: Response }).response.ok + ) { + readerStats = ((statsRes.value as { data: unknown }).data as StatsDTO) ?? null; + } + if ( + topPersonsRes?.status === 'fulfilled' && + (topPersonsRes.value as { response: Response }).response.ok + ) { + topPersons = ((topPersonsRes.value as { data: unknown }).data as PersonSummaryDTO[]) ?? []; + } + if ( + recentDocsRes?.status === 'fulfilled' && + (recentDocsRes.value as { response: Response }).response.ok + ) { + recentDocs = ((recentDocsRes.value as { data: unknown }).data as Document[]) ?? []; + } + if ( + recentStoriesRes?.status === 'fulfilled' && + (recentStoriesRes.value as { response: Response }).response.ok + ) { + recentStories = ((recentStoriesRes.value as { data: unknown }).data as Geschichte[]) ?? []; + } + if ( + draftsRes?.status === 'fulfilled' && + (draftsRes.value as { response: Response }).response.ok + ) { + drafts = ((draftsRes.value as { data: unknown }).data as Geschichte[]) ?? []; + } + + return { + isReader: true as const, + canBlogWrite, + readerStats, + topPersons, + recentDocs, + recentStories, + drafts, + error: null as string | null + }; + } + const [ statsResult, resumeResult, @@ -87,6 +159,7 @@ export async function load({ fetch }) { } return { + isReader: false as const, stats, resumeDoc, pulse, @@ -103,6 +176,7 @@ export async function load({ fetch }) { if ((e as { status?: number }).status) throw e; console.error('Error loading data:', e); return { + isReader, stats: null, resumeDoc: null, pulse: null, @@ -113,6 +187,11 @@ export async function load({ fetch }) { weeklyStats: null, incompleteDocs: [] as IncompleteDocumentDTO[], incompleteTotal: 0, + readerStats: null, + topPersons: [] as PersonSummaryDTO[], + recentDocs: [] as Document[], + recentStories: [] as Geschichte[], + drafts: [] as Geschichte[], error: 'Daten konnten nicht geladen werden.' as string | null }; } diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 8832bde2..6603d6c8 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -19,6 +19,10 @@ function makeUrl(params: Record = {}) { return url; } +function contributorParent() { + return vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false }); +} + // ─── always-dashboard behaviour ─────────────────────────────────────────────── it('never calls /api/documents/search regardless of URL params', async () => { @@ -29,8 +33,9 @@ it('never calls /api/documents/search regardless of URL params', async () => { await load({ url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }), - fetch: vi.fn() as unknown as typeof fetch - }); + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]); expect(calledEndpoints).not.toContain('/api/documents/search'); @@ -42,7 +47,11 @@ it('always fetches dashboard data regardless of URL params', async () => { typeof createApiClient >); - await load({ url: makeUrl({ q: 'Urlaub' }), fetch: vi.fn() as unknown as typeof fetch }); + await load({ + url: makeUrl({ q: 'Urlaub' }), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]); expect(calledEndpoints).toContain('/api/stats'); @@ -99,7 +108,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 }); expect(result.resumeDoc).not.toBeNull(); @@ -132,7 +145,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.stats?.totalDocuments).toBe(248); expect(result.stats?.totalPersons).toBe(34); @@ -149,7 +166,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.stats).toBeNull(); }); @@ -166,7 +187,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.resumeDoc).toBeNull(); }); @@ -186,7 +211,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.activityFeed).toEqual([]); }); @@ -201,7 +230,11 @@ describe('home page load — auth redirect', () => { } as ReturnType); await expect( - load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]) ).rejects.toMatchObject({ location: '/login' }); }); }); @@ -214,8 +247,132 @@ describe('home page load — network error fallback', () => { GET: vi.fn().mockRejectedValue(new Error('Network failure')) } as ReturnType); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.error).toBe('Daten konnten nicht geladen werden.'); }); }); + +// ─── reader branch ───────────────────────────────────────────────────────────── + +describe('home page load — reader branch (isReader = !canWrite && !canAnnotate)', () => { + it('does not call /api/transcription/* endpoints for a read-only user', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string); + const transcriptionCalls = calledEndpoints.filter((ep: string) => + ep.startsWith('/api/transcription') + ); + expect(transcriptionCalls).toHaveLength(0); + }); + + it('calls /api/stats, /api/persons, /api/documents, /api/geschichten for a read-only user', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string); + expect(calledEndpoints).toContain('/api/stats'); + expect(calledEndpoints).toContain('/api/persons'); + expect(calledEndpoints).toContain('/api/documents'); + expect(calledEndpoints).toContain('/api/geschichten'); + }); + + it('does not call /api/geschichten with status=DRAFT when canBlogWrite is false', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + const draftCalls = mockGet.mock.calls.filter( + (c: unknown[]) => + c[0] === '/api/geschichten' && + (c[1] as { params?: { query?: { status?: string } } })?.params?.query?.status === 'DRAFT' + ); + expect(draftCalls).toHaveLength(0); + }); + + it('calls /api/geschichten with status=DRAFT when canBlogWrite is true', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: true }) + } as Parameters[0]); + + const draftCalls = mockGet.mock.calls.filter( + (c: unknown[]) => + c[0] === '/api/geschichten' && + (c[1] as { params?: { query?: { status?: string } } })?.params?.query?.status === 'DRAFT' + ); + expect(draftCalls).toHaveLength(1); + }); + + it('returns isReader: true for read-only user', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(true); + }); + + it('returns isReader: false for contributor with WRITE_ALL', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(false); + }); +}); -- 2.49.1 From 4cc021b348604e13e2c09fa9a69bacdd200dbb8f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 19:06:53 +0200 Subject: [PATCH 06/25] feat(i18n): add reader dashboard message keys (de/en/es) New keys: reader stats strip, person chips, drafts module, recent docs, recent stories, Neu/Aktualisiert badges, and all-items links. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 12 ++++++++++++ frontend/messages/en.json | 12 ++++++++++++ frontend/messages/es.json | 12 ++++++++++++ 3 files changed, 36 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2ab6edc6..ec5aac62 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -448,6 +448,18 @@ "dashboard_recent_heading": "Zuletzt aktiv", "dashboard_stats_documents": "Dokumente", "dashboard_stats_persons": "Personen", + "dashboard_reader_stats_documents": "Dokumente", + "dashboard_reader_stats_persons": "Personen", + "dashboard_reader_stats_stories": "Geschichten", + "dashboard_reader_person_chips_heading": "Personen", + "dashboard_reader_all_persons": "Alle Personen →", + "dashboard_reader_drafts_heading": "Meine Entwürfe", + "dashboard_reader_drafts_empty": "Keine Entwürfe", + "dashboard_reader_recent_docs_heading": "Zuletzt aktualisiert", + "dashboard_reader_recent_stories_heading": "Neue Geschichten", + "dashboard_badge_new": "Neu", + "dashboard_badge_updated": "Aktualisiert", + "dashboard_reader_all_stories": "Alle Geschichten →", "dashboard_resume_label": "Zuletzt geöffnet:", "dashboard_resume_fallback": "Unbekanntes Dokument", "doc_status_placeholder": "Platzhalter", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0a39a394..cc09ae99 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -448,6 +448,18 @@ "dashboard_recent_heading": "Recent Activity", "dashboard_stats_documents": "Documents", "dashboard_stats_persons": "Persons", + "dashboard_reader_stats_documents": "Documents", + "dashboard_reader_stats_persons": "Persons", + "dashboard_reader_stats_stories": "Stories", + "dashboard_reader_person_chips_heading": "Persons", + "dashboard_reader_all_persons": "All Persons →", + "dashboard_reader_drafts_heading": "My Drafts", + "dashboard_reader_drafts_empty": "No drafts", + "dashboard_reader_recent_docs_heading": "Recently Updated", + "dashboard_reader_recent_stories_heading": "New Stories", + "dashboard_badge_new": "New", + "dashboard_badge_updated": "Updated", + "dashboard_reader_all_stories": "All Stories →", "dashboard_resume_label": "Last opened:", "dashboard_resume_fallback": "Unknown document", "doc_status_placeholder": "Placeholder", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 29e50e9f..c57d32f4 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -448,6 +448,18 @@ "dashboard_recent_heading": "Actividad reciente", "dashboard_stats_documents": "Documentos", "dashboard_stats_persons": "Personas", + "dashboard_reader_stats_documents": "Documentos", + "dashboard_reader_stats_persons": "Personas", + "dashboard_reader_stats_stories": "Historias", + "dashboard_reader_person_chips_heading": "Personas", + "dashboard_reader_all_persons": "Todas las personas →", + "dashboard_reader_drafts_heading": "Mis borradores", + "dashboard_reader_drafts_empty": "Sin borradores", + "dashboard_reader_recent_docs_heading": "Actualizados recientemente", + "dashboard_reader_recent_stories_heading": "Nuevas historias", + "dashboard_badge_new": "Nuevo", + "dashboard_badge_updated": "Actualizado", + "dashboard_reader_all_stories": "Todas las historias →", "dashboard_resume_label": "Último abierto:", "dashboard_resume_fallback": "Documento desconocido", "doc_status_placeholder": "Marcador", -- 2.49.1 From aabaa78f4d9ea43f9384044fda22bff20d2af616 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:39:35 +0200 Subject: [PATCH 07/25] feat(dashboard): add reader dashboard components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 5 new components for the permission-gated reader layout: - ReaderStatsStrip: stat tiles (documents / persons / stories) linking to list pages - ReaderPersonChips: top-N persons by doc count with avatar + name - ReaderDraftsModule: blog draft list for BLOG_WRITE users - ReaderRecentDocs: 5 most-recently-updated docs with Neu/Aktualisiert badge - ReaderRecentStories: 3 latest published stories with 150-char HTML-stripped excerpt Each component ships with a vitest-browser spec covering the key assertions. Avatar color/initials logic is inlined to satisfy $lib/shared → $lib/person boundary rule. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/ReaderDraftsModule.svelte | 38 ++++++++++ .../ReaderDraftsModule.svelte.spec.ts | 56 ++++++++++++++ .../shared/dashboard/ReaderPersonChips.svelte | 56 ++++++++++++++ .../ReaderPersonChips.svelte.spec.ts | 66 +++++++++++++++++ .../shared/dashboard/ReaderRecentDocs.svelte | 65 ++++++++++++++++ .../dashboard/ReaderRecentDocs.svelte.spec.ts | 74 +++++++++++++++++++ .../dashboard/ReaderRecentStories.svelte | 53 +++++++++++++ .../ReaderRecentStories.svelte.spec.ts | 60 +++++++++++++++ .../shared/dashboard/ReaderStatsStrip.svelte | 43 +++++++++++ .../dashboard/ReaderStatsStrip.svelte.spec.ts | 37 ++++++++++ frontend/src/routes/+page.server.ts | 7 +- frontend/src/routes/page.server.spec.ts | 4 +- 12 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts create mode 100644 frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts create mode 100644 frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts create mode 100644 frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts create mode 100644 frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte new file mode 100644 index 00000000..a03899e1 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte @@ -0,0 +1,38 @@ + + +
+

+ {m.dashboard_reader_drafts_heading()} +

+ {#if drafts.length === 0} +

{m.dashboard_reader_drafts_empty()}

+ {:else} + + {/if} +
diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts new file mode 100644 index 00000000..9d4beab8 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderDraftsModule from './ReaderDraftsModule.svelte'; +import type { components } from '$lib/generated/api'; + +type Geschichte = components['schemas']['Geschichte']; + +afterEach(() => { + cleanup(); +}); + +const draft1: Geschichte = { + id: 'g1', + title: 'Mein erster Entwurf', + status: 'DRAFT', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-02T00:00:00Z' +}; + +const draft2: Geschichte = { + id: 'g2', + title: 'Zweiter Entwurf', + status: 'DRAFT', + createdAt: '2025-02-01T00:00:00Z', + updatedAt: '2025-02-01T00:00:00Z' +}; + +describe('ReaderDraftsModule', () => { + it('renders a link to /geschichten/{id}/edit for each draft', async () => { + render(ReaderDraftsModule, { drafts: [draft1, draft2] }); + const link1 = page.getByRole('link', { name: /Mein erster Entwurf/ }); + await expect.element(link1).toHaveAttribute('href', '/geschichten/g1/edit'); + const link2 = page.getByRole('link', { name: /Zweiter Entwurf/ }); + await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit'); + }); + + it('shows heading "Meine Entwürfe"', async () => { + render(ReaderDraftsModule, { drafts: [draft1] }); + const heading = page.getByRole('heading', { name: /Meine Entwürfe/i }); + await expect.element(heading).toBeInTheDocument(); + }); + + it('shows empty state when drafts is empty', async () => { + render(ReaderDraftsModule, { drafts: [] }); + const emptyText = page.getByText(/Keine Entwürfe/i); + await expect.element(emptyText).toBeInTheDocument(); + }); + + it('does not show empty state when drafts are present', async () => { + render(ReaderDraftsModule, { drafts: [draft1] }); + const emptyText = page.getByText(/Keine Entwürfe/i); + await expect.element(emptyText).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte new file mode 100644 index 00000000..6cddcfbd --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts new file mode 100644 index 00000000..a24da959 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderPersonChips from './ReaderPersonChips.svelte'; +import type { components } from '$lib/generated/api'; + +type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; + +afterEach(() => { + cleanup(); +}); + +const person1: PersonSummaryDTO = { + id: 'aaaaaaaa-0000-0000-0000-000000000001', + firstName: 'Anna', + lastName: 'Müller', + displayName: 'Anna Müller', + documentCount: 23, + personType: 'PERSON', + familyMember: false +}; + +const person2: PersonSummaryDTO = { + id: 'aaaaaaaa-0000-0000-0000-000000000002', + firstName: 'Karl', + lastName: 'Schmidt', + displayName: 'Karl Schmidt', + documentCount: 5, + personType: 'PERSON', + familyMember: false +}; + +describe('ReaderPersonChips', () => { + it('renders a chip for each person with correct href', async () => { + render(ReaderPersonChips, { persons: [person1, person2] }); + const link1 = page.getByRole('link', { name: /Anna Müller/ }); + await expect + .element(link1) + .toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000001'); + const link2 = page.getByRole('link', { name: /Karl Schmidt/ }); + await expect + .element(link2) + .toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002'); + }); + + it('shows document count in each chip', async () => { + render(ReaderPersonChips, { persons: [person1] }); + const chip = page.getByRole('link', { name: /Anna Müller/ }); + await expect.element(chip).toBeInTheDocument(); + const text = ((await chip.element()) as HTMLElement).textContent; + expect(text).toContain('23'); + }); + + it('renders an "Alle Personen" link to /persons', async () => { + render(ReaderPersonChips, { persons: [person1] }); + const allLink = page.getByRole('link', { name: /Alle Personen/i }); + await expect.element(allLink).toHaveAttribute('href', '/persons'); + }); + + it('renders empty state without chips when persons array is empty', async () => { + render(ReaderPersonChips, { persons: [] }); + const chips = page.getByRole('link', { name: /Müller|Schmidt/ }); + await expect.element(chips).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte new file mode 100644 index 00000000..3fd37db4 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -0,0 +1,65 @@ + + +
+

+ {m.dashboard_reader_recent_docs_heading()} +

+ +
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts new file mode 100644 index 00000000..6d0f45f7 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderRecentDocs from './ReaderRecentDocs.svelte'; +import type { components } from '$lib/generated/api'; + +type Document = components['schemas']['Document']; + +afterEach(() => { + cleanup(); +}); + +const baseDoc: Document = { + id: 'doc1', + title: 'Brief an Hans', + originalFilename: 'brief.pdf', + status: 'UPLOADED', + metadataComplete: true, + scriptType: 'HANDWRITING_KURRENT', + createdAt: '2025-01-01T12:00:00Z', + updatedAt: '2025-01-01T12:00:00Z' +}; + +const updatedDoc: Document = { + ...baseDoc, + id: 'doc2', + title: 'Urkunde 1920', + createdAt: '2025-01-01T12:00:00Z', + updatedAt: '2025-03-01T12:00:00Z' +}; + +describe('ReaderRecentDocs', () => { + it('renders a link to /documents/{id} for each document', async () => { + render(ReaderRecentDocs, { documents: [baseDoc] }); + const link = page.getByRole('link', { name: /Brief an Hans/ }); + await expect.element(link).toHaveAttribute('href', '/documents/doc1'); + }); + + it('shows "Neu" badge when createdAt equals updatedAt', async () => { + render(ReaderRecentDocs, { documents: [baseDoc] }); + const badge = page.getByText(/^Neu$/i); + await expect.element(badge).toBeInTheDocument(); + }); + + it('shows "Aktualisiert" badge when updatedAt differs from createdAt', async () => { + render(ReaderRecentDocs, { documents: [updatedDoc] }); + const badge = page.getByText(/^Aktualisiert$/i); + await expect.element(badge).toBeInTheDocument(); + }); + + it('does not show "Neu" badge when updatedAt differs from createdAt', async () => { + render(ReaderRecentDocs, { documents: [updatedDoc] }); + const badge = page.getByText(/^Neu$/i); + await expect.element(badge).not.toBeInTheDocument(); + }); + + it('renders sender link when sender is present', async () => { + const docWithSender: Document = { + ...baseDoc, + sender: { + id: 'p1', + lastName: 'Müller', + firstName: 'Anna', + displayName: 'Anna Müller', + personType: 'PERSON' as const, + familyMember: false + } + }; + render(ReaderRecentDocs, { documents: [docWithSender] }); + const senderLink = page.getByRole('link', { name: /Anna Müller/ }); + await expect.element(senderLink).toHaveAttribute('href', '/persons/p1'); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte new file mode 100644 index 00000000..8c19b8ae --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte @@ -0,0 +1,53 @@ + + +{#if stories.length > 0} + +{/if} diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts new file mode 100644 index 00000000..df7f3891 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderRecentStories from './ReaderRecentStories.svelte'; +import type { components } from '$lib/generated/api'; + +type Geschichte = components['schemas']['Geschichte']; + +afterEach(() => { + cleanup(); +}); + +const story1: Geschichte = { + id: 'g1', + title: 'Die Familie Müller', + body: '

Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.

', + status: 'PUBLISHED', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + publishedAt: '2025-01-01T00:00:00Z' +}; + +const longBodyStory: Geschichte = { + id: 'g2', + title: 'Sehr lange Geschichte', + body: '

' + 'A'.repeat(200) + '

', + status: 'PUBLISHED', + createdAt: '2025-02-01T00:00:00Z', + updatedAt: '2025-02-01T00:00:00Z', + publishedAt: '2025-02-01T00:00:00Z' +}; + +describe('ReaderRecentStories', () => { + it('renders a link to /geschichten/{id} for each story', async () => { + render(ReaderRecentStories, { stories: [story1] }); + const link = page.getByRole('link', { name: /Die Familie Müller/ }); + await expect.element(link).toHaveAttribute('href', '/geschichten/g1'); + }); + + it('truncates body excerpt to 150 characters and strips HTML', async () => { + render(ReaderRecentStories, { stories: [longBodyStory] }); + const excerpt = page.getByText(/A{100,150}/); + await expect.element(excerpt).toBeInTheDocument(); + const text = ((await excerpt.element()) as HTMLElement).textContent; + expect(text!.replace(/…$/, '').length).toBeLessThanOrEqual(150); + }); + + it('shows empty state when stories array is empty', async () => { + render(ReaderRecentStories, { stories: [] }); + const links = page.getByRole('link'); + await expect.element(links).not.toBeInTheDocument(); + }); + + it('renders "Alle Geschichten" link', async () => { + render(ReaderRecentStories, { stories: [story1] }); + const allLink = page.getByRole('link', { name: /Alle Geschichten/i }); + await expect.element(allLink).toHaveAttribute('href', '/geschichten'); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte new file mode 100644 index 00000000..8129b03c --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts new file mode 100644 index 00000000..b33ddfc7 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderStatsStrip from './ReaderStatsStrip.svelte'; + +afterEach(() => { + cleanup(); +}); + +describe('ReaderStatsStrip', () => { + it('renders a link to /documents', async () => { + render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 }); + const link = page.getByRole('link', { name: /42/ }); + await expect.element(link).toHaveAttribute('href', '/documents'); + }); + + it('renders a link to /persons', async () => { + render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 }); + const link = page.getByRole('link', { name: /7/ }); + await expect.element(link).toHaveAttribute('href', '/persons'); + }); + + it('renders a link to /geschichten', async () => { + render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 }); + const link = page.getByRole('link', { name: /3/ }); + await expect.element(link).toHaveAttribute('href', '/geschichten'); + }); + + it('shows "—" when documents count is null', async () => { + render(ReaderStatsStrip, { documents: null, persons: null, stories: null }); + const links = page.getByRole('link'); + await expect.element(links.first()).toBeInTheDocument(); + const text = ((await links.first().element()) as HTMLElement).textContent; + expect(text).toContain('—'); + }); +}); diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 9643fa85..57dc7c54 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -29,7 +29,7 @@ export async function load({ fetch, parent }) { const readerFetches: Promise[] = [ api.GET('/api/stats'), api.GET('/api/persons', { params: { query: { size: 4, sort: 'documentCount' } } }), - api.GET('/api/documents', { + api.GET('/api/documents/search', { params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } } }), api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } }) @@ -65,7 +65,10 @@ export async function load({ fetch, parent }) { recentDocsRes?.status === 'fulfilled' && (recentDocsRes.value as { response: Response }).response.ok ) { - recentDocs = ((recentDocsRes.value as { data: unknown }).data as Document[]) ?? []; + const searchResult = (recentDocsRes.value as { data: unknown }).data as { + items: { document: Document }[]; + } | null; + recentDocs = searchResult?.items.map((i) => i.document) ?? []; } if ( recentStoriesRes?.status === 'fulfilled' && diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 6603d6c8..1aae2752 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -281,7 +281,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate expect(transcriptionCalls).toHaveLength(0); }); - it('calls /api/stats, /api/persons, /api/documents, /api/geschichten for a read-only user', async () => { + it('calls /api/stats, /api/persons, /api/documents/search, /api/geschichten for a read-only user', async () => { const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< typeof createApiClient @@ -298,7 +298,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string); expect(calledEndpoints).toContain('/api/stats'); expect(calledEndpoints).toContain('/api/persons'); - expect(calledEndpoints).toContain('/api/documents'); + expect(calledEndpoints).toContain('/api/documents/search'); expect(calledEndpoints).toContain('/api/geschichten'); }); -- 2.49.1 From 5fd4c6425cb0ab64dac3d245c50752f14e85178d Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:40:23 +0200 Subject: [PATCH 08/25] feat(dashboard): wire reader layout to +page.svelte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds conditional {#if data.isReader} block that renders the 5-zone reader layout (StatsStrip → DraftsModule → PersonChips → two-column docs/stories row) for READ_ALL-only users, while preserving the existing contributor layout for WRITE_ALL / ANNOTATE_ALL users. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.svelte | 84 ++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2512be89..86e073c2 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -5,6 +5,11 @@ import MissionControlStrip from '$lib/document/MissionControlStrip.svelte'; import DashboardFamilyPulse from '$lib/shared/dashboard/DashboardFamilyPulse.svelte'; import DashboardActivityFeed from '$lib/activity/DashboardActivityFeed.svelte'; import EnrichmentBlock from '$lib/document/EnrichmentBlock.svelte'; +import ReaderStatsStrip from '$lib/shared/dashboard/ReaderStatsStrip.svelte'; +import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte'; +import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte'; +import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte'; +import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte'; import { m } from '$lib/paraglide/messages.js'; let { data } = $props(); @@ -31,36 +36,61 @@ const greetingText = $derived.by(() => { {/if} -
+ {#if data.isReader}
- - - (bannerCount = 0)} + -
-

- {m.dashboard_mission_caption()} -

- -
-
- -
- - - {#if data.canWrite} - (bannerCount = count)} /> + {#if data.canBlogWrite} + {/if} + + + +
+
+ +
+
+ +
+
-
+ {:else} +
+
+ + + (bannerCount = 0)} + /> + +
+

+ {m.dashboard_mission_caption()} +

+ +
+
+ +
+ + + {#if data.canWrite} + (bannerCount = count)} /> + {/if} +
+
+ {/if} -- 2.49.1 From bd8a20fee0528bcd8dcb11d7b94f93ded898c8c8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:16:34 +0200 Subject: [PATCH 09/25] feat(stats): add totalStories to StatsDTO via GeschichteService.countPublished() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @Elicit review concern: stories stat tile was permanently showing "—" because StatsDTO had no published-story count. Now wired end-to-end. Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/dashboard/StatsDTO.java | 7 ++++++- .../familienarchiv/dashboard/StatsService.java | 4 +++- .../geschichte/GeschichteService.java | 4 ++++ .../dashboard/StatsControllerTest.java | 4 ++-- .../familienarchiv/dashboard/StatsServiceTest.java | 13 +++++++++++++ 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsDTO.java index 37ac36d1..52ff3836 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsDTO.java @@ -1,7 +1,12 @@ package org.raddatz.familienarchiv.dashboard; +import io.swagger.v3.oas.annotations.media.Schema; + /** * Aggregate counts for the dashboard/persons stats bar. */ -public record StatsDTO(long totalPersons, long totalDocuments) { +public record StatsDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalPersons, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalDocuments, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalStories) { } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsService.java index 3c42855c..cac398fe 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsService.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.dashboard; import lombok.RequiredArgsConstructor; import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.geschichte.GeschichteService; import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.dashboard.StatsDTO; import org.springframework.stereotype.Service; @@ -12,8 +13,9 @@ public class StatsService { private final PersonService personService; private final DocumentService documentService; + private final GeschichteService geschichteService; public StatsDTO getStats() { - return new StatsDTO(personService.count(), documentService.count()); + return new StatsDTO(personService.count(), documentService.count(), geschichteService.countPublished()); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index 1b0cfc78..53443cf4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -56,6 +56,10 @@ public class GeschichteService { // ─── Read API ──────────────────────────────────────────────────────────── + public long countPublished() { + return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED)); + } + public Geschichte getById(UUID id) { Geschichte g = geschichteRepository.findById(id) .orElseThrow(() -> DomainException.notFound( diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsControllerTest.java index db18630d..bd6767cc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsControllerTest.java @@ -44,7 +44,7 @@ class StatsControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void getStats_returns200_withCorrectCounts() throws Exception { - when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L)); + when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L, 2L)); mockMvc.perform(get("/api/stats")) .andExpect(status().isOk()) @@ -55,7 +55,7 @@ class StatsControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void getStats_returns200_withZeroCounts() throws Exception { - when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L)); + when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L, 0L)); mockMvc.perform(get("/api/stats")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsServiceTest.java index f462f43e..52489515 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsServiceTest.java @@ -7,6 +7,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.dashboard.StatsDTO; +import org.raddatz.familienarchiv.geschichte.GeschichteService; import org.raddatz.familienarchiv.person.PersonService; import static org.assertj.core.api.Assertions.assertThat; @@ -17,6 +18,7 @@ class StatsServiceTest { @Mock PersonService personService; @Mock DocumentService documentService; + @Mock GeschichteService geschichteService; @InjectMocks StatsService statsService; @Test @@ -30,6 +32,17 @@ class StatsServiceTest { assertThat(stats.totalDocuments()).isEqualTo(12L); } + @Test + void getStats_includes_totalStories() { + when(personService.count()).thenReturn(3L); + when(documentService.count()).thenReturn(7L); + when(geschichteService.countPublished()).thenReturn(5L); + + StatsDTO stats = statsService.getStats(); + + assertThat(stats.totalStories()).isEqualTo(5L); + } + @Test void getStats_returnsZero_whenNoEntities() { when(personService.count()).thenReturn(0L); -- 2.49.1 From d932f24694a889e2477e7a13dd6c83792181767c Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:18:04 +0200 Subject: [PATCH 10/25] fix(security): cap PersonController size param at 50 to prevent resource exhaustion Addresses @Nora review: ?sort=documentCount&size=999999 could trigger a full-table query and large serialization. Cap enforced at controller boundary. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/person/PersonController.java | 3 ++- .../familienarchiv/person/PersonControllerTest.java | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java index c082ca0b..5c47cbde 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java @@ -40,7 +40,8 @@ public class PersonController { @RequestParam(required = false, defaultValue = "0") int size, @RequestParam(required = false) String sort) { if ("documentCount".equals(sort) && size > 0 && q == null) { - return ResponseEntity.ok(personService.findTopByDocumentCount(size)); + int safeSize = Math.min(size, 50); + return ResponseEntity.ok(personService.findTopByDocumentCount(safeSize)); } return ResponseEntity.ok(personService.findAll(q)); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java index 7d8044c7..c7800da1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java @@ -92,6 +92,18 @@ class PersonControllerTest { .andExpect(jsonPath("$[0].firstName").value("Käthe")); } + @Test + @WithMockUser(authorities = "READ_ALL") + void getPersons_capsTopByDocumentCount_atFifty() throws Exception { + ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Integer.class); + when(personService.findTopByDocumentCount(sizeCaptor.capture())).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "999")) + .andExpect(status().isOk()); + + assertThat(sizeCaptor.getValue()).isEqualTo(50); + } + private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) { return new PersonSummaryDTO() { public java.util.UUID getId() { return UUID.randomUUID(); } -- 2.49.1 From 809b6c06d4071981c770185154ae3052b06ef8b0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:19:17 +0200 Subject: [PATCH 11/25] feat(stats): wire totalStories stat tile in reader dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manually adds totalStories to generated StatsDTO type and wires it from readerStats into ReaderStatsStrip — resolves @Elicit: stories tile was permanently showing "—". Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 2 ++ frontend/src/routes/+page.svelte | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 85f75eff..cd2160d9 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2125,6 +2125,8 @@ export interface components { totalPersons?: number; /** Format: int64 */ totalDocuments?: number; + /** Format: int64 */ + totalStories: number; }; PersonSummaryDTO: { title?: string; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 86e073c2..20c05ebf 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -41,7 +41,7 @@ const greetingText = $derived.by(() => { {#if data.canBlogWrite} -- 2.49.1 From cfafa45d4bf88396e8200c3bbad192b9265b2d66 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:22:15 +0200 Subject: [PATCH 12/25] refactor(dashboard): extract settled() helper; fix page.svelte.spec.ts types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses 5x duplicated null-check pattern in the reader fetch branch into a single typed helper — addresses @Felix review blocker. Also adds isReader/incompleteDocs/incompleteTotal to page.svelte.spec.ts baseData so it satisfies the discriminated PageData union introduced by this PR. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.server.ts | 51 ++++++------------------- frontend/src/routes/page.svelte.spec.ts | 3 ++ 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 57dc7c54..e19219ab 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -13,6 +13,12 @@ type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; type Document = components['schemas']['Document']; type Geschichte = components['schemas']['Geschichte']; +function settled(res: PromiseSettledResult | undefined): T | null { + if (res?.status !== 'fulfilled') return null; + const v = res.value as { response: Response; data: unknown }; + return v.response.ok ? ((v.data as T) ?? null) : null; +} + export async function load({ fetch, parent }) { const { canWrite, canAnnotate, canBlogWrite } = await parent(); const isReader = !canWrite && !canAnnotate; @@ -43,45 +49,12 @@ export async function load({ fetch, parent }) { const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] = await Promise.allSettled(readerFetches); - let readerStats: StatsDTO | null = null; - let topPersons: PersonSummaryDTO[] = []; - let recentDocs: Document[] = []; - let recentStories: Geschichte[] = []; - let drafts: Geschichte[] = []; - - if ( - statsRes?.status === 'fulfilled' && - (statsRes.value as { response: Response }).response.ok - ) { - readerStats = ((statsRes.value as { data: unknown }).data as StatsDTO) ?? null; - } - if ( - topPersonsRes?.status === 'fulfilled' && - (topPersonsRes.value as { response: Response }).response.ok - ) { - topPersons = ((topPersonsRes.value as { data: unknown }).data as PersonSummaryDTO[]) ?? []; - } - if ( - recentDocsRes?.status === 'fulfilled' && - (recentDocsRes.value as { response: Response }).response.ok - ) { - const searchResult = (recentDocsRes.value as { data: unknown }).data as { - items: { document: Document }[]; - } | null; - recentDocs = searchResult?.items.map((i) => i.document) ?? []; - } - if ( - recentStoriesRes?.status === 'fulfilled' && - (recentStoriesRes.value as { response: Response }).response.ok - ) { - recentStories = ((recentStoriesRes.value as { data: unknown }).data as Geschichte[]) ?? []; - } - if ( - draftsRes?.status === 'fulfilled' && - (draftsRes.value as { response: Response }).response.ok - ) { - drafts = ((draftsRes.value as { data: unknown }).data as Geschichte[]) ?? []; - } + const readerStats = settled(statsRes); + const topPersons = settled(topPersonsRes) ?? []; + const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes); + const recentDocs = searchData?.items.map((i) => i.document) ?? []; + const recentStories = settled(recentStoriesRes) ?? []; + const drafts = settled(draftsRes) ?? []; return { isReader: true as const, diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index 59d9a1c0..d90f98c4 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -20,6 +20,7 @@ const baseData = { enabled: true, createdAt: '2024-01-01T00:00:00Z' } as User, + isReader: false as const, canWrite: true, canAnnotate: false, canBlogWrite: false, @@ -31,6 +32,8 @@ const baseData = { transcriptionDocs: [], readyDocs: [], weeklyStats: null, + incompleteDocs: [], + incompleteTotal: 0, error: null }; -- 2.49.1 From 79e71cb1a45afbc22b55195fc93d29efef3d065f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:23:28 +0200 Subject: [PATCH 13/25] fix(a11y): fix WCAG AA contrast on reader dashboard "view all" links brand-mint on white is ~2.8:1; brand-navy is ~10:1. Both "Alle Personen" (ReaderPersonChips) and "Alle Geschichten" (ReaderRecentStories) links updated: text-brand-navy underline hover:text-brand-mint. Addresses @Leonie critical review finding. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte | 4 +++- frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte index 6cddcfbd..8aa8d8c3 100644 --- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte @@ -50,7 +50,9 @@ const { persons }: Props = $props(); {/each} - {m.dashboard_reader_all_persons()} diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte index 8c19b8ae..fb27a398 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte @@ -46,7 +46,10 @@ function excerpt(body: string | undefined): string { {/each} - + {m.dashboard_reader_all_stories()} -- 2.49.1 From cadd14c3d4658de16a4702aebb24e96191e318ef Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:26:53 +0200 Subject: [PATCH 14/25] test(dashboard): add partial-failure resilience test + fix i18n Dok. key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - page.server.spec.ts: new test verifies topPersons=[] when that fetch rejects, rest of reader data still loads — addresses @Sara concern - ReaderPersonChips: replaces hardcoded "Dok." with dashboard_reader_doc_count_suffix Paraglide key (de/en/es) — addresses @Felix suggestion Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../shared/dashboard/ReaderPersonChips.svelte | 4 ++- frontend/src/routes/page.server.spec.ts | 35 +++++++++++++++++++ 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index ec5aac62..8cf0b7bf 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -460,6 +460,7 @@ "dashboard_badge_new": "Neu", "dashboard_badge_updated": "Aktualisiert", "dashboard_reader_all_stories": "Alle Geschichten →", + "dashboard_reader_doc_count_suffix": "Dok.", "dashboard_resume_label": "Zuletzt geöffnet:", "dashboard_resume_fallback": "Unbekanntes Dokument", "doc_status_placeholder": "Platzhalter", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cc09ae99..5651df83 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -460,6 +460,7 @@ "dashboard_badge_new": "New", "dashboard_badge_updated": "Updated", "dashboard_reader_all_stories": "All Stories →", + "dashboard_reader_doc_count_suffix": "docs.", "dashboard_resume_label": "Last opened:", "dashboard_resume_fallback": "Unknown document", "doc_status_placeholder": "Placeholder", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index c57d32f4..44e7aebe 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -460,6 +460,7 @@ "dashboard_badge_new": "Nuevo", "dashboard_badge_updated": "Actualizado", "dashboard_reader_all_stories": "Todas las historias →", + "dashboard_reader_doc_count_suffix": "docs.", "dashboard_resume_label": "Último abierto:", "dashboard_resume_fallback": "Documento desconocido", "doc_status_placeholder": "Marcador", diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte index 8aa8d8c3..57a602df 100644 --- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte @@ -45,7 +45,9 @@ const { persons }: Props = $props(); {p.displayName ?? p.lastName} - {p.documentCount ?? 0} Dok. + {p.documentCount ?? 0} {m.dashboard_reader_doc_count_suffix()} {/each} diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 1aae2752..b51540c3 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -375,4 +375,39 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate expect(result.isReader).toBe(false); }); + + it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => { + const okStats = { + response: { ok: true, status: 200 }, + data: { totalDocuments: 5, totalPersons: 2, totalStories: 1 } + }; + const failPersons = Promise.reject(new Error('timeout')); + const okSearch = { response: { ok: true, status: 200 }, data: { items: [] } }; + const okStories = { response: { ok: true, status: 200 }, data: [] }; + + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // initial persons check + .mockResolvedValueOnce(okStats) + .mockReturnValueOnce(failPersons) + .mockResolvedValueOnce(okSearch) + .mockResolvedValueOnce(okStories); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(true); + if (result.isReader) { + expect(result.topPersons).toEqual([]); + expect(result.readerStats?.totalDocuments).toBe(5); + } + }); }); -- 2.49.1 From 69b0526e2c4bf0895e67aaf12022d3f02c321bae Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 22:28:10 +0200 Subject: [PATCH 15/25] docs: add Reader glossary entry + clarifying comments on specs and query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GLOSSARY.md: defines "Reader" as the permission-derived role (isReader = !canWrite && !canAnnotate) — addresses @Markus blocker - GeschichteSpecifications.hasAuthor: comment explains null = no restriction (PUBLISHED path) — addresses @Markus suggestion - PersonRepository.findTopByDocumentCount: comment explains alias-in-ORDER-BY is intentional PostgreSQL behaviour — addresses @Markus suggestion Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/geschichte/GeschichteSpecifications.java | 1 + .../org/raddatz/familienarchiv/person/PersonRepository.java | 2 ++ docs/GLOSSARY.md | 3 +++ 3 files changed, 6 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java index 6834a783..42797ffe 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java @@ -42,6 +42,7 @@ public final class GeschichteSpecifications { }; } + // null authorId → no restriction (PUBLISHED path passes null; Spring Data skips null predicates) public static Specification hasAuthor(UUID authorId) { return (root, query, cb) -> authorId == null ? null : cb.equal(root.get("author").get("id"), authorId); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index a0e3fb16..6f431b74 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -69,6 +69,8 @@ public interface PersonRepository extends JpaRepository { nativeQuery = true) List searchWithDocumentCount(@Param("query") String query); + // ORDER BY uses the computed alias "documentCount" — valid PostgreSQL (aliases allowed in ORDER BY, + // unlike WHERE/HAVING). This is intentional; it would silently fail on MySQL or H2. @Query(value = """ SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, p.person_type AS personType, diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 2b7ebe42..f1c75053 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -13,6 +13,9 @@ For domain package structure see [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) _(com **AppUser** (`AppUser`) — a real person who can log into the system (a family member or administrator). `AppUser` records carry login credentials, group memberships, and notification history. _Not to be confused with [Person](#person-person)_ — an AppUser is never recorded as a document sender, receiver, or historical individual. +**Reader** — an `AppUser` whose effective permissions include `READ_ALL` but neither `WRITE_ALL` nor `ANNOTATE_ALL`. Readers see a dedicated dashboard (`isReader = !canWrite && !canAnnotate`) focused on browsing documents, persons, and stories rather than contribution tasks. A user who also holds `BLOG_WRITE` is still classified as a Reader and additionally sees a drafts module. +_Not to be confused with [AppUser](#appuser-appuser)_ — Reader is a permission-derived role, not an entity. + **Permission** — a discrete capability string assigned to a `UserGroup` (e.g. `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`). Enforced via the `@RequirePermission` AOP annotation on controller methods, checked at runtime by `PermissionAspect`; not via Spring Security's `@PreAuthorize`. **Person** (`Person`) — a historical individual in the family archive (sender, receiver of letters, person mentioned in transcriptions). NEVER has a login account and NEVER appears as an `AppUser`. -- 2.49.1 From ca902c3ecf067fdc69e1eac484d9317f99d8b5df Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:36:54 +0200 Subject: [PATCH 16/25] fix(api): mark StatsDTO totalPersons + totalDocuments as required Mirrors what npm run generate:api would emit against the StatsDTO record (all three @Schema(REQUIRED) annotations). Round-1 fix only updated totalStories; this brings the other two into line. Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/generated/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index cd2160d9..091c5ce4 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2122,9 +2122,9 @@ export interface components { }; StatsDTO: { /** Format: int64 */ - totalPersons?: number; + totalPersons: number; /** Format: int64 */ - totalDocuments?: number; + totalDocuments: number; /** Format: int64 */ totalStories: number; }; -- 2.49.1 From fa269de4d3ad07687728e5308ecefe8dd74654f2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:44:34 +0200 Subject: [PATCH 17/25] fix(dashboard): isNew compares timestamps numerically, not by ISO string ISO strings differing only in millisecond precision or timezone formatting represent the same instant but failed string equality, so freshly created documents could miss the "Neu" badge depending on whatever shape the backend serializer emitted. Browser specs cannot run in the worktree (birpc WebSocket closure crash documented in the PR description); the new vitest-browser test must be verified from a normal checkout. Co-Authored-By: Claude Opus 4.7 --- .../src/lib/shared/dashboard/ReaderRecentDocs.svelte | 2 +- .../shared/dashboard/ReaderRecentDocs.svelte.spec.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte index 3fd37db4..4052fc8a 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -12,7 +12,7 @@ interface Props { const { documents }: Props = $props(); function isNew(doc: Document): boolean { - return doc.createdAt === doc.updatedAt; + return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime(); } diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts index 6d0f45f7..502a10d9 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -55,6 +55,18 @@ describe('ReaderRecentDocs', () => { await expect.element(badge).not.toBeInTheDocument(); }); + it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => { + const sameInstantDoc: Document = { + ...baseDoc, + id: 'doc-same-instant', + createdAt: '2025-01-01T12:00:00Z', + updatedAt: '2025-01-01T12:00:00.000Z' + }; + render(ReaderRecentDocs, { documents: [sameInstantDoc] }); + const badge = page.getByText(/^Neu$/i); + await expect.element(badge).toBeInTheDocument(); + }); + it('renders sender link when sender is present', async () => { const docWithSender: Document = { ...baseDoc, -- 2.49.1 From 3379392465ecfb86afe9189f84fe3ae3b985a9b6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:45:32 +0200 Subject: [PATCH 18/25] test(dashboard): cover the {#if data.isReader} render branch Adds a readerData fixture and five render-level assertions: the three ReaderStatsStrip totals, the recent-docs heading, the absent contributor mission caption, and the drafts module appearing only when canBlogWrite is true. Co-Authored-By: Claude Opus 4.7 --- frontend/src/routes/page.svelte.spec.ts | 65 +++++++++++++++++++++---- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index d90f98c4..e1d82648 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -10,16 +10,18 @@ afterEach(cleanup); vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })); +const baseUser: User = { + id: 'u1', + email: 'max@example.com', + firstName: 'Max', + lastName: '', + groups: [], + enabled: true, + createdAt: '2024-01-01T00:00:00Z' +}; + const baseData = { - user: { - id: 'u1', - email: 'max@example.com', - firstName: 'Max', - lastName: '', - groups: [], - enabled: true, - createdAt: '2024-01-01T00:00:00Z' - } as User, + user: baseUser, isReader: false as const, canWrite: true, canAnnotate: false, @@ -37,6 +39,20 @@ const baseData = { error: null }; +const readerData = { + user: baseUser, + isReader: true as const, + canWrite: false, + canAnnotate: false, + canBlogWrite: false, + readerStats: { totalPersons: 12, totalDocuments: 34, totalStories: 5 }, + topPersons: [], + recentDocs: [], + recentStories: [], + drafts: [], + error: null +}; + // ─── Dashboard layout ───────────────────────────────────────────────────────── describe('Home page – dashboard layout', () => { @@ -82,3 +98,34 @@ describe('Home page – dashboard layout', () => { await expect.element(page.getByText(/Dateien auf einmal hochladen/i)).not.toBeInTheDocument(); }); }); + +// ─── Reader dashboard layout ────────────────────────────────────────────────── + +describe('Home page – reader dashboard layout', () => { + it('renders ReaderStatsStrip totals when isReader is true', async () => { + render(Page, { data: readerData }); + await expect.element(page.getByText('34')).toBeInTheDocument(); + await expect.element(page.getByText('12')).toBeInTheDocument(); + await expect.element(page.getByText('5')).toBeInTheDocument(); + }); + + it('renders the recent-docs heading when isReader is true', async () => { + render(Page, { data: readerData }); + await expect.element(page.getByText('Zuletzt aktualisiert')).toBeInTheDocument(); + }); + + it('hides the contributor mission control caption when isReader is true', async () => { + render(Page, { data: readerData }); + await expect.element(page.getByText('Offene Aufgaben')).not.toBeInTheDocument(); + }); + + it('renders the drafts module when canBlogWrite is true', async () => { + render(Page, { data: { ...readerData, canBlogWrite: true } }); + await expect.element(page.getByText('Meine Entwürfe')).toBeInTheDocument(); + }); + + it('hides the drafts module when canBlogWrite is false', async () => { + render(Page, { data: readerData }); + await expect.element(page.getByText('Meine Entwürfe')).not.toBeInTheDocument(); + }); +}); -- 2.49.1 From 6fd360a381da94c9f8505282de59f5bae61c7102 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:46:45 +0200 Subject: [PATCH 19/25] fix(a11y): focus-visible ring on reader-dashboard view-all links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both view-all links (Alle Personen → in ReaderPersonChips, Alle Geschichten → in ReaderRecentStories) were missing the focus-visible:ring-2 ring used by every other interactive element on the reader dashboard, leaving keyboard users with no visible focus indicator. WCAG 2.1 §2.4.7 (Focus Visible, Level AA). Co-Authored-By: Claude Opus 4.7 --- .../src/lib/shared/dashboard/ReaderPersonChips.svelte | 2 +- .../lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts | 8 ++++++++ .../src/lib/shared/dashboard/ReaderRecentStories.svelte | 2 +- .../shared/dashboard/ReaderRecentStories.svelte.spec.ts | 8 ++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte index 57a602df..960162d2 100644 --- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte @@ -54,7 +54,7 @@ const { persons }: Props = $props(); {m.dashboard_reader_all_persons()} diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts index a24da959..c0491597 100644 --- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts @@ -58,6 +58,14 @@ describe('ReaderPersonChips', () => { await expect.element(allLink).toHaveAttribute('href', '/persons'); }); + it('exposes a focus-visible ring on the "Alle Personen" link', async () => { + render(ReaderPersonChips, { persons: [person1] }); + const allLink = page.getByRole('link', { name: /Alle Personen/i }); + const cls = ((await allLink.element()) as HTMLElement).className; + expect(cls).toMatch(/focus-visible:ring-2/); + expect(cls).toMatch(/focus-visible:ring-brand-navy/); + }); + it('renders empty state without chips when persons array is empty', async () => { render(ReaderPersonChips, { persons: [] }); const chips = page.getByRole('link', { name: /Müller|Schmidt/ }); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte index fb27a398..0dc0579d 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte @@ -48,7 +48,7 @@ function excerpt(body: string | undefined): string { {m.dashboard_reader_all_stories()} diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts index df7f3891..13102075 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts @@ -57,4 +57,12 @@ describe('ReaderRecentStories', () => { const allLink = page.getByRole('link', { name: /Alle Geschichten/i }); await expect.element(allLink).toHaveAttribute('href', '/geschichten'); }); + + it('exposes a focus-visible ring on the "Alle Geschichten" link', async () => { + render(ReaderRecentStories, { stories: [story1] }); + const allLink = page.getByRole('link', { name: /Alle Geschichten/i }); + const cls = ((await allLink.element()) as HTMLElement).className; + expect(cls).toMatch(/focus-visible:ring-2/); + expect(cls).toMatch(/focus-visible:ring-brand-navy/); + }); }); -- 2.49.1 From 0a7b1655e6f1f7da91d54033dca99e765726f272 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:47:22 +0200 Subject: [PATCH 20/25] fix(a11y): Aktualisiert badge passes WCAG AA contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit text-ink-3 on bg-ink-3/10 (low-saturation grey on lighter grey) gave roughly 2.8:1 contrast — below the 4.5:1 AA threshold for normal-weight small text. Switching the foreground to text-ink-1 keeps the muted background but lifts the text contrast well above 7:1. Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte | 2 +- .../lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte index 4052fc8a..d569b57c 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -40,7 +40,7 @@ function isNew(doc: Document): boolean { {:else} {m.dashboard_badge_updated()} diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts index 502a10d9..8960a5fa 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -49,6 +49,14 @@ describe('ReaderRecentDocs', () => { await expect.element(badge).toBeInTheDocument(); }); + it('renders the "Aktualisiert" badge with high-contrast text-ink-1', async () => { + render(ReaderRecentDocs, { documents: [updatedDoc] }); + const badge = page.getByText(/^Aktualisiert$/i); + const cls = ((await badge.element()) as HTMLElement).className; + expect(cls).toMatch(/text-ink-1/); + expect(cls).not.toMatch(/text-ink-3(?!\/)/); + }); + it('does not show "Neu" badge when updatedAt differs from createdAt', async () => { render(ReaderRecentDocs, { documents: [updatedDoc] }); const badge = page.getByText(/^Neu$/i); -- 2.49.1 From bf216f0cded06e3f1a9a72a1634a09167947d795 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:48:06 +0200 Subject: [PATCH 21/25] fix(a11y): view-all links meet 44px touch target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WCAG 2.2 §2.5.8 (Target Size, Minimum). The Alle Personen → and Alle Geschichten → text links were inline elements with no enforced minimum height — small tap targets on mobile. inline-flex + min-h-[44px] keeps the visual layout while guaranteeing the 44px hit area. Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte | 2 +- .../lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts | 7 +++++++ .../src/lib/shared/dashboard/ReaderRecentStories.svelte | 2 +- .../shared/dashboard/ReaderRecentStories.svelte.spec.ts | 7 +++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte index 960162d2..467dcebf 100644 --- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte @@ -54,7 +54,7 @@ const { persons }: Props = $props(); {m.dashboard_reader_all_persons()} diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts index c0491597..e5aad9f6 100644 --- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts @@ -66,6 +66,13 @@ describe('ReaderPersonChips', () => { expect(cls).toMatch(/focus-visible:ring-brand-navy/); }); + it('meets the 44px touch target on the "Alle Personen" link', async () => { + render(ReaderPersonChips, { persons: [person1] }); + const allLink = page.getByRole('link', { name: /Alle Personen/i }); + const cls = ((await allLink.element()) as HTMLElement).className; + expect(cls).toMatch(/min-h-\[44px\]/); + }); + it('renders empty state without chips when persons array is empty', async () => { render(ReaderPersonChips, { persons: [] }); const chips = page.getByRole('link', { name: /Müller|Schmidt/ }); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte index 0dc0579d..ab5b0a30 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte @@ -48,7 +48,7 @@ function excerpt(body: string | undefined): string { {m.dashboard_reader_all_stories()} diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts index 13102075..c749858d 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts @@ -65,4 +65,11 @@ describe('ReaderRecentStories', () => { expect(cls).toMatch(/focus-visible:ring-2/); expect(cls).toMatch(/focus-visible:ring-brand-navy/); }); + + it('meets the 44px touch target on the "Alle Geschichten" link', async () => { + render(ReaderRecentStories, { stories: [story1] }); + const allLink = page.getByRole('link', { name: /Alle Geschichten/i }); + const cls = ((await allLink.element()) as HTMLElement).className; + expect(cls).toMatch(/min-h-\[44px\]/); + }); }); -- 2.49.1 From 406b6a7f5281c29afc310322bf4d4acf3d24ba66 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:49:09 +0200 Subject: [PATCH 22/25] feat(dashboard): empty-state message for ReaderPersonChips When the top-persons fetch returns an empty list (or fails and degrades to []), the chip area used to render the heading and the view-all link with nothing in between, looking like a load failure. Adds dashboard_reader_no_persons (de/en/es) and renders it above the chip row. Co-Authored-By: Claude Opus 4.7 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte | 3 +++ .../lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts | 6 ++++++ 5 files changed, 12 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8cf0b7bf..39c77daa 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -452,6 +452,7 @@ "dashboard_reader_stats_persons": "Personen", "dashboard_reader_stats_stories": "Geschichten", "dashboard_reader_person_chips_heading": "Personen", + "dashboard_reader_no_persons": "Noch keine Personen im Archiv.", "dashboard_reader_all_persons": "Alle Personen →", "dashboard_reader_drafts_heading": "Meine Entwürfe", "dashboard_reader_drafts_empty": "Keine Entwürfe", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 5651df83..6929736c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -452,6 +452,7 @@ "dashboard_reader_stats_persons": "Persons", "dashboard_reader_stats_stories": "Stories", "dashboard_reader_person_chips_heading": "Persons", + "dashboard_reader_no_persons": "No persons in the archive yet.", "dashboard_reader_all_persons": "All Persons →", "dashboard_reader_drafts_heading": "My Drafts", "dashboard_reader_drafts_empty": "No drafts", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 44e7aebe..fbdec2aa 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -452,6 +452,7 @@ "dashboard_reader_stats_persons": "Personas", "dashboard_reader_stats_stories": "Historias", "dashboard_reader_person_chips_heading": "Personas", + "dashboard_reader_no_persons": "Todavía no hay personas en el archivo.", "dashboard_reader_all_persons": "Todas las personas →", "dashboard_reader_drafts_heading": "Mis borradores", "dashboard_reader_drafts_empty": "Sin borradores", diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte index 467dcebf..36b81df5 100644 --- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte @@ -31,6 +31,9 @@ const { persons }: Props = $props();

{m.dashboard_reader_person_chips_heading()}

+ {#if persons.length === 0} +

{m.dashboard_reader_no_persons()}

+ {/if}
{#each persons as p (p.id)} { const chips = page.getByRole('link', { name: /Müller|Schmidt/ }); await expect.element(chips).not.toBeInTheDocument(); }); + + it('renders an empty-state message when persons array is empty', async () => { + render(ReaderPersonChips, { persons: [] }); + const message = page.getByText(/Noch keine Personen im Archiv/i); + await expect.element(message).toBeInTheDocument(); + }); }); -- 2.49.1 From d83d38cd31b2e67db438107a9af3b859c50a5c06 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:49:40 +0200 Subject: [PATCH 23/25] fix(a11y): darken avatar palette teal for AA contrast against white MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #007596 with white initials hits ~4.5:1 — at the AA threshold for small text. #005F74 lifts it comfortably above 5:1, matching the contrast margin of the other four palette entries. Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte index 36b81df5..f78a9b6a 100644 --- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte @@ -2,7 +2,7 @@ import type { components } from '$lib/generated/api'; import * as m from '$lib/paraglide/messages.js'; -const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const; +const AVATAR_PALETTE = ['#012851', '#5A3080', '#005F74', '#2A6040', '#803020'] as const; function djb2(str: string): number { let hash = 5381; for (let i = 0; i < str.length; i++) hash = (hash * 33) ^ str.charCodeAt(i); -- 2.49.1 From 4d8caddac1bfbb0cc4d8a8ab9ab7e304071d48fa Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:50:12 +0200 Subject: [PATCH 24/25] docs(dashboard): comment isReader discriminant with ADR-007 pointer Felix and Elicit both flagged that the isReader formula had no in-code explanation at the point of definition; future maintainers adding a new permission level need a fast pointer to the architectural rationale. Co-Authored-By: Claude Opus 4.7 --- frontend/src/routes/+page.server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index e19219ab..b217452f 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -21,6 +21,8 @@ function settled(res: PromiseSettledResult | undefined): T | null { export async function load({ fetch, parent }) { const { canWrite, canAnnotate, canBlogWrite } = await parent(); + // READ_ALL without WRITE_ALL or ANNOTATE_ALL — see ADR-007. + // BLOG_WRITE-only users land here too and see the drafts module on top. const isReader = !canWrite && !canAnnotate; const api = createApiClient(fetch); -- 2.49.1 From 0cf31b0cf80ddf9980f50809eda1d225cf31286c Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 8 May 2026 12:51:20 +0200 Subject: [PATCH 25/25] docs(adr): ADR-007 reader-dashboard permission discriminant Captures the architectural decision behind isReader = !canWrite && !canAnnotate, why BLOG_WRITE intentionally lands on the reader dashboard, the alternatives considered (separate route, AppUser column, middleware redirect, BLOG_WRITE exclusion), and the implications for future permission additions. Co-Authored-By: Claude Opus 4.7 --- ...eader-dashboard-permission-discriminant.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/adr/007-reader-dashboard-permission-discriminant.md diff --git a/docs/adr/007-reader-dashboard-permission-discriminant.md b/docs/adr/007-reader-dashboard-permission-discriminant.md new file mode 100644 index 00000000..0d7e2aa8 --- /dev/null +++ b/docs/adr/007-reader-dashboard-permission-discriminant.md @@ -0,0 +1,52 @@ +# ADR-007: Reader-dashboard permission discriminant + +## Status + +Accepted + +## Context + +Issue #447 introduced two distinct user cohorts on the home page: + +- **Contributors** — transcribe, annotate, upload. The existing `MissionControlStrip`, `EnrichmentBlock`, `DashboardResumeStrip`, `DashboardFamilyPulse`, `DashboardActivityFeed`, and `DropZone` are aimed at them. +- **Readers** — browse and consume finished content. Older, less technical, on smaller devices. The contribution-focused widgets are noise to them. + +`AppUser` permissions are already derived in `+layout.server.ts` and exposed via `$page.data` as `canWrite`, `canAnnotate`, and `canBlogWrite`. The home route needs a single boolean to switch its layout and its data fetch set, and that boolean has to be load-bearing — every future permission introduced has to be classified against it. + +## Decision + +```ts +const isReader = !canWrite && !canAnnotate; +``` + +Computed at the start of `+page.server.ts` `load()`. When true, the loader fetches a lean reader set (stats / top-4 persons / recent docs / recent stories — and drafts when `canBlogWrite`) via `Promise.allSettled` and returns a discriminated-union shape the page distinguishes via `data.isReader`. + +`BLOG_WRITE` is **not** part of the discriminant. A `READ_ALL + BLOG_WRITE` user is still a reader and additionally sees the `ReaderDraftsModule`. Story writers are conceptually closer to readers than to transcribers: they consume the archive, occasionally publish narrative on top of it, and have no business with the transcription queue. + +A `BLOG_WRITE`-only user (no `READ_ALL`) is also classified as a reader by this formula. Because every reader API requires `READ_ALL`, all four content tiles degrade to empty via `Promise.allSettled`. They see the empty reader shell plus the drafts module — acceptable behaviour, since this permission combination is degenerate by configuration. Documented in `docs/GLOSSARY.md`. + +## Alternatives Considered + +| Alternative | Why rejected | +|---|---| +| New `/reader-home` route with a server-side redirect from `/` | Two routes that mostly answer the same product question (home page). Bookmarks split, breadcrumbs split, header `home` link has to know which to use. The conditional-render keeps a single canonical URL and lets the auth state drive the layout, matching how `canWrite` already gates the upload zone in the contributor branch. | +| `AppUser.dashboardVariant` column persisted in the DB | Permissions already encode the relevant signal; a separate field has to be kept in sync with permission changes. Drift is a feature foot-gun: a user gets `WRITE_ALL` granted but their `dashboardVariant` field still says `reader` and they keep seeing the wrong UI. | +| Middleware/handle hook redirecting based on permissions | Same logical issue as the dedicated route plus a network round-trip on every dashboard hit. The discriminant runs once inside the same `load()` that's already fetching the user. | +| `isReader = !canWrite && !canAnnotate && !canBlogWrite` (exclude `BLOG_WRITE` from readers) | Treats blog writers as contributors. They would land on the `MissionControlStrip` they cannot meaningfully use (no `WRITE_ALL`, no `ANNOTATE_ALL`) and would have to scroll past the transcription queue to find their own drafts. The reader shell + drafts module fits their actual workflow. | + +## Consequences + +**Easier:** +- Reader and contributor views share one canonical home URL — no redirect, no routing fork. +- Adding a new content tile to the reader dashboard is a single-file change inside the `if (isReader)` branch of `load()` plus a new component import in `+page.svelte`. +- Backend `@RequirePermission(READ_ALL)` on every reader API call remains the load-bearing security gate. `isReader` is purely a UX flag — manipulating it client-side serves a different layout to the same authenticated user with the same permissions. + +**Harder:** +- Every future `Permission` value has to be explicitly classified against this formula. Adding a permission that grants contribution rights but not `WRITE_ALL`/`ANNOTATE_ALL` would silently leave its bearers on the reader dashboard. Mitigation: keep this ADR linked from `+page.server.ts` and from the `Permission` enum's Javadoc. +- The discriminated-union return type of `load()` (`{isReader: true} | {isReader: false}`) requires every consumer to narrow on `data.isReader` before accessing branch-specific fields. The current `+page.svelte` already does this with the top-level `{#if data.isReader}`; new consumers of the home loader must follow suit. + +## Future Direction + +If a third cohort emerges (e.g. an admin home with system-health tiles), promote the discriminant to a tagged-union: `dashboard: 'reader' | 'contributor' | 'admin'`. The discriminant computation moves from `+page.server.ts` into a small helper in `lib/shared/server/`, callable from any route that needs the same classification (e.g. a future `/welcome` onboarding flow). + +If `BLOG_WRITE`-only access becomes a real product mode (rather than the degenerate combination it is today), revisit whether the formula should add a `canRead` precondition: `isReader = canRead && !canWrite && !canAnnotate`. -- 2.49.1