Compare commits

..

9 Commits

Author SHA1 Message Date
Marcel
d1d0acf029 fix(themen): add focus rings to child and 'weitere' links (WCAG 2.4.7)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m46s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m38s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:05:46 +02:00
Marcel
fc53c69aaf docs(architecture): add /themen route and ThemenWidget to C4 frontend diagram
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m33s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:55:31 +02:00
Marcel
9084c8dd32 feat(themen): /themen dedicated page with root-tag cards and child rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:52:43 +02:00
Marcel
9d8e9c4531 feat(dashboard): add ThemenWidget to reader and editor sidebar layouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:51:44 +02:00
Marcel
1d032f52d9 feat(themen): ThemenWidget component with compact prop + browser tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:50:58 +02:00
Marcel
41754fc052 feat(dashboard): load tag tree for both reader and editor dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:45:55 +02:00
Marcel
f376fae600 feat(themen): add /themen server load function + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:41:50 +02:00
Marcel
94d5e69615 feat(i18n): add themen widget and page translation keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:39:06 +02:00
Marcel
aad8382b76 feat(tag): hasAnyDocuments recursive helper + unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:38:06 +02:00
17 changed files with 94 additions and 186 deletions

View File

@@ -6,7 +6,6 @@ import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag; import org.raddatz.familienarchiv.tag.Tag;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -33,9 +32,5 @@ public record DocumentListItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors, List<ActivityActorDTO> contributors,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData, SearchMatchData matchData
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime updatedAt
) {} ) {}

View File

@@ -767,9 +767,7 @@ public class DocumentService {
doc.getSummary(), doc.getSummary(),
completionPct, completionPct,
contributors, contributors,
match, match
doc.getCreatedAt(),
doc.getUpdatedAt()
); );
} }

View File

@@ -135,8 +135,7 @@ class DocumentControllerTest {
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null, docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null, List.of(), List.of(), null, null, null, null,
0, List.of(), matchData, 0, List.of(), matchData))));
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));
mockMvc.perform(get("/api/documents/search").param("q", "Brief")) mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -154,8 +153,7 @@ class DocumentControllerTest {
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null, docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null, List.of(), List.of(), null, null, null, null,
0, List.of(), matchData, 0, List.of(), matchData))));
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk()) .andExpect(status().isOk())

View File

@@ -5,7 +5,6 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -17,8 +16,7 @@ class DocumentSearchResultTest {
return new DocumentListItem( return new DocumentListItem(
docId, "Test", "test.pdf", null, null, null, docId, "Test", "test.pdf", null, null, null,
List.of(), List.of(), null, null, null, null, List.of(), List.of(), null, null, null, null,
0, List.of(), SearchMatchData.empty(), 0, List.of(), SearchMatchData.empty());
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0));
} }
@Test @Test
@@ -68,8 +66,7 @@ class DocumentSearchResultTest {
DocumentListItem item = new DocumentListItem( DocumentListItem item = new DocumentListItem(
id, "T", "t.pdf", null, null, null, id, "T", "t.pdf", null, null, null,
List.of(), List.of(), null, null, null, null, List.of(), List.of(), null, null, null, null,
75, List.of(actor), SearchMatchData.empty(), 75, List.of(actor), SearchMatchData.empty());
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0));
DocumentSearchResult result = DocumentSearchResult.of(List.of(item)); DocumentSearchResult result = DocumentSearchResult.of(List.of(item));

View File

@@ -201,7 +201,7 @@ services:
networks: networks:
- archiv-net - archiv-net
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health | grep -q UP || exit 1"] test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 10 retries: 10

View File

