diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java index 7cbc6496..bf8c19a3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java @@ -6,6 +6,7 @@ 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; @@ -32,5 +33,9 @@ public record DocumentListItem( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List contributors, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - SearchMatchData matchData + SearchMatchData matchData, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime createdAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime updatedAt ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index cfbbf848..65bd7cd7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -767,7 +767,9 @@ public class DocumentService { doc.getSummary(), completionPct, contributors, - match + match, + doc.getCreatedAt(), + doc.getUpdatedAt() ); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index f7c24541..fe15ba3b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -135,7 +135,8 @@ 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)))); + 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")) .andExpect(status().isOk()) @@ -153,7 +154,8 @@ 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)))); + 0, List.of(), matchData, + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0))))); mockMvc.perform(get("/api/documents/search")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java index 1dd09fed..a487e272 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java @@ -5,6 +5,7 @@ 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; @@ -16,7 +17,8 @@ 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()); + 0, List.of(), SearchMatchData.empty(), + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)); } @Test @@ -66,7 +68,8 @@ 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()); + 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)); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a408615d..9738729a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,9 +23,9 @@ "@eslint/compat": "^1.4.0", "@eslint/js": "^9.39.1", "@inlang/paraglide-js": "^2.5.0", - "@playwright/test": "^1.58.2", - "@sveltejs/adapter-node": "^5.4.0", - "@sveltejs/kit": "^2.48.5", + "@playwright/test": "^1.60.0", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.60.1", "@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.56.1", + "playwright": "^1.60.0", "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.2.2", + "vite": "^7.3.3", "vite-plugin-devtools-json": "^1.0.0", "vitest": "^4.0.10", "vitest-browser-svelte": "^2.0.1" diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 9a9a5408..62f94fd8 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -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,8 +2315,6 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -2325,6 +2323,8 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; + first?: boolean; + last?: boolean; empty?: boolean; }; PageableObject: { @@ -2407,6 +2407,10 @@ 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"][]; diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte index 9f54d6b5..98985f95 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -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 Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; interface Props { - documents: Document[]; + documents: DocumentListItem[]; } const { documents }: Props = $props(); -function isNew(doc: Document): boolean { - return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime(); +function isNew(doc: DocumentListItem): boolean { + return new Date(doc.createdAt).getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000; } diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts index c13c92b1..7fa8ab9d 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -5,24 +5,33 @@ import { page } from 'vitest/browser'; import ReaderRecentDocs from './ReaderRecentDocs.svelte'; import type { components } from '$lib/generated/api'; -type Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; afterEach(() => { cleanup(); }); -const baseDoc: Document = { +const baseDoc: DocumentListItem = { id: 'doc1', title: 'Brief an Hans', originalFilename: 'brief.pdf', - status: 'UPLOADED', - metadataComplete: true, - scriptType: 'HANDWRITING_KURRENT', + completionPercentage: 0, + receivers: [], + tags: [], + contributors: [], + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, createdAt: '2025-01-01T12:00:00Z', updatedAt: '2025-01-01T12:00:00Z' }; -const updatedDoc: Document = { +const updatedDoc: DocumentListItem = { ...baseDoc, id: 'doc2', title: 'Urkunde 1920', @@ -88,8 +97,14 @@ describe('ReaderRecentDocs', () => { expect(thumb!.className).toMatch(/rounded-/); }); - it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => { - render(ReaderRecentDocs, { documents: [baseDoc] }); + 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] }); const badge = page.getByText(/^Neu$/i); await expect.element(badge).toBeInTheDocument(); const cls = ((await badge.element()) as HTMLElement).className; @@ -98,7 +113,7 @@ describe('ReaderRecentDocs', () => { 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] }); const badge = page.getByText(/^Neu$/i); await expect.element(badge).not.toBeInTheDocument(); @@ -106,20 +121,20 @@ describe('ReaderRecentDocs', () => { await expect.element(updatedBadge).not.toBeInTheDocument(); }); - it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => { - const sameInstantDoc: Document = { + it('shows "Neu" badge when document was created 6 days ago', async () => { + const almostOldDoc: DocumentListItem = { ...baseDoc, - id: 'doc-same-instant', - createdAt: '2025-01-01T12:00:00Z', - updatedAt: '2025-01-01T12:00:00.000Z' + 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() }; - render(ReaderRecentDocs, { documents: [sameInstantDoc] }); + render(ReaderRecentDocs, { documents: [almostOldDoc] }); const badge = page.getByText(/^Neu$/i); await expect.element(badge).toBeInTheDocument(); }); it('renders sender name text when sender is present', async () => { - const docWithSender: Document = { + const docWithSender: DocumentListItem = { ...baseDoc, sender: { id: 'p1', diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts index d15e6f7f..6c93c55e 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts @@ -31,25 +31,25 @@ describe('ReaderRecentDocs', () => { .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, { props: { - documents: [ - makeDoc({ createdAt: '2026-04-15T10:00:00Z', updatedAt: '2026-04-15T10:00:00Z' }) - ] + documents: [makeDoc({ createdAt: recentDate, updatedAt: laterUpdate })] } }); 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, { props: { documents: [ makeDoc({ createdAt: '2026-04-15T10:00:00Z', - updatedAt: '2026-04-15T11:00:00Z' + updatedAt: '2026-04-15T10:00:00Z' }) ] } diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 3b58a62f..a0486e6d 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -409,19 +409,24 @@ 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; - // 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}'); - - // 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)); + // 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. + 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(); + } const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape); const walterFetches = newFetches.filter( diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index b217452f..ed229042 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -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 Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; type Geschichte = components['schemas']['Geschichte']; function settled(res: PromiseSettledResult | undefined): T | null { @@ -53,8 +53,8 @@ export async function load({ fetch, parent }) { const readerStats = settled(statsRes); const topPersons = settled(topPersonsRes) ?? []; - const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes); - const recentDocs = searchData?.items.map((i) => i.document) ?? []; + const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes); + const recentDocs = searchData?.items ?? []; const recentStories = settled(recentStoriesRes) ?? []; const drafts = settled(draftsRes) ?? []; @@ -167,7 +167,7 @@ export async function load({ fetch, parent }) { incompleteTotal: 0, readerStats: null, topPersons: [] as PersonSummaryDTO[], - recentDocs: [] as Document[], + recentDocs: [] as DocumentListItem[], recentStories: [] as Geschichte[], drafts: [] as Geschichte[], error: 'Daten konnten nicht geladen werden.' as string | null diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index b87ce9a8..a04cf1fe 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -394,6 +394,55 @@ 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 + 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[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 },