From 709a9d622447c3a6ad1c588108eba2e03404dcc7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:56:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(stammbaum):=20/stammbaum=20page=20?= =?UTF-8?q?=E2=80=94=20SVG=20tree=20+=20side=20panel=20+=20empty=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /stammbaum/+page.server.ts loads GET /api/network (already filtered to family members on the backend) and returns nodes + edges. - +page.svelte holds the page shell, manages selectedId (with ?focus={id} deep-link support) and zoom state, renders the empty state when nodes.length === 0 (icon + heading + body + link to /persons), or the tree + side panel otherwise. - StammbaumTree.svelte: BFS-based generation assignment from roots, spouses promoted to the deeper generation so couples sit on the same row, alphabetical sort within row, simple grid layout. SVG nodes are role="button" + aria-label="{name}, {birth}–{death}" + aria-expanded={selected}, with click + Enter/Space activation. Solid parent→child connectors; mint spouse line with midpoint circle, dashed if SPOUSE_OF.toYear is set (former spouse). Zoom maps to viewBox. - StammbaumSidePanel.svelte: lazily loads /api/persons/{id}/relationships and /inferred-relationships when the selection changes; shows direct chips (mint), top-5 derived chips (grey), and a "Zur Personenseite →" link. Escape closes the panel. Refs #358. Co-Authored-By: Claude Sonnet 4.6 --- frontend/CLAUDE.md | 197 +++++++++++++++ frontend/e2e/CLAUDE.md | 141 +++++++++++ .../lib/components/StammbaumSidePanel.svelte | 160 ++++++++++++ .../src/lib/components/StammbaumTree.svelte | 239 ++++++++++++++++++ frontend/src/routes/stammbaum/+page.server.ts | 18 ++ frontend/src/routes/stammbaum/+page.svelte | 110 ++++++++ 6 files changed, 865 insertions(+) create mode 100644 frontend/CLAUDE.md create mode 100644 frontend/e2e/CLAUDE.md create mode 100644 frontend/src/lib/components/StammbaumSidePanel.svelte create mode 100644 frontend/src/lib/components/StammbaumTree.svelte create mode 100644 frontend/src/routes/stammbaum/+page.server.ts create mode 100644 frontend/src/routes/stammbaum/+page.svelte diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 00000000..551a1f34 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,197 @@ +# Frontend — Familienarchiv + +## Overview + +SvelteKit 2 application providing the Familienarchiv web UI. Server-side rendered (SSR) where beneficial, with client-side interactivity for document viewing, transcription, annotation, and admin workflows. + +## Tech Stack + +- **Framework**: SvelteKit 2 with Svelte 5 (runes mode) +- **Language**: TypeScript 5.9 +- **Styling**: Tailwind CSS 4.1 + custom brand utilities +- **Build Tool**: Vite 7 +- **Adapter**: `@sveltejs/adapter-node` (Node.js server, not static) +- **i18n**: Paraglide.js 2.5 (`@inlang/paraglide-js`) — German (default), English, Spanish +- **API Client**: `openapi-fetch` + `openapi-typescript` (generated from backend OpenAPI spec) +- **PDF Rendering**: `pdfjs-dist` (PDF.js) +- **Testing**: + - Unit/Server: Vitest 4 (Node environment) + - Component: Vitest Browser Mode with Playwright (Chromium) + - E2E: Playwright (`frontend/e2e/`) + +## Project Structure + +``` +src/ +├── routes/ # SvelteKit file-based routing +│ ├── +layout.svelte # Global layout: header, nav, auth state +│ ├── +layout.server.ts # Loads current user, injects auth cookie +│ ├── +page.svelte # Home / document search dashboard +│ ├── documents/ # Document CRUD, detail, edit, upload +│ ├── persons/ # Person directory, detail, edit, merge +│ ├── briefwechsel/ # Bilateral conversation timeline +│ ├── chronik/ # Unified activity feed +│ ├── admin/ # User, group, tag, OCR, system management +│ ├── api/ # Internal API proxies (server-side only) +│ ├── login/ logout/ # Auth pages +│ └── ... +├── lib/ +│ ├── components/ # Reusable Svelte components +│ │ ├── document/ # Document-specific components +│ │ ├── chronik/ # Activity feed components +│ │ └── user/ # User-related components +│ ├── generated/ # Auto-generated API types (openapi-typescript) +│ ├── server/ # Server-only utilities (db, auth helpers) +│ ├── services/ # Client-side service logic +│ ├── stores/ # Svelte stores (global state) +│ ├── types.ts # Shared TypeScript types +│ ├── errors.ts # Error code mapping (mirrors backend ErrorCode) +│ ├── api.server.ts # Typed API client factory +│ ├── utils.ts # Shared utilities +│ ├── relativeTime.ts # Time formatting +│ ├── search.ts # Search utilities +│ └── paraglide/ # Generated i18n code +├── hooks/ # SvelteKit hooks (handle, handleFetch) +└── actions/ # Custom Svelte actions (click outside, etc.) +``` + +## API Client Pattern + +All server-side API calls use the typed client from `$lib/api.server.ts`: + +```typescript +const api = createApiClient(fetch); +const result = await api.GET('/api/persons/{id}', { params: { path: { id } } }); + +// Always check via response.ok, NOT result.error +if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + throw error(result.response.status, getErrorMessage(code)); +} +return { person: result.data! }; +``` + +Key rules: + +- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined) +- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code +- Use `result.data!` after an ok check + +For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`. + +## Form Actions Pattern + +```typescript +// +page.server.ts +export const actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + // ... + return fail(400, { error: 'message' }); // on error + throw redirect(303, '/target'); // on success + } +}; +``` + +## Date Handling + +- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `` sends ISO to the backend. +- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one: + ```typescript + new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format( + new Date(doc.documentDate + 'T12:00:00') + ); + ``` + +## Styling Conventions (Tailwind CSS 4) + +Brand color utilities (defined in `layout.css`): + +| Class | Value | Usage | +| ------------ | --------- | -------------------------------- | +| `brand-navy` | `#002850` | Primary text, buttons, headers | +| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons | +| `brand-sand` | `#E4E2D7` | Page background, card borders | + +Typography: + +- `font-serif` (Merriweather) — body text, document titles, names +- `font-sans` (Montserrat) — labels, metadata, UI chrome + +Card pattern for content sections: + +```svelte +
+