@@ -4,7 +4,7 @@
# Used by docker-compose.yml (target: development). Source is bind-mounted in # Used by docker-compose.yml (target: development). Source is bind-mounted in
# dev so the COPY . below is effectively replaced at runtime; the layer still # dev so the COPY . below is effectively replaced at runtime; the layer still
# exists so the image is self-contained for cold starts (e.g. devcontainer). # exists so the image is self-contained for cold starts (e.g. devcontainer).
FROM node:22-alpine3.21 AS development FROM node:20.19.0-alpine3.21 AS development
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
@@ -14,7 +14,7 @@ CMD ["npm", "run", "dev"]
# ── Build ──────────────────────────────────────────────────────────────────── # ── Build ────────────────────────────────────────────────────────────────────
# Compiles the SvelteKit Node-adapter output to /app/build. # Compiles the SvelteKit Node-adapter output to /app/build.
FROM node:22-alpine3.21 AS build FROM node:20.19.0-alpine3.21 AS build
WORKDIR /app WORKDIR /app
# VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle. # VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle.
# Passed via docker-compose build.args; empty string disables the SDK. # Passed via docker-compose build.args; empty string disables the SDK.
@@ -27,7 +27,7 @@ RUN npm run build
# ── Production ─────────────────────────────────────────────────────────────── # ── Production ───────────────────────────────────────────────────────────────
# Self-contained Node server. `node build` is the adapter-node entrypoint. # Self-contained Node server. `node build` is the adapter-node entrypoint.
FROM node:22-alpine3.21 AS production FROM node:20.19.0-alpine3.21 AS production
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=build /app/build ./build COPY --from=build /app/build ./build

View File

@@ -23,9 +23,9 @@
"@eslint/compat": "^1.4.0", "@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@inlang/paraglide-js": "^2.5.0", "@inlang/paraglide-js": "^2.5.0",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.60.1", "@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -43,7 +43,7 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"openapi-typescript": "^7.8.0", "openapi-typescript": "^7.8.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"playwright": "^1.60.0", "playwright": "^1.56.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
@@ -52,7 +52,7 @@
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.47.0",
"vite": "^7.3.3", "vite": "^7.2.2",
"vite-plugin-devtools-json": "^1.0.0", "vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.0.10", "vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"

View File

