Compare commits
9 Commits
main
...
d1d0acf029
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d0acf029 | ||
|
|
fc53c69aaf | ||
|
|
9084c8dd32 | ||
|
|
9d8e9c4531 | ||
|
|
1d032f52d9 | ||
|
|
41754fc052 | ||
|
|
f376fae600 | ||
|
|
94d5e69615 | ||
|
|
aad8382b76 |
@@ -6,7 +6,6 @@ import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -33,9 +32,5 @@ public record DocumentListItem(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<ActivityActorDTO> contributors,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
SearchMatchData matchData,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
LocalDateTime createdAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
LocalDateTime updatedAt
|
||||
SearchMatchData matchData
|
||||
) {}
|
||||
|
||||
@@ -767,9 +767,7 @@ public class DocumentService {
|
||||
doc.getSummary(),
|
||||
completionPct,
|
||||
contributors,
|
||||
match,
|
||||
doc.getCreatedAt(),
|
||||
doc.getUpdatedAt()
|
||||
match
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -135,8 +135,7 @@ class DocumentControllerTest {
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||
docId, "Brief an Anna", "brief.pdf", null, null, null,
|
||||
List.of(), List.of(), null, null, null, null,
|
||||
0, List.of(), matchData,
|
||||
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));
|
||||
0, List.of(), matchData))));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -154,8 +153,7 @@ class DocumentControllerTest {
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||
docId, "Brief an Anna", "brief.pdf", null, null, null,
|
||||
List.of(), List.of(), null, null, null, null,
|
||||
0, List.of(), matchData,
|
||||
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));
|
||||
0, List.of(), matchData))));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -5,7 +5,6 @@ import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -17,8 +16,7 @@ class DocumentSearchResultTest {
|
||||
return new DocumentListItem(
|
||||
docId, "Test", "test.pdf", null, null, null,
|
||||
List.of(), List.of(), null, null, null, null,
|
||||
0, List.of(), SearchMatchData.empty(),
|
||||
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0));
|
||||
0, List.of(), SearchMatchData.empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,8 +66,7 @@ class DocumentSearchResultTest {
|
||||
DocumentListItem item = new DocumentListItem(
|
||||
id, "T", "t.pdf", null, null, null,
|
||||
List.of(), List.of(), null, null, null, null,
|
||||
75, List.of(actor), SearchMatchData.empty(),
|
||||
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0));
|
||||
75, List.of(actor), SearchMatchData.empty());
|
||||
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ services:
|
||||
networks:
|
||||
- archiv-net
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# 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
|
||||
# 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
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
@@ -14,7 +14,7 @@ CMD ["npm", "run", "dev"]
|
||||
|
||||
# ── 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
|
||||
# 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.
|
||||
@@ -27,7 +27,7 @@ RUN npm run build
|
||||
|
||||
# ── Production ───────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /app/build ./build
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -23,9 +23,9 @@
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@inlang/paraglide-js": "^2.5.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.60.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.48.5",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -43,7 +43,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"playwright": "^1.60.0",
|
||||
"playwright": "^1.56.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
@@ -52,7 +52,7 @@
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"vite": "^7.3.3",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"vitest": "^4.0.10",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
|
||||
@@ -2205,10 +2205,10 @@ export interface components {
|
||||
totalStories: number;
|
||||
};
|
||||
PersonSummaryDTO: {
|
||||
title?: string;
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
title?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: int64 */
|
||||
@@ -2315,6 +2315,8 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
content?: components["schemas"]["NotificationDTO"][];
|
||||
@@ -2323,8 +2325,6 @@ export interface components {
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
empty?: boolean;
|
||||
};
|
||||
PageableObject: {
|
||||
@@ -2407,10 +2407,6 @@ export interface components {
|
||||
completionPercentage: number;
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
matchData: components["schemas"]["SearchMatchData"];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
DocumentSearchResult: {
|
||||
items: components["schemas"]["DocumentListItem"][];
|
||||
|
||||
@@ -3,16 +3,16 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
interface Props {
|
||||
documents: DocumentListItem[];
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
const { documents }: Props = $props();
|
||||
|
||||
function isNew(doc: DocumentListItem): boolean {
|
||||
return new Date(doc.createdAt).getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
function isNew(doc: Document): boolean {
|
||||
return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,33 +5,24 @@ import { page } from 'vitest/browser';
|
||||
import ReaderRecentDocs from './ReaderRecentDocs.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseDoc: DocumentListItem = {
|
||||
const baseDoc: Document = {
|
||||
id: 'doc1',
|
||||
title: 'Brief an Hans',
|
||||
originalFilename: 'brief.pdf',
|
||||
completionPercentage: 0,
|
||||
receivers: [],
|
||||
tags: [],
|
||||
contributors: [],
|
||||
matchData: {
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
},
|
||||
status: 'UPLOADED',
|
||||
metadataComplete: true,
|
||||
scriptType: 'HANDWRITING_KURRENT',
|
||||
createdAt: '2025-01-01T12:00:00Z',
|
||||
updatedAt: '2025-01-01T12:00:00Z'
|
||||
};
|
||||
|
||||
const updatedDoc: DocumentListItem = {
|
||||
const updatedDoc: Document = {
|
||||
...baseDoc,
|
||||
id: 'doc2',
|
||||
title: 'Urkunde 1920',
|
||||
@@ -97,14 +88,8 @@ describe('ReaderRecentDocs', () => {
|
||||
expect(thumb!.className).toMatch(/rounded-/);
|
||||
});
|
||||
|
||||
it('shows "Neu" accent-pill badge when document was created within the last 7 days', async () => {
|
||||
const recentDoc: DocumentListItem = {
|
||||
...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] });
|
||||
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();
|
||||
const cls = ((await badge.element()) as HTMLElement).className;
|
||||
@@ -113,7 +98,7 @@ describe('ReaderRecentDocs', () => {
|
||||
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] });
|
||||
const badge = page.getByText(/^Neu$/i);
|
||||
await expect.element(badge).not.toBeInTheDocument();
|
||||
@@ -121,20 +106,20 @@ describe('ReaderRecentDocs', () => {
|
||||
await expect.element(updatedBadge).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Neu" badge when document was created 6 days ago', async () => {
|
||||
const almostOldDoc: DocumentListItem = {
|
||||
it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => {
|
||||
const sameInstantDoc: Document = {
|
||||
...baseDoc,
|
||||
id: 'doc-almost-old',
|
||||
createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString()
|
||||
id: 'doc-same-instant',
|
||||
createdAt: '2025-01-01T12:00:00Z',
|
||||
updatedAt: '2025-01-01T12:00:00.000Z'
|
||||
};
|
||||
render(ReaderRecentDocs, { documents: [almostOldDoc] });
|
||||
render(ReaderRecentDocs, { documents: [sameInstantDoc] });
|
||||
const badge = page.getByText(/^Neu$/i);
|
||||
await expect.element(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sender name text when sender is present', async () => {
|
||||
const docWithSender: DocumentListItem = {
|
||||
const docWithSender: Document = {
|
||||
...baseDoc,
|
||||
sender: {
|
||||
id: 'p1',
|
||||
|
||||
@@ -31,25 +31,25 @@ describe('ReaderRecentDocs', () => {
|
||||
.toHaveAttribute('href', '/documents');
|
||||
});
|
||||
|
||||
it('renders the New badge when document was created within the last 7 days', 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();
|
||||
it('renders the New badge when createdAt equals updatedAt', async () => {
|
||||
render(ReaderRecentDocs, {
|
||||
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();
|
||||
});
|
||||
|
||||
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, {
|
||||
props: {
|
||||
documents: [
|
||||
makeDoc({
|
||||
createdAt: '2026-04-15T10:00:00Z',
|
||||
updatedAt: '2026-04-15T10:00:00Z'
|
||||
updatedAt: '2026-04-15T11:00:00Z'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,12 +10,9 @@ interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_TAGS = 6;
|
||||
|
||||
const { tags, compact = false }: Props = $props();
|
||||
|
||||
const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments));
|
||||
const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<a
|
||||
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()} →
|
||||
</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'}"
|
||||
data-compact={compact}
|
||||
>
|
||||
{#each shownTags as tag (tag.id)}
|
||||
{#each visibleTags as tag (tag.id)}
|
||||
<a
|
||||
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||
href="/?tag={encodeURIComponent(tag.name)}"
|
||||
aria-label="{tag.name}{tag.documentCount > 0
|
||||
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
||||
: ''}"
|
||||
|
||||
@@ -409,24 +409,19 @@ describe('PersonMentionEditor — onExit cancels pending debounce', () => {
|
||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
||||
const fetchesBeforeEscape = fetchMock.mock.calls.length;
|
||||
|
||||
// Freeze setTimeout so the 150 ms debounce cannot fire before Escape
|
||||
// triggers onExit. We install fake timers only now — after the setup
|
||||
// above — so that vi.waitFor()'s real-timer polling still worked.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// 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.
|
||||
// Trigger a new debounced search (queues runSearch after 150 ms), then
|
||||
// immediately Escape *while focus is back in the editor* so Tiptap's
|
||||
// suggestion-plugin Escape handler fires onExit before the debounce.
|
||||
// Without onExit cancelling the pending debounce, runSearch executes
|
||||
// against the now-unmounted dropdown's state.
|
||||
await page.getByRole('searchbox').fill('Walter');
|
||||
// Focus the editor so the Escape lands on Tiptap's suggestion handler.
|
||||
(page.getByRole('textbox').element() as HTMLElement).focus();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
// onExit has now called debouncedSearch.cancel(). Advance past the
|
||||
// debounce window — the cancelled timer must not fire.
|
||||
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
// Wait past the debounce window. If onExit did not cancel the pending
|
||||
// debounce, a fetch with q=Walter would still fire here.
|
||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
||||
|
||||
const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
|
||||
const walterFetches = newFetches.filter(
|
||||
|
||||
@@ -10,7 +10,7 @@ type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
type Document = components['schemas']['Document'];
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
@@ -55,8 +55,8 @@ export async function load({ fetch, parent }) {
|
||||
|
||||
const readerStats = settled<StatsDTO>(statsRes);
|
||||
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
|
||||
const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
|
||||
const recentDocs = searchData?.items ?? [];
|
||||
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
|
||||
const recentDocs = searchData?.items.map((i) => i.document) ?? [];
|
||||
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
|
||||
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
|
||||
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
|
||||
@@ -178,7 +178,7 @@ export async function load({ fetch, parent }) {
|
||||
incompleteTotal: 0,
|
||||
readerStats: null,
|
||||
topPersons: [] as PersonSummaryDTO[],
|
||||
recentDocs: [] as DocumentListItem[],
|
||||
recentDocs: [] as Document[],
|
||||
recentStories: [] as Geschichte[],
|
||||
tagTree: [] as TagTreeNodeDTO[],
|
||||
drafts: [] as Geschichte[],
|
||||
|
||||
@@ -59,13 +59,10 @@ const greetingText = $derived.by(() => {
|
||||
<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} />
|
||||
|
||||
<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
|
||||
topDocs={data.incompleteDocs ?? []}
|
||||
totalCount={data.incompleteTotal ?? 0}
|
||||
@@ -88,12 +85,12 @@ const greetingText = $derived.by(() => {
|
||||
|
||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||
<ThemenWidget tags={data.tagTree ?? []} compact={true} />
|
||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||
{#if data.canWrite}
|
||||
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -396,56 +396,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
||||
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 () => {
|
||||
const okStats = {
|
||||
response: { ok: true, status: 200 },
|
||||
|
||||
@@ -40,7 +40,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
|
||||
></div>
|
||||
|
||||
<a
|
||||
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||
href="/?tag={encodeURIComponent(tag.name)}"
|
||||
aria-label="{tag.name}{tag.documentCount > 0
|
||||
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
||||
: ''}"
|
||||
@@ -58,7 +58,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
|
||||
|
||||
{#each shownChildren as child (child.id)}
|
||||
<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"
|
||||
>
|
||||
<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}
|
||||
<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"
|
||||
>
|
||||
{m.themen_weitere({ count: hiddenCount })} →
|
||||
|
||||
Reference in New Issue
Block a user