Section

+ +
+``` + +## Key UI Components + +| Component | Props | Description | +| -------------------- | ---------------------------------------------------- | ------------------------------------- | +| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead | +| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector | +| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead | +| `PdfViewer` | `url`, `annotations`, `on:annotation` | PDF rendering with annotation overlay | +| `TranscriptionBlock` | `block`, `mode` | Read/edit transcription block | +| `DocumentTopBar` | `document` | Responsive document metadata header | + +## How to Run + +### Development + +```bash +cd frontend +npm install +npm run dev # Dev server on port 5173 (or 3000 if --port 3000) +``` + +### Build & Preview + +```bash +npm run build # Production build +npm run preview # Preview production build +``` + +### Code Quality + +```bash +npm run lint # Prettier + ESLint check +npm run format # Auto-fix formatting +npm run check # svelte-check (type checking) +``` + +### Testing + +```bash +npm run test # Vitest unit + server tests (headless) +npm run test:coverage # Coverage report (server project only) +npm run test:e2e # Playwright E2E tests +npm run test:e2e:headed # Playwright E2E with visible browser +npm run test:e2e:ui # Playwright UI mode +``` + +### Regenerate API Types + +Requires backend running with `--spring.profiles.active=dev`: + +```bash +npm run generate:api +``` + +## Vite Proxy + +During development, `/api` calls are proxied to the Spring Boot backend. The proxy injects the `Authorization` header from the `auth_token` cookie automatically (see `vite.config.ts`). + +## i18n (Paraglide) + +Translations live in `messages/{de,en,es}.json`. The compiler generates type-safe helpers in `src/lib/paraglide/`. Run compilation manually with: + +```bash +npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide +``` + +Or let the Vite plugin handle it automatically during dev/build. diff --git a/frontend/e2e/CLAUDE.md b/frontend/e2e/CLAUDE.md new file mode 100644 index 00000000..b3e4c4a4 --- /dev/null +++ b/frontend/e2e/CLAUDE.md @@ -0,0 +1,141 @@ +# E2E Tests — Familienarchiv + +## Overview + +End-to-end tests for the Familienarchiv frontend using Playwright. These tests verify complete user flows across the full stack (SvelteKit frontend + Spring Boot backend + PostgreSQL + MinIO). + +## Tech Stack + +- **Test Runner**: Playwright (`@playwright/test`) +- **Browser**: Chromium (desktop) +- **Locale**: `de-DE` (ensures German language detection) +- **Auth**: Shared session cookie stored after setup + +## Project Structure + +``` +frontend/e2e/ +├── auth.setup.ts # Authentication setup — logs in and saves session +├── auth.spec.ts # Authentication flows (login, logout, register) +├── admin.spec.ts # Admin panel CRUD operations +├── annotations.spec.ts # Document annotation features +├── bottom-panel.spec.ts # Bottom panel / transcription panel +├── dashboard-*.spec.ts # Dashboard variants and screenshots +├── documents.spec.ts # Document upload, edit, search +├── focus-rings.spec.ts # Accessibility focus ring tests +├── header.spec.ts # Navigation header +├── history.spec.ts # Chronik / activity feed +├── korrespondenz.spec.ts # Correspondence timeline +├── lang.spec.ts # Language switching +├── password-reset.spec.ts # Password reset flow +├── permissions.spec.ts # Role-based access control +├── persons.spec.ts # Person directory CRUD +├── profile.spec.ts # User profile +├── theme.spec.ts # Dark/light mode +├── transcription.spec.ts # Transcription workflows +├── accessibility.spec.ts # Axe accessibility scans +├── fixtures/ # Test data fixtures +└── helpers/ # Test helper utilities +``` + +## Authentication Strategy + +Tests share auth state via a stored session cookie: + +1. **Setup** (`auth.setup.ts`): Logs in with test credentials and saves `storageState` to `e2e/.auth/user.json` +2. **Tests**: All test projects depend on `setup` and reuse the stored session + +This avoids re-logging in for every test, but means tests **must run sequentially** (`fullyParallel: false`, `workers: 1`). + +## Configuration + +Config lives in `frontend/playwright.config.ts`: + +| Setting | Value | Notes | +| --------------- | ----------------------- | ------------------------------ | +| `testDir` | `./e2e` | Test file location | +| `fullyParallel` | `false` | Shared auth state | +| `workers` | `1` | Sequential execution | +| `screenshot` | `'on'` | Always capture | +| `video` | `'retain-on-failure'` | Keep on failure | +| `trace` | `'retain-on-failure'` | Keep on failure | +| `baseURL` | `http://localhost:3000` | Overridable via `E2E_BASE_URL` | + +The `webServer` config auto-starts `npm run dev -- --port 3000` if no server is detected at the base URL. + +## How to Run + +### Prerequisites + +The full stack must be running (or the `webServer` config will start the frontend dev server): + +```bash +# Start infrastructure +docker-compose up -d + +# Ensure backend is healthy +curl http://localhost:8080/actuator/health +``` + +### Run E2E Tests + +```bash +cd frontend + +# Headless (CI mode) +npm run test:e2e + +# With visible browser +npm run test:e2e:headed + +# Interactive UI mode +npm run test:e2e:ui + +# Run a specific test file +npx playwright test documents.spec.ts + +# Run with a different base URL (e.g., docker frontend on 5173) +E2E_BASE_URL=http://localhost:5173 npx playwright test +``` + +## Writing New E2E Tests + +1. Create a new `.spec.ts` file in `frontend/e2e/` +2. Use the shared auth state (no manual login needed) +3. Use page object patterns or helper functions from `helpers/` +4. Add `test-data-id` attributes to components for stable selectors +5. Run with `--debug` or `--ui` to troubleshoot + +### Example Test Pattern + +```typescript +import { test, expect } from '@playwright/test'; + +test('user can create a document', async ({ page }) => { + await page.goto('/documents/new'); + await page.getByTestId('document-title').fill('Test Document'); + await page.getByTestId('save-button').click(); + await expect(page).toHaveURL(/\/documents\/[^/]+$/); +}); +``` + +## Accessibility Testing + +`accessibility.spec.ts` runs Axe scans on key pages. Violations fail the test. + +```bash +npx playwright test accessibility.spec.ts +``` + +## Troubleshooting + +| Issue | Solution | +| --------------------- | ---------------------------------------- | +| Auth failures | Delete `e2e/.auth/user.json` and re-run | +| Backend not reachable | Ensure `docker-compose up -d` is running | +| Flaky tests | Increase timeout or add explicit waits | +| Screenshots missing | Check `test-results/e2e/` | + +## CI Integration + +E2E tests are **not** currently run in CI (the pipeline stops at unit/component tests). To add them, extend `infra/gitea/workflows/ci.yml` with a Playwright job that starts the full Docker Compose stack first. diff --git a/frontend/src/lib/components/StammbaumSidePanel.svelte b/frontend/src/lib/components/StammbaumSidePanel.svelte new file mode 100644 index 00000000..56d758fb --- /dev/null +++ b/frontend/src/lib/components/StammbaumSidePanel.svelte @@ -0,0 +1,160 @@ + + +
+
+

