feat(#447): permission-gated reader dashboard #477

Merged
marcel merged 25 commits from worktree-feat+issue-447-reader-dashboard into main 2026-05-08 15:56:54 +02:00
35 changed files with 1219 additions and 56 deletions

View File

@@ -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) {
}

View File

@@ -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());
}
}

View File

@@ -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");
};
}

View File

@@ -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
}

View File

@@ -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(
@@ -77,8 +81,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<Geschichte> spec = Specification.allOf(
GeschichteSpecifications.hasStatus(effective),
GeschichteSpecifications.hasAuthor(authorId),
GeschichteSpecifications.hasAllPersons(personIds),
GeschichteSpecifications.hasDocument(documentId),
GeschichteSpecifications.orderByDisplayDateDesc()

View File

@@ -42,6 +42,12 @@ public final class GeschichteSpecifications {
};
}
// null authorId → no restriction (PUBLISHED path passes null; Spring Data skips null predicates)
public static Specification<Geschichte> hasAuthor(UUID authorId) {
return (root, query, cb) ->
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
}
public static Specification<Geschichte> hasDocument(UUID documentId) {
return (root, query, cb) -> {
if (documentId == null) return null;

View File

@@ -35,7 +35,14 @@ public class PersonController {
@GetMapping
@RequirePermission(Permission.READ_ALL)
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
public ResponseEntity<List<PersonSummaryDTO>> 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) {
int safeSize = Math.min(size, 50);
return ResponseEntity.ok(personService.findTopByDocumentCount(safeSize));
}
return ResponseEntity.ok(personService.findAll(q));
}

View File

@@ -69,6 +69,22 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
nativeQuery = true)
List<PersonSummaryDTO> 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,
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<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);
// --- Correspondent queries ---
@Query(value = """

View File

@@ -41,6 +41,10 @@ public class PersonService {
return personRepository.searchWithDocumentCount(q.trim());
}
public List<PersonSummaryDTO> 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));

View File

@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC);

View File

@@ -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())

View File

@@ -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);

View File

@@ -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<Pageable> 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

View File

@@ -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("<p>private</p>");
geschichteService.create(dto);
authenticateAs(writer2, Permission.BLOG_WRITE);
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
assertThat(result).isEmpty();
}
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle(title);

View File

@@ -81,6 +81,29 @@ 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"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_capsTopByDocumentCount_atFifty() throws Exception {
ArgumentCaptor<Integer> 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(); }

View File

@@ -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`.

View File

@@ -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`.

View File

@@ -448,6 +448,20 @@
"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_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",
"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_reader_doc_count_suffix": "Dok.",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument",
"doc_status_placeholder": "Platzhalter",

View File

@@ -448,6 +448,20 @@
"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_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",
"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_reader_doc_count_suffix": "docs.",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document",
"doc_status_placeholder": "Placeholder",

View File

@@ -448,6 +448,20 @@
"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_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",
"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_reader_doc_count_suffix": "docs.",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido",
"doc_status_placeholder": "Marcador",

View File

@@ -2122,9 +2122,11 @@ export interface components {
};
StatsDTO: {
/** Format: int64 */
totalPersons?: number;
totalPersons: number;
/** Format: int64 */
totalDocuments?: number;
totalDocuments: number;
/** Format: int64 */
totalStories: number;
};
PersonSummaryDTO: {
title?: string;
@@ -2973,6 +2975,8 @@ export interface operations {
parameters: {
query?: {
q?: string;
size?: number;
sort?: string;
};
header?: never;
path?: never;
@@ -4801,7 +4805,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 */

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
interface Props {
drafts: Geschichte[];
}
const { drafts }: Props = $props();
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_drafts_heading()}
</h2>
{#if drafts.length === 0}
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
{:else}
<ul class="flex flex-col gap-2">
{#each drafts as draft (draft.id)}
<li>
<a
href="/geschichten/{draft.id}/edit"
class="flex min-h-[44px] items-center justify-between gap-4 rounded-sm py-2 transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="text-ink-1 truncate font-serif text-sm">{draft.title}</span>
<span class="shrink-0 font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(draft.updatedAt))}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>

View File

@@ -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();
});
});

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import * as m from '$lib/paraglide/messages.js';
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);
return Math.abs(hash);
}
function personAvatarColor(id: string): string {
return AVATAR_PALETTE[djb2(id) % AVATAR_PALETTE.length];
}
function getInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '';
if (words.length === 1) return words[0].charAt(0).toUpperCase();
return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
}
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
interface Props {
persons: PersonSummaryDTO[];
}
const { persons }: Props = $props();
</script>
<div class="flex flex-col gap-4">
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_person_chips_heading()}
</h2>
{#if persons.length === 0}
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_no_persons()}</p>
{/if}
<div class="flex flex-wrap gap-2">
{#each persons as p (p.id)}
<a
href="/persons/{p.id}"
class="flex min-h-[44px] items-center gap-2 rounded-sm border border-line bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style="background-color: {personAvatarColor(p.id ?? '')}"
>
{getInitials(p.displayName ?? p.lastName ?? '')}
</span>
<span class="flex min-w-0 flex-col">
<span class="text-ink-1 truncate font-serif text-sm">{p.displayName ?? p.lastName}</span>
<span class="font-sans text-xs text-ink-3"
>{p.documentCount ?? 0} {m.dashboard_reader_doc_count_suffix()}</span
>
</span>
</a>
{/each}
</div>
<a
href="/persons"
class="inline-flex min-h-[44px] items-center self-end rounded-sm font-sans text-sm text-brand-navy underline hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
>{m.dashboard_reader_all_persons()}</a
>
</div>

View File

@@ -0,0 +1,87 @@
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('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('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/ });
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();
});
});

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Document = components['schemas']['Document'];
interface Props {
documents: Document[];
}
const { documents }: Props = $props();
function isNew(doc: Document): boolean {
return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime();
}
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_recent_docs_heading()}
</h2>
<ul class="flex flex-col divide-y divide-line">
{#each documents as doc (doc.id)}
<li class="py-3 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<a
href="/documents/{doc.id}"
class="text-ink-1 truncate rounded-sm font-serif text-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
{doc.title}
</a>
{#if isNew(doc)}
<span
class="rounded bg-brand-mint/20 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide text-brand-navy uppercase"
>
{m.dashboard_badge_new()}
</span>
{:else}
<span
class="text-ink-1 rounded bg-ink-3/10 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide uppercase"
>
{m.dashboard_badge_updated()}
</span>
{/if}
</div>
{#if doc.sender}
<a
href="/persons/{doc.sender.id}"
class="font-sans text-xs text-ink-3 transition-colors hover:text-brand-mint"
>
{doc.sender.displayName ?? doc.sender.lastName}
</a>
{/if}
</div>
<span class="shrink-0 font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(doc.updatedAt))}
</span>
</div>
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,94 @@
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('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);
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,
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');
});
});

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
interface Props {
stories: Geschichte[];
}
const { stories }: Props = $props();
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '');
}
function excerpt(body: string | undefined): string {
if (!body) return '';
const text = stripHtml(body);
if (text.length <= 150) return text;
return text.slice(0, 150) + '…';
}
</script>
{#if stories.length > 0}
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_recent_stories_heading()}
</h2>
<ul class="flex flex-col divide-y divide-line">
{#each stories as story (story.id)}
<li class="py-4 first:pt-0 last:pb-0">
<a
href="/geschichten/{story.id}"
class="flex flex-col gap-1 rounded-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="text-ink-1 font-serif text-base italic">{story.title}</span>
{#if story.body}
<p class="line-clamp-2 font-sans text-xs text-ink-3">{excerpt(story.body)}</p>
{/if}
<span class="font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(story.publishedAt ?? story.updatedAt))}
</span>
</a>
</li>
{/each}
</ul>
<a
href="/geschichten"
class="mt-4 inline-flex min-h-[44px] items-center rounded-sm font-sans text-sm text-brand-navy underline hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
>
{m.dashboard_reader_all_stories()}
</a>
</div>
{/if}

View File

@@ -0,0 +1,75 @@
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: '<p>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.</p>',
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: '<p>' + 'A'.repeat(200) + '</p>',
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');
});
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/);
});
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\]/);
});
});

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
documents: number | null;
persons: number | null;
stories: number | null;
}
const { documents, persons, stories }: Props = $props();
</script>
<div class="hidden gap-4 sm:flex">
<a
href="/documents"
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-2xl font-bold text-brand-navy">{documents ?? '—'}</span>
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
>{m.dashboard_reader_stats_documents()}</span
>
</a>
<a
href="/persons"
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-2xl font-bold text-brand-navy">{persons ?? '—'}</span>
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
>{m.dashboard_reader_stats_persons()}</span
>
</a>
<a
href="/geschichten"
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-2xl font-bold text-brand-navy">{stories ?? '—'}</span>
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
>{m.dashboard_reader_stats_stories()}</span
>
</a>
</div>

View File

@@ -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('—');
});
});

View File

@@ -9,8 +9,21 @@ 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 }) {
function settled<T>(res: PromiseSettledResult<unknown> | 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();
// 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);
try {
@@ -20,6 +33,43 @@ export async function load({ fetch }) {
throw redirect(302, '/login');
}
if (isReader) {
const readerFetches: Promise<unknown>[] = [
api.GET('/api/stats'),
api.GET('/api/persons', { params: { query: { size: 4, sort: 'documentCount' } } }),
api.GET('/api/documents/search', {
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);
const readerStats = settled<StatsDTO>(statsRes);
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
const recentDocs = searchData?.items.map((i) => i.document) ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
return {
isReader: true as const,
canBlogWrite,
readerStats,
topPersons,
recentDocs,
recentStories,
drafts,
error: null as string | null
};
}
const [
statsResult,
resumeResult,
@@ -87,6 +137,7 @@ export async function load({ fetch }) {
}
return {
isReader: false as const,
stats,
resumeDoc,
pulse,
@@ -103,6 +154,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 +165,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
};
}

View File

@@ -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(() => {
</div>
{/if}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
{#if data.isReader}
<div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<EnrichmentBlock
topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0}
bannerCount={bannerCount}
onBannerClose={() => (bannerCount = 0)}
<ReaderStatsStrip
documents={data.readerStats?.totalDocuments ?? null}
persons={data.readerStats?.totalPersons ?? null}
stories={data.readerStats?.totalStories ?? null}
/>
<section aria-label={m.dashboard_mission_caption()}>
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_mission_caption()}
</h2>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
</section>
</div>
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} />
<DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
{#if data.canBlogWrite}
<ReaderDraftsModule drafts={data.drafts ?? []} />
{/if}
<ReaderPersonChips persons={data.topPersons ?? []} />
<div class="flex flex-col gap-5 md:flex-row">
<div class="flex-[3]">
<ReaderRecentDocs documents={data.recentDocs ?? []} />
</div>
<div class="flex-[2]">
<ReaderRecentStories stories={data.recentStories ?? []} />
</div>
</div>
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<EnrichmentBlock
topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0}
bannerCount={bannerCount}
onBannerClose={() => (bannerCount = 0)}
/>
<section aria-label={m.dashboard_mission_caption()}>
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_mission_caption()}
</h2>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
</section>
</div>
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} />
<DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
{/if}
</div>
</div>
{/if}
</main>

View File

@@ -19,6 +19,10 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[0]);
expect(result.activityFeed).toEqual([]);
});
@@ -201,7 +230,11 @@ describe('home page load — auth redirect', () => {
} as ReturnType<typeof createApiClient>);
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<typeof load>[0])
).rejects.toMatchObject({ location: '/login' });
});
});
@@ -214,8 +247,167 @@ describe('home page load — network error fallback', () => {
GET: vi.fn().mockRejectedValue(new Error('Network failure'))
} as ReturnType<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<typeof load>[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<typeof load>[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/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
>);
await load({
url: makeUrl(),
fetch: vi.fn() as unknown as typeof fetch,
parent: vi
.fn()
.mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false })
} as Parameters<typeof load>[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/search');
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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[0]);
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<typeof load>[0]);
expect(result.isReader).toBe(true);
if (result.isReader) {
expect(result.topPersons).toEqual([]);
expect(result.readerStats?.totalDocuments).toBe(5);
}
});
});

View File

@@ -10,16 +10,19 @@ 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,
canBlogWrite: false,
@@ -31,6 +34,22 @@ const baseData = {
transcriptionDocs: [],
readyDocs: [],
weeklyStats: null,
incompleteDocs: [],
incompleteTotal: 0,
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
};
@@ -79,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();
});
});