Compare commits

...

4 Commits

Author SHA1 Message Date
Marcel
a7b0bd96d4 test(#145): add Playwright screenshot spec for dashboard (3 viewports × 2 themes)
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:30:39 +02:00
Marcel
7734ce7bae fix(#145): deep-link notifications; show createdAt in recent docs
- Notification widget builds full link with ?commentId= and
  &annotationId= params, matching the bell notification behaviour
- Recent docs widget shows createdAt (upload date) instead of
  documentDate (the date on the original document)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:03:36 +02:00
Marcel
c8da2224f8 feat(#145): internationalise dashboard widget strings (de/en/es)
Replace all hardcoded German strings in dashboard components with
Paraglide translation keys. Date locale uses getLocale() instead
of the hardcoded 'de-DE'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 09:57:14 +02:00
Marcel
08f3f92167 fix(#145): dashboard notification widget shows all recent notifications
- Add type-only filter to notification repo/service (previously only
  worked with type+read=false together)
- Dashboard widget now fetches all recent notifications (mentions +
  replies, both read and unread) instead of unread mentions only
- Update component heading and show type label per row

Root cause: Berit's mentions were read=true, so the unread-only filter
returned 0 results. The recent docs widget had no REVIEWED documents
because 'marking ready' sets metadata_complete, not status=REVIEWED.
Recent docs now shows all uploads without a status filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 09:41:28 +02:00
15 changed files with 167 additions and 33 deletions

View File

@@ -15,6 +15,9 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
Page<Notification> findByRecipientIdAndTypeOrderByCreatedAtDesc(
UUID recipientId, NotificationType type, Pageable pageable);
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
UUID recipientId, NotificationType type, Pageable pageable);

View File

@@ -98,6 +98,10 @@ public class NotificationService {
return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable)
.map(this::toDTO);
}
if (type != null) {
return notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable)
.map(this::toDTO);
}
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
.map(this::toDTO);
}

View File