{node.displayName}

+ {#if node.birthYear || node.deathYear} +

+ {node.birthYear ?? '?'}–{node.deathYear ?? ''} +

+ {/if} +
+ + {#if error} + + {:else if loading} +

+ {:else} +
+

+ {m.stammbaum_panel_direct_rels()} +

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

{m.person_relationships_empty()}

+ {:else} +
    + {#each directRels as rel (rel.id)} +
  • + + {chipLabel(rel)} + + + {otherName(rel)} + +
  • + {/each} +
+ {/if} +
+ + {#if topDerived.length > 0} +
+

+ {m.stammbaum_panel_derived_rels()} +

+
    + {#each topDerived as derived (derived.person.id)} +
  • + + {inferredRelationshipLabel(derived.label)} + + + {derived.person.displayName} + +
  • + {/each} +
+
+ {/if} + {/if} + + +
diff --git a/frontend/src/lib/components/StammbaumTree.svelte b/frontend/src/lib/components/StammbaumTree.svelte new file mode 100644 index 00000000..73b625bc --- /dev/null +++ b/frontend/src/lib/components/StammbaumTree.svelte @@ -0,0 +1,239 @@ + + + + + {#each parentEdges as e (e.id)} + {@const parentCenter = nodeCenter(e.personId)} + {@const childCenter = nodeCenter(e.relatedPersonId)} + {#if parentCenter && childCenter} + + {/if} + {/each} + + + {#each spouseEdges as e (e.id)} + {@const aCenter = nodeCenter(e.personId)} + {@const bCenter = nodeCenter(e.relatedPersonId)} + {#if aCenter && bCenter} + + + {/if} + {/each} + + + {#each nodes as node (node.id)} + {@const pos = layout.positions.get(node.id)} + {#if pos} + {@const isSelected = selectedId === node.id} + onSelect(node.id)} + onkeydown={(e) => handleNodeKey(e, node.id)} + class="cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary" + > + + {#if isSelected} + + {/if} + + {node.displayName} + + {#if node.birthYear || node.deathYear} + + {node.birthYear ?? '?'}–{node.deathYear ?? ''} + + {/if} + + {/if} + {/each} + diff --git a/frontend/src/routes/stammbaum/+page.server.ts b/frontend/src/routes/stammbaum/+page.server.ts new file mode 100644 index 00000000..b35c75d5 --- /dev/null +++ b/frontend/src/routes/stammbaum/+page.server.ts @@ -0,0 +1,18 @@ +import { error, redirect } from '@sveltejs/kit'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export async function load({ fetch }) { + const api = createApiClient(fetch); + const result = await api.GET('/api/network'); + + if (result.response.status === 401) throw redirect(302, '/login'); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + throw error(result.response.status, getErrorMessage(code)); + } + + const network = result.data!; + return { nodes: network.nodes ?? [], edges: network.edges ?? [] }; +} diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte new file mode 100644 index 00000000..6d472173 --- /dev/null +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -0,0 +1,110 @@ + + +
+
+

{m.nav_stammbaum()}

+ {#if data.nodes.length > 0} +
+ + +
+ {/if} +
+ + {#if data.nodes.length === 0} +
+
+ +

{m.stammbaum_empty_heading()}

+

{m.stammbaum_empty_body()}

+ + {m.stammbaum_empty_link()} + +
+
+ {:else} +
+
+ (selectedId = id)} + /> +
+ {#if selectedNode} + + {/if} +
+ {/if} +