Compare commits
19 Commits
d1d0acf029
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
643d504c7a | ||
|
|
c9f5f6d665 | ||
|
|
3f3d5e530c | ||
|
|
5dac1d993c | ||
|
|
264d60c855 | ||
|
|
e6a0c2f6d6 | ||
|
|
80d77a53e9 | ||
|
|
a45652466e | ||
|
|
49a17b581b | ||
|
|
53c8d6e9f0 | ||
|
|
279b4f1098 | ||
|
|
15114c2d92 | ||
|
|
35017d91c4 | ||
|
|
5b367a53a1 | ||
|
|
cb91ed340d | ||
|
|
2e0eb40aec | ||
|
|
d9e01ef1ff | ||
|
|
2e0f85c360 | ||
|
|
a1035171c2 |
@@ -197,6 +197,7 @@ frontend/src/routes/
|
|||||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||||
├── stammbaum/ Family tree (Stammbaum)
|
├── stammbaum/ Family tree (Stammbaum)
|
||||||
|
├── themen/ Topics directory — browsable tag index
|
||||||
├── enrich/ Enrichment workflow — [id], done
|
├── enrich/ Enrichment workflow — [id], done
|
||||||
├── admin/ User, group, tag, OCR, system management
|
├── admin/ User, group, tag, OCR, system management
|
||||||
├── hilfe/transkription/ Transcription help page
|
├── hilfe/transkription/ Transcription help page
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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;
|
||||||
|
|
||||||
@@ -32,5 +33,9 @@ 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
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -767,7 +767,9 @@ public class DocumentService {
|
|||||||
doc.getSummary(),
|
doc.getSummary(),
|
||||||
completionPct,
|
completionPct,
|
||||||
contributors,
|
contributors,
|
||||||
match
|
match,
|
||||||
|
doc.getCreatedAt(),
|
||||||
|
doc.getUpdatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ 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())
|
||||||
@@ -153,7 +154,8 @@ 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())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
|
|
||||||
@@ -16,7 +17,8 @@ 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
|
||||||
@@ -66,7 +68,8 @@ 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));
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- archiv-net
|
- archiv-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health | grep -q UP || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
|||||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||||
|
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||||
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications"
|
|||||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||||
|
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||||
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
||||||
|
|
||||||
|
|||||||
@@ -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:20.19.0-alpine3.21 AS development
|
FROM node:22-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:20.19.0-alpine3.21 AS build
|
FROM node:22-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:20.19.0-alpine3.21 AS production
|
FROM node:22-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
|
||||||
|
|||||||
@@ -1084,5 +1084,10 @@
|
|||||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt",
|
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt",
|
||||||
"error_page_id_label": "Fehler-ID",
|
"error_page_id_label": "Fehler-ID",
|
||||||
"error_copy_id_label": "ID kopieren",
|
"error_copy_id_label": "ID kopieren",
|
||||||
"error_copied": "Kopiert!"
|
"error_copied": "Kopiert!",
|
||||||
|
"themen_widget_title": "Themen",
|
||||||
|
"themen_alle": "Alle Themen",
|
||||||
|
"themen_leer": "Noch keine Themen vergeben.",
|
||||||
|
"themen_weitere": "+ {count} weitere",
|
||||||
|
"themen_dokumente": "{count} Dokumente"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,10 @@
|
|||||||
"timeline_dragging_aria_live": "Range {from} to {to} selected",
|
"timeline_dragging_aria_live": "Range {from} to {to} selected",
|
||||||
"error_page_id_label": "Error ID",
|
"error_page_id_label": "Error ID",
|
||||||
"error_copy_id_label": "Copy ID",
|
"error_copy_id_label": "Copy ID",
|
||||||
"error_copied": "Copied!"
|
"error_copied": "Copied!",
|
||||||
|
"themen_widget_title": "Topics",
|
||||||
|
"themen_alle": "All Topics",
|
||||||
|
"themen_leer": "No topics assigned yet.",
|
||||||
|
"themen_weitere": "+ {count} more",
|
||||||
|
"themen_dokumente": "{count} documents"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,10 @@
|
|||||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado",
|
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado",
|
||||||
"error_page_id_label": "ID de error",
|
"error_page_id_label": "ID de error",
|
||||||
"error_copy_id_label": "Copiar ID",
|
"error_copy_id_label": "Copiar ID",
|
||||||
"error_copied": "¡Copiado!"
|
"error_copied": "¡Copiado!",
|
||||||
|
"themen_widget_title": "Temas",
|
||||||
|
"themen_alle": "Todos los temas",
|
||||||
|
"themen_leer": "Aún no hay temas.",
|
||||||
|
"themen_weitere": "+ {count} más",
|
||||||
|
"themen_dokumente": "{count} documentos"
|
||||||
}
|
}
|
||||||
|
|||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -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.58.2",
|
"@playwright/test": "^1.60.0",
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/kit": "^2.48.5",
|
"@sveltejs/kit": "^2.60.1",
|
||||||
"@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.56.1",
|
"playwright": "^1.60.0",
|
||||||
"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.2.2",
|
"vite": "^7.3.3",
|
||||||
"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"
|
||||||
|
|||||||
@@ -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,8 +2315,6 @@ 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"][];
|
||||||
@@ -2325,6 +2323,8 @@ 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,6 +2407,10 @@ 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"][];
|
||||||
|
|||||||
@@ -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 Document = components['schemas']['Document'];
|
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
documents: Document[];
|
documents: DocumentListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { documents }: Props = $props();
|
const { documents }: Props = $props();
|
||||||
|
|
||||||
function isNew(doc: Document): boolean {
|
function isNew(doc: DocumentListItem): boolean {
|
||||||
return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime();
|
return new Date(doc.createdAt).getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,33 @@ 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 Document = components['schemas']['Document'];
|
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseDoc: Document = {
|
const baseDoc: DocumentListItem = {
|
||||||
id: 'doc1',
|
id: 'doc1',
|
||||||
title: 'Brief an Hans',
|
title: 'Brief an Hans',
|
||||||
originalFilename: 'brief.pdf',
|
originalFilename: 'brief.pdf',
|
||||||
status: 'UPLOADED',
|
completionPercentage: 0,
|
||||||
metadataComplete: true,
|
receivers: [],
|
||||||
scriptType: 'HANDWRITING_KURRENT',
|
tags: [],
|
||||||
|
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: Document = {
|
const updatedDoc: DocumentListItem = {
|
||||||
...baseDoc,
|
...baseDoc,
|
||||||
id: 'doc2',
|
id: 'doc2',
|
||||||
title: 'Urkunde 1920',
|
title: 'Urkunde 1920',
|
||||||
@@ -88,8 +97,14 @@ describe('ReaderRecentDocs', () => {
|
|||||||
expect(thumb!.className).toMatch(/rounded-/);
|
expect(thumb!.className).toMatch(/rounded-/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => {
|
it('shows "Neu" accent-pill badge when document was created within the last 7 days', async () => {
|
||||||
render(ReaderRecentDocs, { documents: [baseDoc] });
|
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] });
|
||||||
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;
|
||||||
@@ -98,7 +113,7 @@ describe('ReaderRecentDocs', () => {
|
|||||||
expect(cls).toMatch(/\btext-ink\b/);
|
expect(cls).toMatch(/\btext-ink\b/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows no badge when updatedAt differs from createdAt', async () => {
|
it('shows no badge when document was created more than 7 days ago', 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();
|
||||||
@@ -106,20 +121,20 @@ describe('ReaderRecentDocs', () => {
|
|||||||
await expect.element(updatedBadge).not.toBeInTheDocument();
|
await expect.element(updatedBadge).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => {
|
it('shows "Neu" badge when document was created 6 days ago', async () => {
|
||||||
const sameInstantDoc: Document = {
|
const almostOldDoc: DocumentListItem = {
|
||||||
...baseDoc,
|
...baseDoc,
|
||||||
id: 'doc-same-instant',
|
id: 'doc-almost-old',
|
||||||
createdAt: '2025-01-01T12:00:00Z',
|
createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
updatedAt: '2025-01-01T12:00:00.000Z'
|
updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
};
|
};
|
||||||
render(ReaderRecentDocs, { documents: [sameInstantDoc] });
|
render(ReaderRecentDocs, { documents: [almostOldDoc] });
|
||||||
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: Document = {
|
const docWithSender: DocumentListItem = {
|
||||||
...baseDoc,
|
...baseDoc,
|
||||||
sender: {
|
sender: {
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
|
|||||||
@@ -31,25 +31,25 @@ describe('ReaderRecentDocs', () => {
|
|||||||
.toHaveAttribute('href', '/documents');
|
.toHaveAttribute('href', '/documents');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the New badge when createdAt equals updatedAt', async () => {
|
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();
|
||||||
render(ReaderRecentDocs, {
|
render(ReaderRecentDocs, {
|
||||||
props: {
|
props: {
|
||||||
documents: [
|
documents: [makeDoc({ createdAt: recentDate, updatedAt: laterUpdate })]
|
||||||
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 updated after creation', async () => {
|
it('hides the New badge when document was created more than 7 days ago', 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-15T11:00:00Z'
|
updatedAt: '2026-04-15T10:00:00Z'
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
67
frontend/src/lib/shared/dashboard/ThemenWidget.svelte
Normal file
67
frontend/src/lib/shared/dashboard/ThemenWidget.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { hasAnyDocuments } from '$lib/shared/utils/tagUtils';
|
||||||
|
|
||||||
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tags: TagTreeNodeDTO[];
|
||||||
|
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">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.themen_widget_title()}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{m.themen_alle()} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if visibleTags.length === 0}
|
||||||
|
<p class="font-sans text-sm text-ink-3">{m.themen_leer()}</p>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}"
|
||||||
|
data-compact={compact}
|
||||||
|
>
|
||||||
|
{#each shownTags as tag (tag.id)}
|
||||||
|
<a
|
||||||
|
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||||
|
aria-label="{tag.name}{tag.documentCount > 0
|
||||||
|
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
||||||
|
: ''}"
|
||||||
|
class="flex cursor-pointer items-stretch overflow-hidden rounded-sm border border-line bg-canvas hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||||
|
style="min-height: 56px"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-1 flex-shrink-0 self-stretch"
|
||||||
|
aria-hidden="true"
|
||||||
|
style="background: var(--c-tag-{tag.color ?? 'slate'})"
|
||||||
|
></span>
|
||||||
|
<span class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-3">
|
||||||
|
<span class="truncate font-serif text-sm font-semibold text-ink">{tag.name}</span>
|
||||||
|
{#if tag.documentCount > 0}
|
||||||
|
<span class="font-sans text-xs text-ink-3 tabular-nums">
|
||||||
|
{m.themen_dokumente({ count: tag.documentCount })}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import ThemenWidget from './ThemenWidget.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
|
|
||||||
|
function makeTag(
|
||||||
|
name: string,
|
||||||
|
documentCount: number,
|
||||||
|
children: TagTreeNodeDTO[] = []
|
||||||
|
): TagTreeNodeDTO {
|
||||||
|
return { id: 'id-' + name, name, documentCount, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ThemenWidget', () => {
|
||||||
|
it('renders a card link per visible tag', async () => {
|
||||||
|
const tags = [makeTag('Briefe', 5), makeTag('Fotos', 3)];
|
||||||
|
const { getByRole } = render(ThemenWidget, { tags });
|
||||||
|
await expect.element(getByRole('link', { name: /Briefe/ })).toBeInTheDocument();
|
||||||
|
await expect.element(getByRole('link', { name: /Fotos/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides tags where no document exists in the subtree', async () => {
|
||||||
|
const tags = [makeTag('Briefe', 5), makeTag('Leer', 0)];
|
||||||
|
render(ThemenWidget, { tags });
|
||||||
|
expect(document.body.textContent).toContain('Briefe');
|
||||||
|
expect(document.body.textContent).not.toContain('Leer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state text when all tags are filtered out', async () => {
|
||||||
|
render(ThemenWidget, { tags: [makeTag('Leer', 0)] });
|
||||||
|
expect(document.body.textContent).toMatch(/Noch keine Themen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when tags array is empty', async () => {
|
||||||
|
render(ThemenWidget, { tags: [] });
|
||||||
|
expect(document.body.textContent).toMatch(/Noch keine Themen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders in compact single-column mode when compact prop is true', async () => {
|
||||||
|
const tags = [makeTag('Briefe', 5)];
|
||||||
|
const { container } = render(ThemenWidget, { tags, compact: true });
|
||||||
|
const grid = container.querySelector('[data-compact="true"]');
|
||||||
|
expect(grid).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to "Alle Themen" page', async () => {
|
||||||
|
const tags = [makeTag('Briefe', 5)];
|
||||||
|
const { getByRole } = render(ThemenWidget, { tags });
|
||||||
|
const link = getByRole('link', { name: /Alle Themen/ });
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/themen');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -409,19 +409,24 @@ 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;
|
||||||
|
|
||||||
// Trigger a new debounced search (queues runSearch after 150 ms), then
|
// Freeze setTimeout so the 150 ms debounce cannot fire before Escape
|
||||||
// immediately Escape *while focus is back in the editor* so Tiptap's
|
// triggers onExit. We install fake timers only now — after the setup
|
||||||
// suggestion-plugin Escape handler fires onExit before the debounce.
|
// above — so that vi.waitFor()'s real-timer polling still worked.
|
||||||
// Without onExit cancelling the pending debounce, runSearch executes
|
vi.useFakeTimers();
|
||||||
// against the now-unmounted dropdown's state.
|
try {
|
||||||
await page.getByRole('searchbox').fill('Walter');
|
// fill() dispatches the input event synchronously via CDP; by the
|
||||||
// Focus the editor so the Escape lands on Tiptap's suggestion handler.
|
// time the await resolves, onSearch('Walter') has run and the fake
|
||||||
(page.getByRole('textbox').element() as HTMLElement).focus();
|
// debounce timer is set.
|
||||||
await userEvent.keyboard('{Escape}');
|
await page.getByRole('searchbox').fill('Walter');
|
||||||
|
// Focus the editor so the Escape lands on Tiptap's suggestion handler.
|
||||||
// Wait past the debounce window. If onExit did not cancel the pending
|
(page.getByRole('textbox').element() as HTMLElement).focus();
|
||||||
// debounce, a fetch with q=Walter would still fire here.
|
await userEvent.keyboard('{Escape}');
|
||||||
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
|
const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
|
||||||
const walterFetches = newFetches.filter(
|
const walterFetches = newFetches.filter(
|
||||||
|
|||||||
29
frontend/src/lib/shared/utils/tagUtils.test.ts
Normal file
29
frontend/src/lib/shared/utils/tagUtils.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { hasAnyDocuments } from './tagUtils';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
|
|
||||||
|
function makeNode(documentCount: number, children: TagTreeNodeDTO[] = []): TagTreeNodeDTO {
|
||||||
|
return { id: 'id', name: 'name', documentCount, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hasAnyDocuments', () => {
|
||||||
|
it('returns false for a leaf node with documentCount=0', () => {
|
||||||
|
expect(hasAnyDocuments(makeNode(0))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for a leaf node with documentCount=3', () => {
|
||||||
|
expect(hasAnyDocuments(makeNode(3))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for a root with documentCount=0 but a child with documentCount=5', () => {
|
||||||
|
const node = makeNode(0, [makeNode(5)]);
|
||||||
|
expect(hasAnyDocuments(node)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for a root with documentCount=0 and all children also 0', () => {
|
||||||
|
const node = makeNode(0, [makeNode(0), makeNode(0)]);
|
||||||
|
expect(hasAnyDocuments(node)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
7
frontend/src/lib/shared/utils/tagUtils.ts
Normal file
7
frontend/src/lib/shared/utils/tagUtils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
|
|
||||||
|
export function hasAnyDocuments(node: TagTreeNodeDTO): boolean {
|
||||||
|
return (node.documentCount ?? 0) > 0 || (node.children ?? []).some(hasAnyDocuments);
|
||||||
|
}
|
||||||
@@ -10,8 +10,9 @@ 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 Document = components['schemas']['Document'];
|
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||||
type Geschichte = components['schemas']['Geschichte'];
|
type Geschichte = components['schemas']['Geschichte'];
|
||||||
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
|
|
||||||
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
|
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
|
||||||
if (res?.status !== 'fulfilled') return null;
|
if (res?.status !== 'fulfilled') return null;
|
||||||
@@ -40,7 +41,8 @@ export async function load({ fetch, parent }) {
|
|||||||
api.GET('/api/documents/search', {
|
api.GET('/api/documents/search', {
|
||||||
params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } }
|
params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } }
|
||||||
}),
|
}),
|
||||||
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } })
|
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } }),
|
||||||
|
api.GET('/api/tags/tree')
|
||||||
];
|
];
|
||||||
if (canBlogWrite) {
|
if (canBlogWrite) {
|
||||||
readerFetches.push(
|
readerFetches.push(
|
||||||
@@ -48,14 +50,15 @@ export async function load({ fetch, parent }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] =
|
const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, tagTreeRes, draftsRes] =
|
||||||
await Promise.allSettled(readerFetches);
|
await Promise.allSettled(readerFetches);
|
||||||
|
|
||||||
const readerStats = settled<StatsDTO>(statsRes);
|
const readerStats = settled<StatsDTO>(statsRes);
|
||||||
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
|
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
|
||||||
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
|
const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
|
||||||
const recentDocs = searchData?.items.map((i) => i.document) ?? [];
|
const recentDocs = searchData?.items ?? [];
|
||||||
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
|
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
|
||||||
|
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
|
||||||
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
|
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -65,6 +68,7 @@ export async function load({ fetch, parent }) {
|
|||||||
topPersons,
|
topPersons,
|
||||||
recentDocs,
|
recentDocs,
|
||||||
recentStories,
|
recentStories,
|
||||||
|
tagTree,
|
||||||
drafts,
|
drafts,
|
||||||
error: null as string | null
|
error: null as string | null
|
||||||
};
|
};
|
||||||
@@ -80,7 +84,8 @@ export async function load({ fetch, parent }) {
|
|||||||
readyResult,
|
readyResult,
|
||||||
weeklyStatsResult,
|
weeklyStatsResult,
|
||||||
incompleteResult,
|
incompleteResult,
|
||||||
incompleteCountResult
|
incompleteCountResult,
|
||||||
|
tagTreeResult
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
api.GET('/api/stats'),
|
api.GET('/api/stats'),
|
||||||
api.GET('/api/dashboard/resume'),
|
api.GET('/api/dashboard/resume'),
|
||||||
@@ -91,7 +96,8 @@ export async function load({ fetch, parent }) {
|
|||||||
api.GET('/api/transcription/ready-to-read'),
|
api.GET('/api/transcription/ready-to-read'),
|
||||||
api.GET('/api/transcription/weekly-stats'),
|
api.GET('/api/transcription/weekly-stats'),
|
||||||
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
||||||
api.GET('/api/documents/incomplete-count')
|
api.GET('/api/documents/incomplete-count'),
|
||||||
|
api.GET('/api/tags/tree')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let stats: StatsDTO | null = null;
|
let stats: StatsDTO | null = null;
|
||||||
@@ -104,6 +110,7 @@ export async function load({ fetch, parent }) {
|
|||||||
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
|
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
|
||||||
let incompleteDocs: IncompleteDocumentDTO[] = [];
|
let incompleteDocs: IncompleteDocumentDTO[] = [];
|
||||||
let incompleteTotal = 0;
|
let incompleteTotal = 0;
|
||||||
|
let tagTree: TagTreeNodeDTO[] = [];
|
||||||
|
|
||||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||||
stats = statsResult.value.data ?? null;
|
stats = statsResult.value.data ?? null;
|
||||||
@@ -135,6 +142,9 @@ export async function load({ fetch, parent }) {
|
|||||||
if (incompleteCountResult.status === 'fulfilled' && incompleteCountResult.value.response.ok) {
|
if (incompleteCountResult.status === 'fulfilled' && incompleteCountResult.value.response.ok) {
|
||||||
incompleteTotal = (incompleteCountResult.value.data?.count as number | undefined) ?? 0;
|
incompleteTotal = (incompleteCountResult.value.data?.count as number | undefined) ?? 0;
|
||||||
}
|
}
|
||||||
|
if (tagTreeResult.status === 'fulfilled' && tagTreeResult.value.response.ok) {
|
||||||
|
tagTree = (tagTreeResult.value.data as TagTreeNodeDTO[]) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isReader: false as const,
|
isReader: false as const,
|
||||||
@@ -148,6 +158,7 @@ export async function load({ fetch, parent }) {
|
|||||||
weeklyStats,
|
weeklyStats,
|
||||||
incompleteDocs,
|
incompleteDocs,
|
||||||
incompleteTotal,
|
incompleteTotal,
|
||||||
|
tagTree,
|
||||||
error: null as string | null
|
error: null as string | null
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -167,8 +178,9 @@ export async function load({ fetch, parent }) {
|
|||||||
incompleteTotal: 0,
|
incompleteTotal: 0,
|
||||||
readerStats: null,
|
readerStats: null,
|
||||||
topPersons: [] as PersonSummaryDTO[],
|
topPersons: [] as PersonSummaryDTO[],
|
||||||
recentDocs: [] as Document[],
|
recentDocs: [] as DocumentListItem[],
|
||||||
recentStories: [] as Geschichte[],
|
recentStories: [] as Geschichte[],
|
||||||
|
tagTree: [] as TagTreeNodeDTO[],
|
||||||
drafts: [] as Geschichte[],
|
drafts: [] as Geschichte[],
|
||||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte';
|
|||||||
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
|
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
|
||||||
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
|
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
|
||||||
import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte';
|
import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte';
|
||||||
|
import ThemenWidget from '$lib/shared/dashboard/ThemenWidget.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -45,6 +46,8 @@ const greetingText = $derived.by(() => {
|
|||||||
|
|
||||||
<ReaderPersonChips persons={data.topPersons ?? []} />
|
<ReaderPersonChips persons={data.topPersons ?? []} />
|
||||||
|
|
||||||
|
<ThemenWidget tags={data.tagTree ?? []} />
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||||
<ReaderRecentDocs documents={data.recentDocs ?? []} />
|
<ReaderRecentDocs documents={data.recentDocs ?? []} />
|
||||||
<ReaderRecentStories stories={data.recentStories ?? []} />
|
<ReaderRecentStories stories={data.recentStories ?? []} />
|
||||||
@@ -56,36 +59,40 @@ 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} />
|
|
||||||
|
|
||||||
<EnrichmentBlock
|
<ThemenWidget tags={data.tagTree ?? []} />
|
||||||
topDocs={data.incompleteDocs ?? []}
|
|
||||||
totalCount={data.incompleteTotal ?? 0}
|
|
||||||
bannerCount={bannerCount}
|
|
||||||
onBannerClose={() => (bannerCount = 0)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section aria-label={m.dashboard_mission_caption()}>
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
<div class="flex flex-col gap-5">
|
||||||
{m.dashboard_mission_caption()}
|
<EnrichmentBlock
|
||||||
</h2>
|
topDocs={data.incompleteDocs ?? []}
|
||||||
<MissionControlStrip
|
totalCount={data.incompleteTotal ?? 0}
|
||||||
segmentationDocs={data.segmentationDocs ?? []}
|
bannerCount={bannerCount}
|
||||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
onBannerClose={() => (bannerCount = 0)}
|
||||||
readyDocs={data.readyDocs ?? []}
|
|
||||||
weeklyStats={data.weeklyStats ?? null}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
<section aria-label={m.dashboard_mission_caption()}>
|
||||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
{m.dashboard_mission_caption()}
|
||||||
{#if data.canWrite}
|
</h2>
|
||||||
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
|
<MissionControlStrip
|
||||||
{/if}
|
segmentationDocs={data.segmentationDocs ?? []}
|
||||||
|
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||||
|
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}
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ describe('home page load — dashboard', () => {
|
|||||||
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
|
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
|
||||||
}) // weekly-stats
|
}) // weekly-stats
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count
|
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) // incomplete-count
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
>);
|
>);
|
||||||
@@ -146,7 +147,8 @@ describe('home page load — dashboard', () => {
|
|||||||
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
|
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
|
||||||
}) // weekly-stats
|
}) // weekly-stats
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count
|
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) // incomplete-count
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
>);
|
>);
|
||||||
@@ -394,6 +396,56 @@ 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 },
|
||||||
@@ -409,7 +461,8 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
|
|||||||
.mockResolvedValueOnce(okStats)
|
.mockResolvedValueOnce(okStats)
|
||||||
.mockReturnValueOnce(failPersons)
|
.mockReturnValueOnce(failPersons)
|
||||||
.mockResolvedValueOnce(okSearch)
|
.mockResolvedValueOnce(okSearch)
|
||||||
.mockResolvedValueOnce(okStories);
|
.mockResolvedValueOnce(okStories)
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // tags/tree
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
>);
|
>);
|
||||||
|
|||||||
12
frontend/src/routes/themen/+page.server.ts
Normal file
12
frontend/src/routes/themen/+page.server.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
|
|
||||||
|
export async function load({ fetch }: Parameters<import('./$types').PageServerLoad>[0]) {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.GET('/api/tags/tree');
|
||||||
|
if (!result.response.ok) throw error(500, 'Themen konnten nicht geladen werden.');
|
||||||
|
return { tree: (result.data ?? []) as TagTreeNodeDTO[] };
|
||||||
|
}
|
||||||
85
frontend/src/routes/themen/+page.svelte
Normal file
85
frontend/src/routes/themen/+page.svelte
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||||
|
import { hasAnyDocuments } from '$lib/shared/utils/tagUtils';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
|
|
||||||
|
const MAX_VISIBLE_CHILDREN = 5;
|
||||||
|
|
||||||
|
let { data }: { data: { tree: TagTreeNodeDTO[] } } = $props();
|
||||||
|
|
||||||
|
const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{m.themen_widget_title()}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-6 flex items-center gap-3">
|
||||||
|
<BackButton />
|
||||||
|
<h1 class="font-serif text-2xl font-semibold text-ink">{m.themen_widget_title()}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if visibleTree.length === 0}
|
||||||
|
<p class="font-sans text-sm text-ink-3">{m.themen_leer()}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each visibleTree as tag (tag.id)}
|
||||||
|
{@const visibleChildren = (tag.children ?? []).filter(hasAnyDocuments)}
|
||||||
|
{@const shownChildren = visibleChildren.slice(0, MAX_VISIBLE_CHILDREN)}
|
||||||
|
{@const hiddenCount = visibleChildren.length - shownChildren.length}
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||||
|
<div
|
||||||
|
class="h-1.5 w-full flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
style="background: var(--c-tag-{tag.color ?? 'slate'})"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/documents?tag={encodeURIComponent(tag.name)}"
|
||||||
|
aria-label="{tag.name}{tag.documentCount > 0
|
||||||
|
? ', ' + m.themen_dokumente({ count: tag.documentCount })
|
||||||
|
: ''}"
|
||||||
|
class="flex min-h-[56px] items-center justify-between px-4 pt-4 pb-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
|
||||||
|
>
|
||||||
|
<span class="font-serif text-base font-semibold text-ink">{tag.name}</span>
|
||||||
|
<span class="mr-1 ml-auto font-sans text-sm text-ink-3 tabular-nums">
|
||||||
|
{#if tag.documentCount > 0}{tag.documentCount}{/if}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" class="h-3.5 w-3.5 flex-shrink-0 text-brand-mint">›</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if shownChildren.length > 0}
|
||||||
|
<div class="mx-4 border-t border-line"></div>
|
||||||
|
|
||||||
|
{#each shownChildren as child (child.id)}
|
||||||
|
<a
|
||||||
|
href="/documents?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>
|
||||||
|
<span class="mr-1 ml-auto font-sans text-xs text-ink-3 tabular-nums">
|
||||||
|
{#if child.documentCount > 0}{child.documentCount}{/if}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" class="h-3 w-3 flex-shrink-0 text-brand-mint">›</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if hiddenCount > 0}
|
||||||
|
<a
|
||||||
|
href="/documents?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 })} →
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
60
frontend/src/routes/themen/page.server.spec.ts
Normal file
60
frontend/src/routes/themen/page.server.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/shared/api.server', () => ({
|
||||||
|
createApiClient: vi.fn(),
|
||||||
|
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
function mockApiGet(ok: boolean, data: unknown) {
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({
|
||||||
|
GET: vi.fn().mockResolvedValue({ response: { ok }, data })
|
||||||
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeTag = (name: string, documentCount = 0) => ({
|
||||||
|
id: 'id-' + name,
|
||||||
|
name,
|
||||||
|
documentCount,
|
||||||
|
children: []
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/themen +page.server load', () => {
|
||||||
|
function makeLoadEvent() {
|
||||||
|
return {
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
request: new Request('http://localhost/themen'),
|
||||||
|
url: new URL('http://localhost/themen')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns tag tree when API succeeds', async () => {
|
||||||
|
const tree = [makeTag('Briefe', 5), makeTag('Fotos', 3)];
|
||||||
|
mockApiGet(true, tree);
|
||||||
|
|
||||||
|
const { load } = await import('./+page.server');
|
||||||
|
const result = await load(makeLoadEvent());
|
||||||
|
|
||||||
|
expect(result.tree).toEqual(tree);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when API returns empty list', async () => {
|
||||||
|
mockApiGet(true, []);
|
||||||
|
|
||||||
|
const { load } = await import('./+page.server');
|
||||||
|
const result = await load(makeLoadEvent());
|
||||||
|
|
||||||
|
expect(result.tree).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 500 when API call fails', async () => {
|
||||||
|
mockApiGet(false, null);
|
||||||
|
|
||||||
|
const { load } = await import('./+page.server');
|
||||||
|
|
||||||
|
await expect(load(makeLoadEvent())).rejects.toMatchObject({ status: 500 });
|
||||||
|
});
|
||||||
|
});
|
||||||
57
frontend/src/routes/themen/page.svelte.spec.ts
Normal file
57
frontend/src/routes/themen/page.svelte.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import ThemenPage from './+page.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
|
|
||||||
|
function makeTag(
|
||||||
|
name: string,
|
||||||
|
documentCount: number,
|
||||||
|
children: TagTreeNodeDTO[] = []
|
||||||
|
): TagTreeNodeDTO {
|
||||||
|
return { id: 'id-' + name, name, documentCount, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('/themen +page', () => {
|
||||||
|
it('renders one card per visible root tag', async () => {
|
||||||
|
const tree = [makeTag('Briefe', 5), makeTag('Fotos', 3)];
|
||||||
|
render(ThemenPage, { data: { tree } });
|
||||||
|
expect(document.body.textContent).toContain('Briefe');
|
||||||
|
expect(document.body.textContent).toContain('Fotos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render a tag with no documents in its subtree', async () => {
|
||||||
|
const tree = [makeTag('Briefe', 5), makeTag('Leer', 0)];
|
||||||
|
render(ThemenPage, { data: { tree } });
|
||||||
|
expect(document.body.textContent).not.toContain('Leer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when all tags filtered out', async () => {
|
||||||
|
render(ThemenPage, { data: { tree: [makeTag('Leer', 0)] } });
|
||||||
|
expect(document.body.textContent).toMatch(/Noch keine Themen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when tree is empty', async () => {
|
||||||
|
render(ThemenPage, { data: { tree: [] } });
|
||||||
|
expect(document.body.textContent).toMatch(/Noch keine Themen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders child tags for a root tag', async () => {
|
||||||
|
const tree = [makeTag('Briefe', 5, [makeTag('Brautbriefe', 3), makeTag('Kriegsbriefe', 2)])];
|
||||||
|
render(ThemenPage, { data: { tree } });
|
||||||
|
expect(document.body.textContent).toContain('Brautbriefe');
|
||||||
|
expect(document.body.textContent).toContain('Kriegsbriefe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "+ N weitere" when a root tag has more than 5 children', async () => {
|
||||||
|
const children = Array.from({ length: 7 }, (_, i) => makeTag(`Kind${i}`, i + 1));
|
||||||
|
const tree = [makeTag('Briefe', 10, children)];
|
||||||
|
render(ThemenPage, { data: { tree } });
|
||||||
|
expect(document.body.textContent).toMatch(/\+\s*2\s*weitere/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user