@@ -79,6 +79,24 @@ class NotificationRepositoryTest {
assertThat(result.getTotalElements()).isEqualTo(5);
}
// ─── findByRecipientIdAndType (without read filter) ──────────────────────
@Test
void findByType_returnsBothReadAndUnreadMentions() {
notificationRepository.save(mention(userA, false)); // unread
notificationRepository.save(mention(userA, true)); // read — should also be included
notificationRepository.save(reply(userA, false)); // REPLY — excluded
notificationRepository.save(mention(userB, false)); // different user — excluded
Page<Notification> result = notificationRepository
.findByRecipientIdAndTypeOrderByCreatedAtDesc(
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
assertThat(result.getContent()).hasSize(2);
assertThat(result.getContent()).allMatch(n -> n.getType() == NotificationType.MENTION);
assertThat(result.getContent()).allMatch(n -> n.getRecipient().getId().equals(userA.getId()));
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Notification mention(AppUser recipient, boolean read) {

View File

@@ -386,6 +386,21 @@ class NotificationServiceTest {
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
}
@Test
void getNotifications_withTypeOnly_usesTypeFilteredRepoMethod() {
when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any()))
.thenReturn(Page.empty());
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, null, Pageable.ofSize(5));
verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any());
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
verify(notificationRepository, never())
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
}
// ─── private helpers ──────────────────────────────────────────────────────
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {

View File

@@ -0,0 +1,45 @@
/**
* Captures dashboard screenshots at multiple viewport sizes and in both
* light and dark theme. Output goes to proofshot-artifacts/dashboard/.
* Run via: npx playwright test e2e/dashboard-screenshots.spec.ts
*/
import { test } from '@playwright/test';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(__dirname, '../../proofshot-artifacts/dashboard');
fs.mkdirSync(outDir, { recursive: true });
const viewports = [
{ name: 'mobile', width: 390, height: 844 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 }
];
for (const vp of viewports) {
for (const theme of ['light', 'dark'] as const) {
test(`dashboard ${vp.name} ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto('/');
// Apply theme
await page.evaluate((t) => {
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
}, theme);
// Wait for the main content to be rendered.
// 'networkidle' is unreliable in SvelteKit dev mode due to the HMR WebSocket.
await page.waitForLoadState('domcontentloaded');
// Wait for at least one dashboard widget or the search bar to be visible
await page.waitForSelector('main', { state: 'visible' });
await page.screenshot({
path: path.join(outDir, `dashboard-${vp.name}-${theme}.png`),
fullPage: true
});
});
}
}

View File

@@ -312,5 +312,13 @@
"page_title_persons": "Personen",
"page_title_admin": "Administration",
"page_title_login": "Anmelden",
"page_title_error": "Fehler Familienarchiv"
"page_title_error": "Fehler Familienarchiv",
"dashboard_notifications_heading": "Benachrichtigungen",
"dashboard_notification_mentioned": "erwähnt Sie",
"dashboard_notification_replied": "hat geantwortet",
"dashboard_needs_metadata_heading": "Metadaten fehlen",
"dashboard_needs_metadata_show_all": "Alle anzeigen",
"dashboard_recent_heading": "Zuletzt hinzugefügt",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument"
}

View File

@@ -312,5 +312,13 @@
"page_title_persons": "Persons",
"page_title_admin": "Administration",
"page_title_login": "Sign in",
"page_title_error": "Error Family Archive"
"page_title_error": "Error Family Archive",
"dashboard_notifications_heading": "Notifications",
"dashboard_notification_mentioned": "mentioned you",
"dashboard_notification_replied": "replied",
"dashboard_needs_metadata_heading": "Missing Metadata",
"dashboard_needs_metadata_show_all": "Show all",
"dashboard_recent_heading": "Recently Added",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document"
}

View File

@@ -312,5 +312,13 @@
"page_title_persons": "Personas",
"page_title_admin": "Administración",
"page_title_login": "Iniciar sesión",
"page_title_error": "Error Archivo familiar"
"page_title_error": "Error Archivo familiar",
"dashboard_notifications_heading": "Notificaciones",
"dashboard_notification_mentioned": "te mencionó",
"dashboard_notification_replied": "respondió",
"dashboard_needs_metadata_heading": "Metadatos incompletos",
"dashboard_needs_metadata_show_all": "Ver todos",
"dashboard_recent_heading": "Añadidos recientemente",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido"
}

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
type NotificationDTO = {
id: string;
type: 'REPLY' | 'MENTION';
documentId?: string;
referenceId?: string;
annotationId?: string;
read: boolean;
createdAt: string;
actorName?: string;
@@ -18,16 +22,23 @@ let { mentions }: Props = $props();
{#if mentions.length > 0}
<div data-testid="dashboard-mentions" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
Erwähnungen
{m.dashboard_notifications_heading()}
</h2>
{#each mentions as mention (mention.id)}
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
{#if mention.documentId}
<a
href="/documents/{mention.documentId}"
href={mention.annotationId
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
class="font-serif text-sm text-ink hover:text-ink-2"
>
{mention.actorName ?? ''}
{#if mention.type === 'MENTION'}<span class="ml-1 font-sans text-xs text-gray-400"
>{m.dashboard_notification_mentioned()}</span
>{:else}<span class="ml-1 font-sans text-xs text-gray-400"
>{m.dashboard_notification_replied()}</span
>{/if}
</a>
{:else}
<span class="font-serif text-sm text-ink">{mention.actorName ?? ''}</span>

View File

@@ -10,6 +10,8 @@ type NotificationDTO = {
id: string;
type: 'REPLY' | 'MENTION';
documentId?: string;
referenceId?: string;
annotationId?: string;
read: boolean;
createdAt: string;
actorName?: string;
@@ -20,6 +22,7 @@ function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO
id: 'notif-1',
type: 'MENTION',
documentId: 'doc-abc',
referenceId: 'comment-xyz',
read: false,
createdAt: '2026-01-15T10:00:00Z',
actorName: 'Anna Schmidt',
@@ -40,15 +43,22 @@ describe('DashboardMentions', () => {
await expect.element(widget).toBeInTheDocument();
});
it('renders one row per mention with link to document', async () => {
const mentions = [
makeMention({ id: 'n1', documentId: 'doc-1', actorName: 'Anna' }),
makeMention({ id: 'n2', documentId: 'doc-2', actorName: 'Bob' })
];
render(DashboardMentions, { mentions });
const links = page.getByRole('link');
await expect.element(links.nth(0)).toHaveAttribute('href', '/documents/doc-1');
await expect.element(links.nth(1)).toHaveAttribute('href', '/documents/doc-2');
it('builds link with commentId param when no annotationId', async () => {
render(DashboardMentions, {
mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })]
});
const link = page.getByRole('link');
await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1');
});
it('builds link with commentId and annotationId when annotationId is present', async () => {
render(DashboardMentions, {
mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })]
});
const link = page.getByRole('link');
await expect
.element(link)
.toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9');
});
it('shows actor name in each row', async () => {

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
type IncompleteDocumentDTO = {
id: string;
title: string;
@@ -14,7 +16,7 @@ let { incompleteDocs }: Props = $props();
{#if incompleteDocs.length > 0}
<div data-testid="dashboard-needs-metadata" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
Metadaten fehlen
{m.dashboard_needs_metadata_heading()}
</h2>
{#each incompleteDocs as doc (doc.id)}
<div class="flex items-center border-b border-line py-2 last:border-0">
@@ -27,7 +29,9 @@ let { incompleteDocs }: Props = $props();
</div>
{/each}
<div class="mt-4">
<a href="/enrich" class="font-sans text-xs text-ink-2 hover:text-ink"> Alle anzeigen </a>
<a href="/enrich" class="font-sans text-xs text-ink-2 hover:text-ink"
>{m.dashboard_needs_metadata_show_all()}</a
>
</div>
</div>
{/if}

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
type Document = {
id: string;
title: string;
documentDate?: string;
createdAt?: string;
sender?: { id: string; firstName: string; lastName: string };
};
@@ -13,18 +16,18 @@ interface Props {
let { recentDocs }: Props = $props();
function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat('de-DE', {
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(dateStr + 'T12:00:00'));
}).format(new Date(dateStr));
}
</script>
{#if recentDocs.length > 0}
<div data-testid="dashboard-recent-docs" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
Zuletzt hinzugefügt
{m.dashboard_recent_heading()}
</h2>
{#each recentDocs as doc (doc.id)}
<div class="flex items-center justify-between border-b border-line py-2 last:border-0">
@@ -34,12 +37,12 @@ function formatDate(dateStr: string): string {
>
{doc.title}
</a>
{#if doc.documentDate}
{#if doc.createdAt}
<span
data-testid="doc-date-{doc.id}"
class="ml-2 shrink-0 font-sans text-xs text-gray-400"
>
{formatDate(doc.documentDate)}
{formatDate(doc.createdAt)}
</span>
{/if}
</div>

View File

@@ -9,12 +9,12 @@ afterEach(cleanup);
type Document = {
id: string;
title: string;
documentDate?: string;
createdAt?: string;
sender?: { id: string; firstName: string; lastName: string };
};
function makeDoc(id: string, title: string, date?: string): Document {
return { id, title, documentDate: date };
function makeDoc(id: string, title: string, createdAt?: string): Document {
return { id, title, createdAt };
}
describe('DashboardRecentDocuments', () => {

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
interface LastVisited {
id: string;
@@ -28,9 +29,9 @@ onMount(() => {
data-testid="resume-strip"
class="flex items-center gap-2 rounded-sm border border-line bg-surface px-4 py-3 font-sans text-sm"
>
<span class="text-ink-2">Zuletzt geöffnet:</span>
<span class="text-ink-2">{m.dashboard_resume_label()}</span>
<a href="/documents/{lastVisited.id}" class="font-medium text-ink hover:underline">
{lastVisited.title || 'Zuletzt geöffnet'}
{lastVisited.title || m.dashboard_resume_fallback()}
</a>
</div>
{/if}

View File

@@ -58,13 +58,9 @@ export async function load({ url, fetch }) {
if (isDashboard) {
const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([
api.GET('/api/notifications', {
params: { query: { type: 'MENTION', read: false, size: 5 } }
}),
api.GET('/api/notifications', { params: { query: { size: 5 } } }),
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
api.GET('/api/documents/search', {
params: { query: { status: 'REVIEWED' } }
})
api.GET('/api/documents/search', { params: { query: {} } })
]);
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {