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
15 changed files with 90 additions and 182 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

@@ -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 await page.getByRole('searchbox').fill('Walter');
// time the await resolves, onSearch('Walter') has run and the fake // Focus the editor so the Escape lands on Tiptap's suggestion handler.
// debounce timer is set. (page.getByRole('textbox').element() as HTMLElement).focus();
await page.getByRole('searchbox').fill('Walter'); await userEvent.keyboard('{Escape}');
// Focus the editor so the Escape lands on Tiptap's suggestion handler.
(page.getByRole('textbox').element() as HTMLElement).focus(); // Wait past the debounce window. If onExit did not cancel the pending
await userEvent.keyboard('{Escape}'); // debounce, a fetch with q=Walter would still fire here.
// onExit has now called debouncedSearch.cancel(). Advance past the await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
// debounce window — the cancelled timer must not fire.
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS);
} finally {
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,40 +59,37 @@ 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="flex flex-col gap-5"> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} /> <div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<ThemenWidget tags={data.tagTree ?? []} /> <EnrichmentBlock
topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0}
bannerCount={bannerCount}
onBannerClose={() => (bannerCount = 0)}
/>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start"> <section aria-label={m.dashboard_mission_caption()}>
<div class="flex flex-col gap-5"> <h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
<EnrichmentBlock {m.dashboard_mission_caption()}
topDocs={data.incompleteDocs ?? []} </h2>
totalCount={data.incompleteTotal ?? 0} <MissionControlStrip
bannerCount={bannerCount} segmentationDocs={data.segmentationDocs ?? []}
onBannerClose={() => (bannerCount = 0)} transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/> />
</section>
</div>
<section aria-label={m.dashboard_mission_caption()}> <div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"> <DashboardFamilyPulse pulse={data.pulse ?? null} />
{m.dashboard_mission_caption()} <ThemenWidget tags={data.tagTree ?? []} compact={true} />
</h2> <DashboardActivityFeed feed={data.activityFeed ?? []} />
<MissionControlStrip {#if data.canWrite}
segmentationDocs={data.segmentationDocs ?? []} <DropZone onUploadComplete={(count) => (bannerCount = count)} />
transcriptionDocs={data.transcriptionDocs ?? []} {/if}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
</section>
</div>
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} />
<DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}

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