Compare commits

..

38 Commits

Author SHA1 Message Date
Marcel
7e1f4f8b09 test(fts): add overflow guard and UUID-as-String regression tests
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m38s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 3m26s
CI / Unit & Component Tests (pull_request) Failing after 4m29s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m20s
- searchDocuments_relevance_returns_empty_when_offset_exceeds_maxInt:
  proves the long→int guard fires and findFtsPageRaw is never called
- searchDocuments_relevance_handles_string_uuid_from_jdbc_driver:
  exercises the toFtsPage String fallback branch for JDBC drivers that
  return UUID columns as String instead of java.util.UUID

Addresses Sara's review concerns on PR #488.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:31:52 +02:00
Marcel
ff2eb2ab91 refactor(fts): address PR #488 review concerns
- Extract isPureTextRelevance() private static method to replace the
  7-clause inline boolean in searchDocuments
- Guard long→int cast in relevanceSortedPageFromSql to prevent silent
  overflow at page ≥43M (CWE-190)
- resolvePersonName now uses the typed API client (createApiClient)
  instead of raw fetch, aligning with project conventions
- Update DocumentServiceTest stubs to match new FTS path (findFtsPageRaw
  + findAllById instead of findAllMatchingIdsByFts)
- Rewrite page.server.spec.ts person-name tests to mock via path-based
  API dispatch, matching the new api.GET call site

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:31:52 +02:00
Marcel
4a0a43b1cf test(fts): add integration tests and update unit tests for SQL-paginated relevance
- DocumentFtsPagedIntegrationTest: Testcontainers repo-level tests for
  findFtsPageRaw (page size, window total, last page, no matches, stopword)
- DocumentServiceSortTest: rewritten to stub findFtsPageRaw + findAllById
  for the pure-text RELEVANCE path; verifies filter-active path stays in-memory