@@ -2205,10 +2205,10 @@ export interface components {
totalStories: number; totalStories: number;
}; };
PersonSummaryDTO: { PersonSummaryDTO: {
title?: string;
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
displayName?: string; displayName?: string;
title?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
/** Format: int64 */ /** Format: int64 */
@@ -2315,6 +2315,8 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
totalPages?: number; totalPages?: number;
pageable?: components["schemas"]["PageableObject"]; pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */ /** Format: int32 */
size?: number; size?: number;
content?: components["schemas"]["NotificationDTO"][]; content?: components["schemas"]["NotificationDTO"][];
@@ -2323,8 +2325,6 @@ export interface components {
sort?: components["schemas"]["SortObject"]; sort?: components["schemas"]["SortObject"];
/** Format: int32 */ /** Format: int32 */
numberOfElements?: number; numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean; empty?: boolean;
}; };
PageableObject: { PageableObject: {
@@ -2407,10 +2407,6 @@ export interface components {
completionPercentage: number; completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][]; contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"]; matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
}; };
DocumentSearchResult: { DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][]; items: components["schemas"]["DocumentListItem"][];

View File

@@ -3,16 +3,16 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime'; import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type DocumentListItem = components['schemas']['DocumentListItem']; type Document = components['schemas']['Document'];
interface Props { interface Props {
documents: DocumentListItem[]; documents: Document[];
} }
const { documents }: Props = $props(); const { documents }: Props = $props();
function isNew(doc: DocumentListItem): boolean { function isNew(doc: Document): boolean {
return new Date(doc.createdAt).getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000; return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime();
} }
</script> </script>

View File

@@ -5,33 +5,24 @@ import { page } from 'vitest/browser';
import ReaderRecentDocs from './ReaderRecentDocs.svelte'; import ReaderRecentDocs from './ReaderRecentDocs.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type DocumentListItem = components['schemas']['DocumentListItem']; type Document = components['schemas']['Document'];
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
const baseDoc: DocumentListItem = { const baseDoc: Document = {
id: 'doc1', id: 'doc1',
title: 'Brief an Hans', title: 'Brief an Hans',
originalFilename: 'brief.pdf', originalFilename: 'brief.pdf',
completionPercentage: 0, status: 'UPLOADED',
receivers: [], metadataComplete: true,
tags: [], scriptType: 'HANDWRITING_KURRENT',
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
createdAt: '2025-01-01T12:00:00Z', createdAt: '2025-01-01T12:00:00Z',
updatedAt: '2025-01-01T12:00:00Z' updatedAt: '2025-01-01T12:00:00Z'
}; };
const updatedDoc: DocumentListItem = { const updatedDoc: Document = {
...baseDoc, ...baseDoc,
id: 'doc2', id: 'doc2',
title: 'Urkunde 1920', title: 'Urkunde 1920',
@@ -97,14 +88,8 @@ describe('ReaderRecentDocs', () => {
expect(thumb!.className).toMatch(/rounded-/); expect(thumb!.className).toMatch(/rounded-/);
}); });
it('shows "Neu" accent-pill badge when document was created within the last 7 days', async () => { it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => {
const recentDoc: DocumentListItem = { render(ReaderRecentDocs, { documents: [baseDoc] });
...baseDoc,
id: 'doc-recent',
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString()
};
render(ReaderRecentDocs, { documents: [recentDoc] });
const badge = page.getByText(/^Neu$/i); const badge = page.getByText(/^Neu$/i);
await expect.element(badge).toBeInTheDocument(); await expect.element(badge).toBeInTheDocument();
const cls = ((await badge.element()) as HTMLElement).className; const cls = ((await badge.element()) as HTMLElement).className;
@@ -113,7 +98,7 @@ describe('ReaderRecentDocs', () => {
expect(cls).toMatch(/\btext-ink\b/); expect(cls).toMatch(/\btext-ink\b/);
}); });
it('shows no badge when document was created more than 7 days ago', async () => { it('shows no badge when updatedAt differs from createdAt', async () => {
render(ReaderRecentDocs, { documents: [updatedDoc] }); render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Neu$/i); const badge = page.getByText(/^Neu$/i);
await expect.element(badge).not.toBeInTheDocument(); await expect.element(badge).not.toBeInTheDocument();
@@ -121,20 +106,20 @@ describe('ReaderRecentDocs', () => {
await expect.element(updatedBadge).not.toBeInTheDocument(); await expect.element(updatedBadge).not.toBeInTheDocument();
}); });
it('shows "Neu" badge when document was created 6 days ago', async () => { it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => {
const almostOldDoc: DocumentListItem = { const sameInstantDoc: Document = {
...baseDoc, ...baseDoc,
id: 'doc-almost-old', id: 'doc-same-instant',
createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), createdAt: '2025-01-01T12:00:00Z',
updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString() updatedAt: '2025-01-01T12:00:00.000Z'
}; };
render(ReaderRecentDocs, { documents: [almostOldDoc] }); render(ReaderRecentDocs, { documents: [sameInstantDoc] });
const badge = page.getByText(/^Neu$/i); const badge = page.getByText(/^Neu$/i);
await expect.element(badge).toBeInTheDocument(); await expect.element(badge).toBeInTheDocument();
}); });
it('renders sender name text when sender is present', async () => { it('renders sender name text when sender is present', async () => {
const docWithSender: DocumentListItem = { const docWithSender: Document = {
...baseDoc, ...baseDoc,
sender: { sender: {
id: 'p1', id: 'p1',

View File

@@ -31,25 +31,25 @@ describe('ReaderRecentDocs', () => {
.toHaveAttribute('href', '/documents'); .toHaveAttribute('href', '/documents');
}); });
it('renders the New badge when document was created within the last 7 days', async () => { it('renders the New badge when createdAt equals updatedAt', async () => {
const recentDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
const laterUpdate = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString();
render(ReaderRecentDocs, { render(ReaderRecentDocs, {
props: { props: {
documents: [makeDoc({ createdAt: recentDate, updatedAt: laterUpdate })] documents: [
makeDoc({ createdAt: '2026-04-15T10:00:00Z', updatedAt: '2026-04-15T10:00:00Z' })
]
} }
}); });
await expect.element(page.getByText('Neu')).toBeVisible(); await expect.element(page.getByText('Neu')).toBeVisible();
}); });
it('hides the New badge when document was created more than 7 days ago', async () => { it('hides the New badge when document was updated after creation', async () => {
render(ReaderRecentDocs, { render(ReaderRecentDocs, {
props: { props: {
documents: [ documents: [
makeDoc({ makeDoc({
createdAt: '2026-04-15T10:00:00Z', createdAt: '2026-04-15T10:00:00Z',
updatedAt: '2026-04-15T10:00:00Z' updatedAt: '2026-04-15T11:00:00Z'
}) })
] ]
} }

View File

@@ -10,12 +10,9 @@ interface Props {
compact?: boolean; compact?: boolean;
} }
const MAX_VISIBLE_TAGS = 6;
const { tags, compact = false }: Props = $props(); const { tags, compact = false }: Props = $props();
const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments)); const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments));
const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
</script> </script>
<section class="rounded-sm border border-line bg-surface p-5 shadow-sm"> <section class="rounded-sm border border-line bg-surface p-5 shadow-sm">
@@ -25,7 +22,7 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
</h2> </h2>
<a <a
href="/themen" href="/themen"
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" class="font-sans text-xs text-brand-mint underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
> >
{m.themen_alle()} {m.themen_alle()}
</a> </a>
@@ -38,9 +35,9 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}" class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}"
data-compact={compact} data-compact={compact}
> >
{#each shownTags as tag (tag.id)} {#each visibleTags as tag (tag.id)}
<a <a
href="/documents?tag={encodeURIComponent(tag.name)}" href="/?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0 aria-label="{tag.name}{tag.documentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount }) ? ', ' + m.themen_dokumente({ count: tag.documentCount })
: ''}" : ''}"

View File

@@ -409,24 +409,19 @@ describe('PersonMentionEditor — onExit cancels pending debounce', () => {
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
const fetchesBeforeEscape = fetchMock.mock.calls.length; const fetchesBeforeEscape = fetchMock.mock.calls.length;
// Freeze setTimeout so the 150 ms debounce cannot fire before Escape // Trigger a new debounced search (queues runSearch after 150 ms), then
// triggers onExit. We install fake timers only now — after the setup // immediately Escape *while focus is back in the editor* so Tiptap's
// above — so that vi.waitFor()'s real-timer polling still worked. // suggestion-plugin Escape handler fires onExit before the debounce.
vi.useFakeTimers(); // Without onExit cancelling the pending debounce, runSearch executes
try { // against the now-unmounted dropdown's state.
// fill() dispatches the input event synchronously via CDP; by the
// time the await resolves, onSearch('Walter') has run and the fake
// debounce timer is set.
await page.getByRole('searchbox').fill('Walter'); await page.getByRole('searchbox').fill('Walter');
// Focus the editor so the Escape lands on Tiptap's suggestion handler. // Focus the editor so the Escape lands on Tiptap's suggestion handler.
(page.getByRole('textbox').element() as HTMLElement).focus(); (page.getByRole('textbox').element() as HTMLElement).focus();
await userEvent.keyboard('{Escape}'); await userEvent.keyboard('{Escape}');
// onExit has now called debouncedSearch.cancel(). Advance past the
// debounce window — the cancelled timer must not fire. // Wait past the debounce window. If onExit did not cancel the pending
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS); // debounce, a fetch with q=Walter would still fire here.
} finally { await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
vi.useRealTimers();
}
const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape); const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
const walterFetches = newFetches.filter( const walterFetches = newFetches.filter(

View File

@@ -10,7 +10,7 @@ type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
type DocumentListItem = components['schemas']['DocumentListItem']; type Document = components['schemas']['Document'];
type Geschichte = components['schemas']['Geschichte']; type Geschichte = components['schemas']['Geschichte'];
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
@@ -55,8 +55,8 @@ export async function load({ fetch, parent }) {
const readerStats = settled<StatsDTO>(statsRes); const readerStats = settled<StatsDTO>(statsRes);
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? []; const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes); const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
const recentDocs = searchData?.items ?? []; const recentDocs = searchData?.items.map((i) => i.document) ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? []; const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? []; const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
const drafts = settled<Geschichte[]>(draftsRes) ?? []; const drafts = settled<Geschichte[]>(draftsRes) ?? [];
@@ -178,7 +178,7 @@ export async function load({ fetch, parent }) {
incompleteTotal: 0, incompleteTotal: 0,
readerStats: null, readerStats: null,
topPersons: [] as PersonSummaryDTO[], topPersons: [] as PersonSummaryDTO[],
recentDocs: [] as DocumentListItem[], recentDocs: [] as Document[],
recentStories: [] as Geschichte[], recentStories: [] as Geschichte[],
tagTree: [] as TagTreeNodeDTO[], tagTree: [] as TagTreeNodeDTO[],
drafts: [] as Geschichte[], drafts: [] as Geschichte[],

View File

@@ -59,13 +59,10 @@ const greetingText = $derived.by(() => {
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1> <h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
</div> </div>
{/if} {/if}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} /> <DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<ThemenWidget tags={data.tagTree ?? []} />
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="flex flex-col gap-5">
<EnrichmentBlock <EnrichmentBlock
topDocs={data.incompleteDocs ?? []} topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0} totalCount={data.incompleteTotal ?? 0}
@@ -88,12 +85,12 @@ const greetingText = $derived.by(() => {
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]"> <div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} /> <DashboardFamilyPulse pulse={data.pulse ?? null} />
<ThemenWidget tags={data.tagTree ?? []} compact={true} />
<DashboardActivityFeed feed={data.activityFeed ?? []} /> <DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite} {#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} /> <DropZone onUploadComplete={(count) => (bannerCount = count)} />
{/if} {/if}
</div> </div>
</div> </div>
</div>
{/if} {/if}
</main> </main>

View File

@@ -396,56 +396,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
expect(result.isReader).toBe(false); expect(result.isReader).toBe(false);
}); });
it('maps search result items directly to recentDocs without wrapping in a .document property', async () => {
const searchItem = {
id: 'd1',
title: 'Liebesbrief',
originalFilename: 'letter.pdf',
completionPercentage: 80,
receivers: [],
tags: [],
contributors: [],
matchData: { titleOffsets: [], senderMatched: false },
createdAt: '2026-05-01T10:00:00Z',
updatedAt: '2026-05-10T08:00:00Z'
};
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // initial persons
.mockResolvedValueOnce({
response: { ok: true },
data: { totalDocuments: 1, totalPersons: 1 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // topPersons
.mockResolvedValueOnce({
response: { ok: true },
data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 }
}) // search
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // stories
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
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.recentDocs).toHaveLength(1);
expect(result.recentDocs[0]).toBeDefined();
expect(result.recentDocs[0].id).toBe('d1');
expect(result.recentDocs[0].createdAt).toBe('2026-05-01T10:00:00Z');
expect(result.recentDocs[0].updatedAt).toBe('2026-05-10T08:00:00Z');
}
});
it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => { it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => {
const okStats = { const okStats = {
response: { ok: true, status: 200 }, response: { ok: true, status: 200 },

View File

@@ -40,7 +40,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
></div> ></div>
<a <a
href="/documents?tag={encodeURIComponent(tag.name)}" href="/?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0 aria-label="{tag.name}{tag.documentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount }) ? ', ' + m.themen_dokumente({ count: tag.documentCount })
: ''}" : ''}"
@@ -58,7 +58,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
{#each shownChildren as child (child.id)} {#each shownChildren as child (child.id)}
<a <a
href="/documents?tag={encodeURIComponent(child.name)}" href="/?tag={encodeURIComponent(child.name)}"
class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset" class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
> >
<span class="font-sans text-sm text-ink">{child.name}</span> <span class="font-sans text-sm text-ink">{child.name}</span>
@@ -71,7 +71,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
{#if hiddenCount > 0} {#if hiddenCount > 0}
<a <a
href="/documents?tag={encodeURIComponent(tag.name)}" href="/?tag={encodeURIComponent(tag.name)}"
class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset" class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
> >
{m.themen_weitere({ count: hiddenCount })} {m.themen_weitere({ count: hiddenCount })}