- DocumentServiceTest: update two enrichment tests to use new SQL-path stubs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:31:52 +02:00
Marcel
a8e732ac39 feat(fts): push FTS pagination into SQL via CTE window function
Pure-text RELEVANCE queries now use findFtsPageRaw (CTE + COUNT(*) OVER())
instead of loading all matching IDs into memory and sorting in-process.
Non-text paths (filters active, DATE sort) still use the in-memory path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:31:52 +02:00
Marcel
ea136a8724 refactor(fts): add FtsHit/FtsPage records; rename findRankedIdsByFts -> findAllMatchingIdsByFts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:31:52 +02:00
Marcel
de1c55d18e docs(adr): ADR-008 SQL-level FTS pagination via window-function CTE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:31:52 +02:00
Marcel
e975642a4c fix(pdf-controls): add focus-visible ring to all PdfControls buttons (WCAG 2.1 §2.4.7)
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:09:15 +02:00
Marcel
72f422afe2 fix(a11y): increase all PdfControls buttons to 44×44px touch targets
Add min-h-[44px] min-w-[44px] to all five PDF viewer buttons (prev,
next, zoom in, zoom out, annotation toggle) and widen icon-only
padding from p-1 to p-2. Adds aria-pressed to the annotation toggle
for correct toggle semantics (WCAG 2.2 §2.5.8 + ARIA 1.2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:09:15 +02:00
Marcel
6074480482 ci: document Docker socket security trade-off in runner config
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m34s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
CI / Unit & Component Tests (push) Failing after 4m30s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 3m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:05:19 +02:00
Marcel
5512790d5a ci: track act_runner config with Docker socket mount
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m31s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
Documents the NAS runner configuration needed for Testcontainers.
Must be deployed to the runner host alongside the act_runner binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:03:36 +02:00
Marcel
a158048f45 fix(ci): expose Docker socket env vars for Testcontainers in backend job
DOCKER_HOST makes the socket explicit rather than relying on runner
config propagation; TESTCONTAINERS_RYUK_DISABLED=true avoids Ryuk
watchdog start failures in nested container environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:03:36 +02:00
Marcel
ac999066dd fix(ci): add TZ=Europe/Berlin to frontend test step
date-buckets.spec.ts midnight tests pass timezone-aware dates (+02:00)
which are 22:00 UTC the prior day; setHours(0,0,0,0) uses local TZ.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:03:36 +02:00
Marcel
8b25a5b940 fix(user): replace Math.abs(hashCode()) with Math.floorMod in computeColor
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Math.abs(Integer.MIN_VALUE) overflows back to Integer.MIN_VALUE (negative),
making the old pattern unsafe for any palette size that doesn't evenly divide
MIN_VALUE. Math.floorMod always returns a non-negative residue in [0, n-1],
eliminating the overflow edge case entirely.

Fixes SpotBugs RV_ABSOLUTE_VALUE_OF_HASHCODE (priority 1, CORRECTNESS).
Closes #471

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:48:59 +02:00
Marcel
265b4f1484 fix(comment): declare missing @PathVariable params on block comment endpoints
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
getBlockComments was missing documentId; replyToBlockComment was missing
blockId. Spring silently ignored undeclared path variables — the segments
were parsed but never bound. Now both parameters are explicitly declared so
Spring rejects non-UUID values with 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:45:48 +02:00
Marcel
bfc3a17676 test(migration): guard cleanup in try-finally to ensure isolation
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m57s
CI / OCR Service Tests (pull_request) Successful in 40s
CI / Backend Unit Tests (pull_request) Failing after 3m21s
CI / Unit & Component Tests (push) Failing after 4m1s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 3m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:25:26 +02:00
Marcel
eb54a98ea2 fix(user): use builder in createGroup and guard against null permissions
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m2s
CI / OCR Service Tests (pull_request) Successful in 37s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
Null dto.permissions now produces an empty HashSet instead of propagating null
into the @ElementCollection — prevents a silent NPE after V64 adds NOT NULL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:19:20 +02:00
Marcel
3fcdfa85f1 fix(db): add PRIMARY KEY to group_permissions; promote tbmp UNIQUE to PK
V63 deduplicates any phantom (group_id, permission) rows accumulated since
the initial schema. V64 sets NOT NULL on permission and adds pk_group_permissions.
V65 renames uq_tbmp_block_person to pk_tbmp for naming-convention consistency.
Integration tests confirm each constraint via pg_catalog.pg_constraint. Closes #469 (partial).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:18:46 +02:00
Marcel
cd1c0b210e test(typeahead): note resetKey smoke-test limitation in spec comment
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m19s
CI / OCR Service Tests (push) Successful in 42s
CI / Backend Unit Tests (push) Failing after 3m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
a239c16c31 fix(documents): sync filter display state with URL on navigation
Three root causes prevented filters from reflecting the URL after SvelteKit
client-side navigation:

1. +page.server.ts now resolves sender/receiver display names in parallel with
   the document search (UUID validation + silent 404 drop), so initialSenderName
   / initialReceiverName land in server data ready for the UI to use.

2. +page.svelte passes initialSenderName, initialReceiverName, and navKey
   (incremented via untrack on every navigation) down to SearchFilterBar.
   The untrack() prevents the effect from re-running due to its own navKey write.

3. SearchFilterBar forwards navKey as resetKey to each PersonTypeahead, which
   already had a void resetKey guard added in the previous commit.

Together these ensure that after navigating to /documents?senderId=<uuid> the
typeahead shows the person's display name, and clicking × reset clears it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
8a8205ad8d fix(person-typeahead): add resetKey prop to clear term on navigation reset
When the user types in the sender/receiver typeahead without selecting a
person and then clicks ×-reset (navigating back to /documents), the
manually-typed term was not cleared because initialName stayed '' between
navigations — the existing $effect tracking initialName never fired.

Adding `resetKey` (incremented by the page on every navigation) forces
the effect to re-run via `void resetKey`, clearing searchTerm=initialName
even when initialName is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
0430383e1c fix(date-input): re-derive display when value prop changes externally
`display` was initialised once and never updated, so the text box would
show a stale German date after the parent reset `value` (e.g. × reset
button or timeline drag). A guarded `$effect` re-derives `display` from
`value` whenever the two are out of sync while preserving mid-typing
partial dates (germanToIso returns '' for incomplete input, which matches
value='' during typing → no spurious re-derive).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
e2d74ff880 ci: add npm run build step to unit-tests job
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
The prerender fix only prevents regression if the build is actually run in
CI. Without this gate, a future prerendered route that becomes unreachable
behind auth would fail silently until someone runs the build manually.

Fits after the test step in the existing unit-tests job — no new job needed
since node_modules is already cached for the Playwright container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:25:32 +02:00
Marcel
586eea009b fix(build): add prerender entry for /hilfe/transkription
The SvelteKit prerender crawler cannot reach this route because
hooks.server.ts redirects all non-public paths to /login before the
crawler follows links. Explicitly listing the route in kit.prerender.entries
tells SvelteKit to render it directly without crawling.

Also removes a misleading comment that claimed the auth hook guards
prerendered static files — it does not. Prerendered HTML is served as a
static file by the reverse proxy; hooks.server.ts only runs for SSR requests.

Closes #472

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:25:32 +02:00
Marcel
7c2c4741ab refactor(dashboard): replace new CSS tokens with existing equivalents
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m0s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m21s
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
mint-soft → accent-bg, line-soft → line-2, link-quiet → ink-2,
ink-4 removed (was never applied to any element).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:12:36 +02:00
Marcel
d464bca9f3 style(dashboard): increase doc row padding py-1.5 → py-3 in ReaderRecentDocs
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m57s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
CI / Unit & Component Tests (push) Failing after 3m58s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 3m18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:22:55 +02:00
Marcel
2283f733cc refactor(dashboard): align ReaderPersonChips cards with /persons overview style
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Failing after 4m10s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m33s
- rounded, px-4 py-6, shadow-sm, gap-4 — matches overview card sizing
- hover: left accent border + shadow-md (matches overview hover)
- avatar: h-12 w-12, font-bold (djb2 palette colors kept)
- name: font-bold, group-hover:underline
- doc count: neutral bg-muted chip instead of mint pill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:21:24 +02:00
Marcel
cc20583ae6 fix(dashboard): replace text-brand-navy dark:text-brand-mint with text-ink
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m7s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
CI / Unit & Component Tests (push) Failing after 4m2s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m25s
text-ink uses --c-ink which is #012851 in light and #f0efe9 in dark, responding
to both @media and [data-theme='dark'] via CSS variable — no extra token needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:40:00 +02:00
Marcel
86d75d91be fix(dashboard): use bg-surface instead of bg-white in ReaderHeaderBar for dark mode
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m8s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
bg-white is hardcoded #fff and only flips via the Tailwind dark: media-query variant.
bg-surface uses a CSS variable (--c-surface) that responds to both the media query
and the [data-theme='dark'] attribute, matching how all other cards on the page work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:35:46 +02:00
Marcel
a98ca0e5d3 fix(dashboard): add dark:text-brand-mint to ReaderHeaderBar greeting and stat numbers
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m2s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:31:14 +02:00
Marcel
1c515a3145 style(dashboard): widen stat columns from px-3 to px-5 in ReaderHeaderBar
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m13s
CI / OCR Service Tests (pull_request) Successful in 40s
CI / Backend Unit Tests (pull_request) Failing after 3m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:28:51 +02:00
Marcel
43d36c898c feat(dashboard): wire ReaderHeaderBar, grid content row, delete ReaderStatsStrip (#483)
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m46s
CI / OCR Service Tests (push) Successful in 52s
CI / Backend Unit Tests (push) Failing after 3m32s
CI / Unit & Component Tests (pull_request) Failing after 4m0s
CI / OCR Service Tests (pull_request) Successful in 43s
CI / Backend Unit Tests (pull_request) Failing after 3m32s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:13:00 +02:00
Marcel
60326cfb0a refactor(dashboard): ReaderDraftsModule mint left-border, card-head, row structure (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:10:20 +02:00
Marcel
e598f5a506 refactor(dashboard): ReaderRecentStories card-head link, touch targets (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:07:16 +02:00
Marcel
e1c78e3fbe refactor(dashboard): ReaderRecentDocs compact card-head, mint-pill badge (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:01:46 +02:00
Marcel
ae6355d206 refactor(dashboard): ReaderPersonChips → grid layout with mint-pill doc count (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:56:52 +02:00
Marcel
b5f9fcfdfd feat(dashboard): add ReaderHeaderBar with greeting + stat columns (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:54:02 +02:00
Marcel
2f48dfabd1 i18n: add reader header-bar keys, remove dashboard_badge_updated (#483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:50:36 +02:00
Marcel
495210052f style: add mint-soft, line-soft, link-quiet, ink-4 tokens (#483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:48:33 +02:00
33 changed files with 853 additions and 214 deletions

View File

@@ -39,6 +39,12 @@ jobs:
- name: Run unit and component tests
run: npm test
working-directory: frontend
env:
TZ: Europe/Berlin
- name: Build frontend
run: npm run build
working-directory: frontend
- name: Upload screenshots
if: always()
@@ -74,6 +80,8 @@ jobs:
runs-on: ubuntu-latest
env:
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
DOCKER_HOST: unix:///var/run/docker.sock
TESTCONTAINERS_RYUK_DISABLED: "true"
steps:
- uses: actions/checkout@v4

View File

@@ -27,7 +27,9 @@ public class CommentController {
// ─── Block (transcription) comments ────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
public List<DocumentComment> getBlockComments(@PathVariable UUID blockId) {
public List<DocumentComment> getBlockComments(
@PathVariable UUID documentId,
@PathVariable UUID blockId) {
return commentService.getCommentsForBlock(blockId);
}
@@ -48,6 +50,7 @@ public class CommentController {
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToBlockComment(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {

View File

@@ -88,7 +88,8 @@ public class AppUser {
};
public static String computeColor(UUID id) {
return PALETTE[Math.abs(id.hashCode()) % PALETTE.length];
// Math.floorMod avoids the Integer.MIN_VALUE overflow trap in Math.abs(hashCode())
return PALETTE[Math.floorMod(id.hashCode(), PALETTE.length)];
}
@PrePersist

View File

@@ -271,9 +271,10 @@ public class UserService {
@Transactional
public UserGroup createGroup(GroupDTO dto) {
UserGroup group = new UserGroup();
group.setName(dto.getName());
group.setPermissions(dto.getPermissions());
UserGroup group = UserGroup.builder()
.name(dto.getName())
.permissions(dto.getPermissions() != null ? dto.getPermissions() : new HashSet<>())
.build();
return groupRepository.save(group);
}

View File

@@ -0,0 +1,7 @@
-- Remove duplicate (group_id, permission) rows that accumulated without a UNIQUE constraint.
-- Keeps the row with the smallest ctid (earliest physical insertion order).
DELETE FROM group_permissions a
USING group_permissions b
WHERE a.ctid < b.ctid
AND a.group_id = b.group_id
AND a.permission = b.permission;

View File

@@ -0,0 +1,11 @@
-- Add NOT NULL and PRIMARY KEY to group_permissions.
-- Requires V63 to have run first (no duplicates can remain).
--
-- After this migration, future seed migrations can use:
-- INSERT INTO group_permissions ... ON CONFLICT DO NOTHING
-- instead of the INSERT ... WHERE NOT EXISTS pattern used before V64.
ALTER TABLE group_permissions
ALTER COLUMN permission SET NOT NULL;
ALTER TABLE group_permissions
ADD CONSTRAINT pk_group_permissions PRIMARY KEY (group_id, permission);

View File

@@ -0,0 +1,8 @@
-- Promote the de-facto unique constraint on transcription_block_mentioned_persons to a named PK.
-- uq_tbmp_block_person (added in V57) is backed by a B-tree index identical to a PK;
-- this rename makes the naming convention explicit (pk_* vs uq_*).
ALTER TABLE transcription_block_mentioned_persons
DROP CONSTRAINT uq_tbmp_block_person;
ALTER TABLE transcription_block_mentioned_persons
ADD CONSTRAINT pk_tbmp PRIMARY KEY (block_id, person_id);

View File

@@ -399,6 +399,68 @@ class MigrationIntegrationTest {
AND dc.annotation_id IS NOT NULL
""";
// ─── V63+V64: group_permissions dedup + primary key ──────────────────────
@Test
void v64_pk_group_permissions_exists() {
Integer count = jdbc.queryForObject(
"""
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
WHERE t.relname = 'group_permissions'
AND c.conname = 'pk_group_permissions'
AND c.contype = 'p'
""",
Integer.class);
assertThat(count).isEqualTo(1);
}
@Test
void v64_permission_column_isNotNullable() {
Integer count = jdbc.queryForObject(
"""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'group_permissions'
AND column_name = 'permission'
AND is_nullable = 'NO'
""",
Integer.class);
assertThat(count).isEqualTo(1);
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void v64_rejectsDuplicateGroupPermission() {
UUID groupId = createUserGroup("DuplicateTestGroup-" + UUID.randomUUID());
try {
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId);
assertThatThrownBy(() ->
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId)
).isInstanceOf(DataIntegrityViolationException.class);
} finally {
jdbc.update("DELETE FROM group_permissions WHERE group_id = ?", groupId);
jdbc.update("DELETE FROM user_groups WHERE id = ?", groupId);
}
}
// ─── V65: tbmp UNIQUE promoted to PRIMARY KEY ─────────────────────────────
@Test
void v65_pk_tbmp_exists() {
Integer count = jdbc.queryForObject(
"""
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
WHERE t.relname = 'transcription_block_mentioned_persons'
AND c.conname = 'pk_tbmp'
AND c.contype = 'p'
""",
Integer.class);
assertThat(count).isEqualTo(1);
}
// ─── helpers ─────────────────────────────────────────────────────────────
private UUID createPerson(String firstName, String lastName) {
@@ -482,4 +544,10 @@ class MigrationIntegrationTest {
""", id, recipientId, docId, commentId);
return id;
}
private UUID createUserGroup(String name) {
UUID id = UUID.randomUUID();
jdbc.update("INSERT INTO user_groups (id, name) VALUES (?, ?)", id, name);
return id;
}
}

View File

@@ -44,6 +44,14 @@ class CommentControllerTest {
// ─── Block comment endpoints ─────────────────────────────────────────────
@Test
@WithMockUser
void getBlockComments_returns400_when_documentId_is_not_a_UUID() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(get("/api/documents/NOT-A-UUID/transcription-blocks/" + blockId + "/comments"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void getBlockComments_returns200() throws Exception {
@@ -115,6 +123,15 @@ class CommentControllerTest {
// ─── Block reply endpoints ───────────────────────────────────────────────
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
+ "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isBadRequest());
}
@Test
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID();

View File

@@ -35,4 +35,15 @@ class AppUserTest {
.count();
assertThat(distinct).isGreaterThan(1);
}
@Test
void computeColor_returnsValidPaletteColorForIntegerMinValueHash() {
// UUID "80000000-0000-0000-0000-000000000000" has hashCode() == Integer.MIN_VALUE.
// Math.abs(Integer.MIN_VALUE) overflows back to Integer.MIN_VALUE (negative), making
// Math.abs(hashCode()) % n unsafe for palette sizes that don't evenly divide MIN_VALUE.
// Math.floorMod eliminates this edge case entirely.
UUID minHashId = UUID.fromString("80000000-0000-0000-0000-000000000000");
assertThat(minHashId.hashCode()).isEqualTo(Integer.MIN_VALUE);
assertThat(EXPECTED_PALETTE).contains(AppUser.computeColor(minHashId));
}
}

View File

@@ -902,4 +902,18 @@ class UserServiceTest {
assertThat(result.getName()).isEqualTo("Familie");
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
}
@Test
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
dto.setName("Leser");
dto.setPermissions(null);
UserGroup saved = UserGroup.builder().id(UUID.randomUUID()).name("Leser").build();
when(groupRepository.save(any())).thenReturn(saved);
userService.createGroup(dto);
verify(groupRepository).save(argThat(g -> g.getPermissions() != null && g.getPermissions().isEmpty()));
}
}

View File

@@ -459,9 +459,17 @@
"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_all_documents": "Alle Dokumente",
"dashboard_greeting_time_morning": "Morgen",
"dashboard_greeting_time_afternoon": "Mittag",
"dashboard_greeting_time_evening": "Abend",
"dashboard_welcome": "Herzlich willkommen, {name}.",
"dashboard_reader_stats_documents_short": "Dok.",
"dashboard_reader_stats_persons_short": "Pers.",
"dashboard_reader_stats_stories_short": "Gesch.",
"dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument",
"doc_status_placeholder": "Platzhalter",

View File

@@ -459,9 +459,17 @@
"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_all_documents": "All Documents",
"dashboard_greeting_time_morning": "Morning",
"dashboard_greeting_time_afternoon": "Afternoon",
"dashboard_greeting_time_evening": "Evening",
"dashboard_welcome": "Welcome, {name}.",
"dashboard_reader_stats_documents_short": "Docs.",
"dashboard_reader_stats_persons_short": "Pers.",
"dashboard_reader_stats_stories_short": "Stor.",
"dashboard_reader_draft_meta": "Draft · last edited {relative}",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document",
"doc_status_placeholder": "Placeholder",

View File

@@ -459,9 +459,17 @@
"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_all_documents": "Todos los documentos",
"dashboard_greeting_time_morning": "Mañana",
"dashboard_greeting_time_afternoon": "Tarde",
"dashboard_greeting_time_evening": "Noche",
"dashboard_welcome": "Bienvenido, {name}.",
"dashboard_reader_stats_documents_short": "Docs.",
"dashboard_reader_stats_persons_short": "Pers.",
"dashboard_reader_stats_stories_short": "Hist.",
"dashboard_reader_draft_meta": "Borrador · editado hace {relative}",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido",
"doc_status_placeholder": "Marcador",

View File

@@ -35,7 +35,7 @@ let {
onclick={onPrev}
disabled={currentPage <= 1}
aria-label="Zurück"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
@@ -52,7 +52,7 @@ let {
onclick={onNext}
disabled={!isLoaded || currentPage >= totalPages}
aria-label="Weiter"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
@@ -65,7 +65,7 @@ let {
<button
onclick={onZoomOut}
aria-label="Verkleinern"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
@@ -75,7 +75,7 @@ let {
<button
onclick={onZoomIn}
aria-label="Vergrößern"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
@@ -89,7 +89,8 @@ let {
<button
onclick={onToggleAnnotations}
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
aria-pressed={showAnnotations}
class="flex min-h-[44px] min-w-[44px] items-center gap-1.5 rounded px-3 py-2 font-sans text-xs transition focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 {showAnnotations
? 'text-ink-2 hover:bg-surface/10'
: 'bg-surface/10 text-primary'}"
>

View File

@@ -65,3 +65,111 @@ describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
expect(annotationBtn!.className).not.toContain('text-accent');
});
});
describe('PdfControls — focus rings (WCAG 2.1 §2.4.7)', () => {
it('annotation toggle button has focus-visible:ring-2 focus ring', async () => {
const { container } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('focus-visible:ring-2');
});
it('icon-only nav/zoom buttons each have focus-visible:ring-2 focus ring', async () => {
const { container } = render(PdfControls, { ...defaultProps });
const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? '';
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
});
expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) {
expect(btn.className).toContain('focus-visible:ring-2');
}
});
});
describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
it('annotation toggle button has min-h-[44px] touch target', async () => {
const { container } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('min-h-[44px]');
});
it('annotation toggle button has min-w-[44px] touch target', async () => {
const { container } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('min-w-[44px]');
});
it('annotation toggle reflects pressed state via aria-pressed', async () => {
const { container: c1 } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const btn1 = Array.from(c1.querySelectorAll('button')).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(btn1!.getAttribute('aria-pressed')).toBe('false');
cleanup();
const { container: c2 } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: true
});
const btn2 = Array.from(c2.querySelectorAll('button')).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(btn2!.getAttribute('aria-pressed')).toBe('true');
});
it('icon-only nav/zoom buttons each have min-h-[44px] touch target', async () => {
const { container } = render(PdfControls, { ...defaultProps });
const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? '';
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
});
expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) {
expect(btn.className).toContain('min-h-[44px]');
}
});
it('icon-only nav/zoom buttons each have min-w-[44px] touch target', async () => {
const { container } = render(PdfControls, { ...defaultProps });
const allButtons = container.querySelectorAll('button');
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
const label = b.getAttribute('aria-label') ?? '';
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
});
expect(iconOnlyButtons).toHaveLength(4);
for (const btn of iconOnlyButtons) {
expect(btn.className).toContain('min-w-[44px]');
}
});
});

View File

@@ -12,24 +12,47 @@ interface Props {
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>
<div
class="flex flex-col overflow-hidden rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface"
>
<!-- Card-head -->
<div class="flex items-center border-b border-line px-3 py-1.5">
<h3 class="text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
{m.dashboard_reader_drafts_heading()}
</h3>
</div>
{#if drafts.length === 0}
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
<p class="px-3 py-3 font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
{:else}
<ul class="flex flex-col gap-2">
<ul class="flex flex-col">
{#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"
class="flex min-h-[44px] items-center justify-between border-b border-line/50 px-3 py-1.5 last:border-b-0 hover:bg-muted 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 class="flex min-w-0 flex-col">
<span class="truncate font-serif text-sm text-ink">{draft.title}</span>
<span class="text-[11px] text-ink-3">
{m.dashboard_reader_draft_meta({ relative: relativeTimeDe(new Date(draft.updatedAt)) })}
</span>
</span>
<svg
width="7"
height="7"
viewBox="0 0 7 7"
fill="none"
aria-hidden="true"
class="shrink-0 text-ink-3"
>
<path
d="M1.5 1 L5.5 3.5 L1.5 6"
stroke="currentColor"
stroke-width="1.5"
fill="none"
/>
</svg>
</a>
</li>
{/each}

View File

@@ -36,10 +36,12 @@ describe('ReaderDraftsModule', () => {
await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit');
});
it('shows heading "Meine Entwürfe"', async () => {
it('shows heading as h3 (not h2)', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const heading = page.getByRole('heading', { name: /Meine Entwürfe/i });
await expect.element(heading).toBeInTheDocument();
const h3 = page.getByRole('heading', { level: 3 });
await expect.element(h3).toBeInTheDocument();
const h2 = page.getByRole('heading', { level: 2 });
await expect.element(h2).not.toBeInTheDocument();
});
it('shows empty state when drafts is empty', async () => {
@@ -53,4 +55,45 @@ describe('ReaderDraftsModule', () => {
const emptyText = page.getByText(/Keine Entwürfe/i);
await expect.element(emptyText).not.toBeInTheDocument();
});
it('card wrapper has mint left-border classes', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const h3 = page.getByRole('heading', { level: 3 });
const card = ((await h3.element()) as HTMLElement).closest('div[class]');
const rootCard = card?.parentElement;
const cls = rootCard?.className ?? '';
expect(cls).toMatch(/border-l-\[3px\]/);
expect(cls).toMatch(/border-l-brand-mint/);
});
it('draft-row link has min-h-[44px] touch target', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('draft title has text-ink class', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
const el = (await link.element()) as HTMLElement;
const titleEl = el.querySelector('[class*="text-ink"]');
expect(titleEl).not.toBeNull();
expect(titleEl?.textContent?.trim()).toBe('Mein erster Entwurf');
});
it('draft meta contains "Entwurf" text', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
const el = (await link.element()) as HTMLElement;
expect(el.textContent).toMatch(/Entwurf/);
});
it('chevron SVG is present in each draft row', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
const el = (await link.element()) as HTMLElement;
const svg = el.querySelector('svg');
expect(svg).not.toBeNull();
});
});

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
name: string;
documents: number | null;
persons: number | null;
stories: number | null;
hour?: number;
}
const { name, documents, persons, stories, hour }: Props = $props();
const timeLabel = $derived.by(() => {
const h = hour ?? new Date().getHours();
if (h < 12) return m.dashboard_greeting_time_morning();
if (h < 18) return m.dashboard_greeting_time_afternoon();
return m.dashboard_greeting_time_evening();
});
</script>
<header
class="flex flex-col items-start gap-4 rounded-sm border border-line bg-surface px-4 py-3 sm:flex-row sm:items-center dark:border-white/8"
>
<!-- Greeting -->
<div class="min-w-0 flex-1">
<span class="block text-[11px] font-bold tracking-[.8px] text-ink uppercase">
{timeLabel}
</span>
<span class="block font-serif text-xl text-ink">
{m.dashboard_welcome({ name })}
</span>
</div>
<!-- Vertical divider — desktop only -->
<div class="hidden w-px shrink-0 self-stretch bg-line sm:block" aria-hidden="true"></div>
<!-- Stats -->
<div
class="flex w-full items-center border-t border-line-2 pt-1.5 sm:w-auto sm:border-t-0 sm:pt-0"
>
<a
href="/documents"
class="flex min-h-[44px] flex-col items-center justify-center border-r border-line-2 px-5 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="block text-2xl leading-none font-black text-ink">{documents ?? '—'}</span>
<span
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
>{m.dashboard_reader_stats_documents()}</span
>
<span
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
>{m.dashboard_reader_stats_documents_short()}</span
>
</a>
<a
href="/persons"
class="flex min-h-[44px] flex-col items-center justify-center border-r border-line-2 px-5 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="block text-2xl leading-none font-black text-ink">{persons ?? '—'}</span>
<span
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
>{m.dashboard_reader_stats_persons()}</span
>
<span
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
>{m.dashboard_reader_stats_persons_short()}</span
>
</a>
<a
href="/geschichten"
class="flex min-h-[44px] flex-col items-center justify-center px-5 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="block text-2xl leading-none font-black text-ink">{stories ?? '—'}</span>
<span
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
>{m.dashboard_reader_stats_stories()}</span
>
<span
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
>{m.dashboard_reader_stats_stories_short()}</span
>
</a>
</div>
</header>

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderHeaderBar from './ReaderHeaderBar.svelte';
afterEach(() => {
cleanup();
});
describe('ReaderHeaderBar', () => {
it('renders a link to /documents with document count', async () => {
render(ReaderHeaderBar, { name: 'Anna', 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 with person count', async () => {
render(ReaderHeaderBar, { name: 'Anna', 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 with story count', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /3/ });
await expect.element(link).toHaveAttribute('href', '/geschichten');
});
it('documents stat link has min-h-[44px] for touch target', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /42/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('persons stat link has min-h-[44px] for touch target', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /7/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('stories stat link has min-h-[44px] for touch target', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /3/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('shows "—" when counts are null', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: null, persons: null, stories: null });
const wrapper = page.getByRole('banner');
const text = ((await wrapper.element()) as HTMLElement).textContent;
expect(text?.match(/—/g)?.length).toBeGreaterThanOrEqual(3);
});
it('time label uses text-ink class for morning hour', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
const timeLabel = page.getByText(/Morgen/i);
await expect.element(timeLabel).toBeInTheDocument();
const cls = ((await timeLabel.element()) as HTMLElement).className;
expect(cls).toMatch(/\btext-ink\b/);
});
it('shows afternoon label for hour 14', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 14 });
const timeLabel = page.getByText(/Mittag/i);
await expect.element(timeLabel).toBeInTheDocument();
});
it('shows evening label for hour 20', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 20 });
const timeLabel = page.getByText(/Abend/i);
await expect.element(timeLabel).toBeInTheDocument();
});
it('welcome line contains the user name', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
const welcome = page.getByText(/Anna/);
await expect.element(welcome).toBeInTheDocument();
});
it('wrapper uses bg-surface (CSS-variable-backed, dark-mode-aware)', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
const wrapper = page.getByRole('banner');
const cls = ((await wrapper.element()) as HTMLElement).className;
expect(cls).toMatch(/\bbg-surface\b/);
});
it('renders a vertical divider with bg-line class', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
const wrapper = page.getByRole('banner');
const el = (await wrapper.element()) as HTMLElement;
const divider = el.querySelector('[aria-hidden="true"]');
expect(divider).not.toBeNull();
expect(divider!.className).toMatch(/bg-line/);
});
});

View File

@@ -27,37 +27,38 @@ interface Props {
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>
<section aria-label={m.dashboard_reader_person_chips_heading()}>
{#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">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
{#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"
class="group flex min-h-[44px] flex-col items-center gap-2 rounded border border-line bg-surface px-4 py-6 text-center no-underline shadow-sm transition-all duration-200 hover:border-l-4 hover:border-accent hover:shadow-md 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"
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-base font-bold text-white shadow-sm dark:shadow-none dark:ring-1 dark:ring-white/10"
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 class="truncate font-serif text-sm font-bold text-ink group-hover:underline"
>{p.displayName ?? p.lastName}</span
>
{#if (p.documentCount ?? 0) > 0}
<span
class="mt-1 inline-flex items-center rounded-full border border-line bg-muted px-2.5 py-0.5 font-sans text-[11px] font-semibold text-ink-2"
>
</span>
{p.documentCount}
</span>
{/if}
</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"
class="mt-1 flex min-h-[44px] items-center justify-end text-right text-xs font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>{m.dashboard_reader_all_persons()}</a
>
</div>
</section>

View File

@@ -32,7 +32,7 @@ const person2: PersonSummaryDTO = {
};
describe('ReaderPersonChips', () => {
it('renders a chip for each person with correct href', async () => {
it('renders a card for each person with correct href', async () => {
render(ReaderPersonChips, { persons: [person1, person2] });
const link1 = page.getByRole('link', { name: /Anna Müller/ });
await expect
@@ -44,12 +44,46 @@ describe('ReaderPersonChips', () => {
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
});
it('shows document count in each chip', async () => {
it('person card has min-h-[44px] touch target', 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');
const link = page.getByRole('link', { name: /Anna Müller/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('doc count renders as neutral chip with bg-muted', async () => {
render(ReaderPersonChips, { persons: [person1] });
const link = page.getByRole('link', { name: /Anna Müller/ });
const el = (await link.element()) as HTMLElement;
const chip = el.querySelector('[class*="bg-muted"]');
expect(chip).not.toBeNull();
expect(chip!.textContent).toContain('23');
});
it('doc count chip has rounded-full and border-line classes', async () => {
render(ReaderPersonChips, { persons: [person1] });
const link = page.getByRole('link', { name: /Anna Müller/ });
const el = (await link.element()) as HTMLElement;
const chip = el.querySelector('[class*="bg-muted"]');
expect(chip).not.toBeNull();
expect(chip!.className).toMatch(/rounded-full/);
expect(chip!.className).toMatch(/border-line/);
});
it('person grid uses grid layout', async () => {
render(ReaderPersonChips, { persons: [person1, person2] });
const section = page.getByRole('region');
const el = (await section.element()) as HTMLElement;
const grid = el.querySelector('[class*="grid"]');
expect(grid).not.toBeNull();
});
it('wrapper is a section with aria-label', async () => {
render(ReaderPersonChips, { persons: [person1] });
const section = page.getByRole('region');
await expect.element(section).toBeInTheDocument();
const label = ((await section.element()) as HTMLElement).getAttribute('aria-label');
expect(label).toBeTruthy();
});
it('renders an "Alle Personen" link to /persons', async () => {
@@ -58,6 +92,13 @@ describe('ReaderPersonChips', () => {
await expect.element(allLink).toHaveAttribute('href', '/persons');
});
it('"Alle Personen" link has text-ink-2 class', 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(/text-ink-2/);
});
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 });
@@ -73,7 +114,13 @@ describe('ReaderPersonChips', () => {
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('renders empty state without chips when persons array is empty', async () => {
it('does not render h2 heading', async () => {
render(ReaderPersonChips, { persons: [person1] });
const heading = page.getByRole('heading', { level: 2 });
await expect.element(heading).not.toBeInTheDocument();
});
it('renders empty state without person cards when persons array is empty', async () => {
render(ReaderPersonChips, { persons: [] });
const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
await expect.element(chips).not.toBeInTheDocument();

View File

@@ -16,49 +16,71 @@ function isNew(doc: Document): boolean {
}
</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">
<div class="flex flex-col overflow-hidden rounded-sm border border-line bg-surface">
<!-- Card-head -->
<div class="flex items-center justify-between border-b border-line px-3 py-1.5">
<h3 class="text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
{m.dashboard_reader_recent_docs_heading()}
</h3>
<a
href="/documents"
class="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
{m.dashboard_all_documents()}
</a>
</div>
<!-- Doc list -->
<ul class="flex flex-col">
{#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>
<li>
<a
href="/documents/{doc.id}"
class="flex min-h-[44px] items-center gap-2 border-b border-line/50 px-3 py-3 last:border-b-0 hover:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<!-- Thumb -->
<span
class="flex h-6 w-5 shrink-0 items-center justify-center rounded-[2px] border border-line bg-canvas"
>
<svg
width="10"
height="12"
viewBox="0 0 10 12"
fill="none"
aria-hidden="true"
class="text-ink-3"
>
<path d="M1 1h5.5L9 3.5V11H1V1z" stroke="currentColor" stroke-width="1" fill="none" />
<path d="M6 1v3h3" stroke="currentColor" stroke-width="1" fill="none" />
</svg>
</span>
<!-- Middle -->
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="flex flex-wrap items-center gap-1.5">
<span class="truncate font-serif text-sm text-ink">{doc.title}</span>
{#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"
class="shrink-0 rounded-full bg-accent-bg px-1.5 py-px text-[11px] font-bold text-ink"
>
{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"
>
</span>
<span class="text-xs text-ink-3">
{#if doc.sender}
{doc.sender.displayName ?? doc.sender.lastName}
</a>
{/if}
</div>
<span class="shrink-0 font-sans text-xs text-ink-3">
{:else}
{/if}
</span>
</span>
<!-- Date -->
<span class="shrink-0 text-[11px] text-ink-3">
{relativeTimeDe(new Date(doc.updatedAt))}
</span>
</div>
</a>
</li>
{/each}
</ul>

View File

@@ -37,30 +37,73 @@ describe('ReaderRecentDocs', () => {
await expect.element(link).toHaveAttribute('href', '/documents/doc1');
});
it('shows "Neu" badge when createdAt equals updatedAt', async () => {
it('card has overflow-hidden and flex-col classes (no p-6, no shadow-sm)', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const heading = page.getByRole('heading', { level: 3 });
const card = (await heading.element())?.closest('div');
const rootCard = card?.parentElement;
const cls = rootCard?.className ?? '';
expect(cls).toMatch(/overflow-hidden/);
expect(cls).toMatch(/flex-col/);
expect(cls).not.toMatch(/\bp-6\b/);
expect(cls).not.toMatch(/shadow-sm/);
});
it('card-head contains an h3 (not h2)', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const h3 = page.getByRole('heading', { level: 3 });
await expect.element(h3).toBeInTheDocument();
const h2 = page.getByRole('heading', { level: 2 });
await expect.element(h2).not.toBeInTheDocument();
});
it('"Alle Dokumente" link in card-head points to /documents', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Alle Dokumente/i });
await expect.element(link).toHaveAttribute('href', '/documents');
});
it('"Alle Dokumente" link has min-h-[44px]', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Alle Dokumente/i });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('doc-row link has min-h-[44px] touch target', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Brief an Hans/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('thumb element has correct classes', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Brief an Hans/ });
const el = (await link.element()) as HTMLElement;
const thumb = el.querySelector('[class*="w-5"][class*="h-6"]');
expect(thumb).not.toBeNull();
expect(thumb!.className).toMatch(/bg-canvas/);
expect(thumb!.className).toMatch(/border-line/);
expect(thumb!.className).toMatch(/rounded-/);
});
it('shows "Neu" accent-pill 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(?!\/)/);
expect(cls).toMatch(/bg-accent-bg/);
expect(cls).toMatch(/rounded-full/);
expect(cls).toMatch(/\btext-ink\b/);
});
it('does not show "Neu" badge when updatedAt differs from createdAt', async () => {
it('shows no badge when updatedAt differs from createdAt', async () => {
render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).not.toBeInTheDocument();
const updatedBadge = page.getByText(/^Aktualisiert$/i);
await expect.element(updatedBadge).not.toBeInTheDocument();
});
it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => {
@@ -75,7 +118,7 @@ describe('ReaderRecentDocs', () => {
await expect.element(badge).toBeInTheDocument();
});
it('renders sender link when sender is present', async () => {
it('renders sender name text when sender is present', async () => {
const docWithSender: Document = {
...baseDoc,
sender: {
@@ -88,7 +131,15 @@ describe('ReaderRecentDocs', () => {
}
};
render(ReaderRecentDocs, { documents: [docWithSender] });
const senderLink = page.getByRole('link', { name: /Anna Müller/ });
await expect.element(senderLink).toHaveAttribute('href', '/persons/p1');
const link = page.getByRole('link', { name: /Brief an Hans/ });
const el = (await link.element()) as HTMLElement;
expect(el.textContent).toContain('Anna Müller');
});
it('shows em-dash when sender is absent', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Brief an Hans/ });
const el = (await link.element()) as HTMLElement;
expect(el.textContent).toContain('—');
});
});

View File

@@ -24,33 +24,38 @@ function excerpt(body: string | undefined): string {
</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">
<div class="flex flex-col overflow-hidden rounded-sm border border-line bg-surface">
<!-- Card-head -->
<div class="flex items-center justify-between border-b border-line px-3 py-1.5">
<h3 class="text-[11px] font-bold tracking-[.12em] text-ink-3 uppercase">
{m.dashboard_reader_recent_stories_heading()}
</h3>
<a
href="/geschichten"
class="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
{m.dashboard_reader_all_stories()}
</a>
</div>
<!-- Story list -->
<ul class="flex flex-col">
{#each stories as story (story.id)}
<li class="py-4 first:pt-0 last:pb-0">
<li>
<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"
class="flex min-h-[44px] flex-col gap-1 border-b border-line/50 px-3 py-2 last:border-b-0 hover:bg-muted 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>
<span class="font-serif text-base text-ink italic">{story.title}</span>
{#if story.body}
<p class="line-clamp-2 font-sans text-xs text-ink-3">{excerpt(story.body)}</p>
<p class="line-clamp-2 text-xs leading-relaxed text-ink-2">{excerpt(story.body)}</p>
{/if}
<span class="font-sans text-xs text-ink-3">
<span class="text-[11px] 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

@@ -52,7 +52,7 @@ describe('ReaderRecentStories', () => {
await expect.element(links).not.toBeInTheDocument();
});
it('renders "Alle Geschichten" link', async () => {
it('renders "Alle Geschichten" link pointing to /geschichten', async () => {
render(ReaderRecentStories, { stories: [story1] });
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
await expect.element(allLink).toHaveAttribute('href', '/geschichten');
@@ -72,4 +72,44 @@ describe('ReaderRecentStories', () => {
const cls = ((await allLink.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('card-head contains an h3 (not h2)', async () => {
render(ReaderRecentStories, { stories: [story1] });
const h3 = page.getByRole('heading', { level: 3 });
await expect.element(h3).toBeInTheDocument();
const h2 = page.getByRole('heading', { level: 2 });
await expect.element(h2).not.toBeInTheDocument();
});
it('card-head div has border-b and border-line classes', async () => {
render(ReaderRecentStories, { stories: [story1] });
const h3 = page.getByRole('heading', { level: 3 });
const cardHead = ((await h3.element()) as HTMLElement).parentElement;
expect(cardHead?.className).toMatch(/border-b/);
expect(cardHead?.className).toMatch(/border-line/);
});
it('"Alle Geschichten" link is inside the card-head (sibling of h3)', async () => {
render(ReaderRecentStories, { stories: [story1] });
const h3 = page.getByRole('heading', { level: 3 });
const cardHead = ((await h3.element()) as HTMLElement).parentElement;
const allLink = cardHead?.querySelector('a');
expect(allLink).not.toBeNull();
expect(allLink?.textContent?.trim()).toMatch(/Alle Geschichten/i);
});
it('story-row link has min-h-[44px] touch target', async () => {
render(ReaderRecentStories, { stories: [story1] });
const link = page.getByRole('link', { name: /Die Familie Müller/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('excerpt has text-ink-2 class', async () => {
render(ReaderRecentStories, { stories: [story1] });
const link = page.getByRole('link', { name: /Die Familie Müller/ });
const el = (await link.element()) as HTMLElement;
const excerptEl = el.querySelector('p');
expect(excerptEl?.className).toMatch(/text-ink-2/);
});
});

View File

@@ -1,43 +0,0 @@
<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

@@ -1,37 +0,0 @@
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

@@ -5,7 +5,7 @@ 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 ReaderHeaderBar from '$lib/shared/dashboard/ReaderHeaderBar.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';
@@ -30,15 +30,10 @@ const greetingText = $derived.by(() => {
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
{#if data?.user}
<div class="mb-6">
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
</div>
{/if}
{#if data.isReader}
<div class="flex flex-col gap-5">
<ReaderStatsStrip
<ReaderHeaderBar
name={data.user?.firstName ?? ''}
documents={data.readerStats?.totalDocuments ?? null}
persons={data.readerStats?.totalPersons ?? null}
stories={data.readerStats?.totalStories ?? null}
@@ -50,16 +45,17 @@ const greetingText = $derived.by(() => {
<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 class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
<ReaderRecentDocs documents={data.recentDocs ?? []} />
<ReaderRecentStories stories={data.recentStories ?? []} />
</div>
</div>
{:else}
{#if data?.user}
<div class="mb-6">
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
</div>
{/if}
<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} />

View File

@@ -1,3 +1 @@
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
// before prerendered HTML is visible.
export const prerender = true;

View File

@@ -102,13 +102,19 @@ describe('Home page dashboard layout', () => {
// ─── Reader dashboard layout ──────────────────────────────────────────────────
describe('Home page reader dashboard layout', () => {
it('renders ReaderStatsStrip totals when isReader is true', async () => {
it('renders reader header-bar 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('reader branch does not render h1 heading', async () => {
render(Page, { data: readerData });
const h1 = page.getByRole('heading', { level: 1 });
await expect.element(h1).not.toBeInTheDocument();
});
it('renders the recent-docs heading when isReader is true', async () => {
render(Page, { data: readerData });
await expect.element(page.getByText('Zuletzt aktualisiert')).toBeInTheDocument();

View File

@@ -6,7 +6,10 @@ const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
kit: {
adapter: adapter(),
prerender: { entries: ['/hilfe/transkription'] }
}
};
export default config;

16
runner-config.yaml Normal file
View File

@@ -0,0 +1,16 @@
# runner-config.yaml — only the relevant section
container:
# passed as DOCKER_HOST inside the job container
docker_host: "unix:///var/run/docker.sock"
# whitelists the socket path so workflows can mount it
valid_volumes:
- "/var/run/docker.sock"
# appended to `docker run` when the runner spawns a job container
# SECURITY: Mounting the Docker socket grants job containers root-equivalent
# access to the host Docker daemon. Acceptable here because only trusted code
# from this private repo runs on this runner. Do NOT use on a runner that
# accepts untrusted PRs from external contributors.
options: "-v /var/run/docker.sock:/var/run/docker.sock"
# keep network mode default (bridge) — Testcontainers handles its own networking
force_pull: false