diff --git a/web/app/(app)/abteilungen/[key]/loading.tsx b/web/app/(app)/abteilungen/[key]/loading.tsx new file mode 100644 index 0000000..5ad90b9 --- /dev/null +++ b/web/app/(app)/abteilungen/[key]/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + + +
+
+ ) +} diff --git a/web/app/(app)/admin/abteilungen/loading.tsx b/web/app/(app)/admin/abteilungen/loading.tsx new file mode 100644 index 0000000..76c3dba --- /dev/null +++ b/web/app/(app)/admin/abteilungen/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + +
+
+ ) +} diff --git a/web/app/(app)/admin/loading.tsx b/web/app/(app)/admin/loading.tsx new file mode 100644 index 0000000..76c3dba --- /dev/null +++ b/web/app/(app)/admin/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + +
+
+ ) +} diff --git a/web/app/(app)/admin/protokoll/loading.tsx b/web/app/(app)/admin/protokoll/loading.tsx new file mode 100644 index 0000000..76c3dba --- /dev/null +++ b/web/app/(app)/admin/protokoll/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + +
+
+ ) +} diff --git a/web/app/(app)/admin/richtlinie/loading.tsx b/web/app/(app)/admin/richtlinie/loading.tsx new file mode 100644 index 0000000..c42fe2f --- /dev/null +++ b/web/app/(app)/admin/richtlinie/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + +
+
+ ) +} diff --git a/web/app/(app)/admin/rollen/loading.tsx b/web/app/(app)/admin/rollen/loading.tsx new file mode 100644 index 0000000..76c3dba --- /dev/null +++ b/web/app/(app)/admin/rollen/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + +
+
+ ) +} diff --git a/web/app/(app)/admin/system/loading.tsx b/web/app/(app)/admin/system/loading.tsx new file mode 100644 index 0000000..76c3dba --- /dev/null +++ b/web/app/(app)/admin/system/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + +
+
+ ) +} diff --git a/web/app/(app)/admin/vorlagen/loading.tsx b/web/app/(app)/admin/vorlagen/loading.tsx new file mode 100644 index 0000000..76c3dba --- /dev/null +++ b/web/app/(app)/admin/vorlagen/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + +
+
+ ) +} diff --git a/web/app/(app)/dokumente/[documentNumber]/export/loading.tsx b/web/app/(app)/dokumente/[documentNumber]/export/loading.tsx new file mode 100644 index 0000000..c42fe2f --- /dev/null +++ b/web/app/(app)/dokumente/[documentNumber]/export/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + +
+
+ ) +} diff --git a/web/app/(app)/einstellungen/loading.tsx b/web/app/(app)/einstellungen/loading.tsx new file mode 100644 index 0000000..5be3f4d --- /dev/null +++ b/web/app/(app)/einstellungen/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+
+ + +
+
+ + +
+ +
+
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/aenderungen/loading.tsx b/web/app/(app)/faelle/[caseNumber]/aenderungen/loading.tsx new file mode 100644 index 0000000..76c3dba --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/aenderungen/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + +
+
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/beteiligte/loading.tsx b/web/app/(app)/faelle/[caseNumber]/beteiligte/loading.tsx new file mode 100644 index 0000000..c42fe2f --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/beteiligte/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + +
+
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/dokumente/loading.tsx b/web/app/(app)/faelle/[caseNumber]/dokumente/loading.tsx new file mode 100644 index 0000000..c42fe2f --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/dokumente/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + +
+
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/export/loading.tsx b/web/app/(app)/faelle/[caseNumber]/export/loading.tsx new file mode 100644 index 0000000..c42fe2f --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/export/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + +
+
+ ) +} diff --git a/web/app/(app)/faelle/neu/loading.tsx b/web/app/(app)/faelle/neu/loading.tsx new file mode 100644 index 0000000..5be3f4d --- /dev/null +++ b/web/app/(app)/faelle/neu/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+
+ + +
+
+ + +
+ +
+
+ ) +} diff --git a/web/app/(app)/personalakten/neu/loading.tsx b/web/app/(app)/personalakten/neu/loading.tsx new file mode 100644 index 0000000..5be3f4d --- /dev/null +++ b/web/app/(app)/personalakten/neu/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+
+ + +
+
+ + +
+ +
+
+ ) +} diff --git a/web/app/(app)/suche/loading.tsx b/web/app/(app)/suche/loading.tsx new file mode 100644 index 0000000..d0243e6 --- /dev/null +++ b/web/app/(app)/suche/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + + +
+
+ ) +} diff --git a/web/app/(app)/uebersicht/loading.tsx b/web/app/(app)/uebersicht/loading.tsx new file mode 100644 index 0000000..8829491 --- /dev/null +++ b/web/app/(app)/uebersicht/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + + +
+
+ ) +} diff --git a/web/app/(app)/zugang/loading.tsx b/web/app/(app)/zugang/loading.tsx new file mode 100644 index 0000000..200924e --- /dev/null +++ b/web/app/(app)/zugang/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonLine } from '@/components/Skeleton' + +/** + * Skelett statt Spinner: die Struktur steht sofort, die Daten fallen hinein. + * Ohne diese Grenze wartet die Navigation stumm, bis der Server fertig ist — + * man klickt und es passiert scheinbar nichts. Siehe docs/09-performance-und-ux.md. + */ +export default function Loading() { + return ( +
+ +
+ + + + +
+
+ ) +} diff --git a/web/app/globals.css b/web/app/globals.css index d850037..591b0a3 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -313,6 +313,31 @@ background-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-raised)); } + /* Der Ladepunkt in einem Link, solange der Wechsel laeuft. Kein Kreisel: + eine ruhig pulsierende Scheibe in der Groesse eines Satzzeichens, die + nichts verdeckt und nichts aufhaelt. */ + .pending-dot { + display: inline-block; + width: 0.45rem; + height: 0.45rem; + flex: none; + border-radius: var(--radius-control); + background-color: currentColor; + animation: pending-pulse 900ms ease-in-out infinite; + } + + @keyframes pending-pulse { + 0%, + 100% { + opacity: 0.25; + transform: scale(0.75); + } + 50% { + opacity: 1; + transform: scale(1); + } + } + /* Die Zahl in einem Knopf: eine Angabe, kein Teil der Beschriftung. Sie sitzt deshalb ruhiger und in Mono. */ .btn-count { diff --git a/web/components/NavLinks.tsx b/web/components/NavLinks.tsx index 2a766d9..ddc49cb 100644 --- a/web/components/NavLinks.tsx +++ b/web/components/NavLinks.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' +import { Pending } from '@/components/Pending' /** * Die Bereiche der Anwendung in der Kopfzeile. @@ -29,6 +30,7 @@ export function NavLinks({ links }: { links: { href: string; label: string }[] } className="btn btn-sm" > {eintrag.label} + ) })} diff --git a/web/components/Pending.tsx b/web/components/Pending.tsx new file mode 100644 index 0000000..0a66823 --- /dev/null +++ b/web/components/Pending.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useLinkStatus } from 'next/link' + +/** + * Der Ladepunkt in einem Link, solange der Wechsel läuft. + * + * Ein Wechsel kann einen Moment dauern, und ohne Rückmeldung sieht es aus, als + * sei der Klick nicht angekommen — man klickt ein zweites Mal. Der Punkt sitzt + * **im geklickten Link**, damit sichtbar bleibt, wohin es geht. + * + * Das ist kein Blockade-Spinner im Sinn von Regel 5 aus CLAUDE.md: er ersetzt + * keinen Inhalt und hält nichts auf. Die Zielseite bringt ihr Skelett mit, das + * ist die eigentliche Antwort auf das Warten — hier geht es nur darum, den + * Klick zu quittieren. + * + * `useLinkStatus` funktioniert nur als Kind eines `next/link`, deshalb die + * eigene Komponente. + */ +export function Pending() { + const { pending } = useLinkStatus() + if (!pending) return null + return +} diff --git a/web/components/Tabs.tsx b/web/components/Tabs.tsx index dec9791..9d1b9da 100644 --- a/web/components/Tabs.tsx +++ b/web/components/Tabs.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' +import { Pending } from '@/components/Pending' /** * Eine Reiterleiste. @@ -37,6 +38,7 @@ export function Tabs({ tabs }: { tabs: { href: string; label: string }[] }) { className="btn btn-sm" > {reiter.label} + ) })} diff --git a/web/lib/dashboard/load.ts b/web/lib/dashboard/load.ts index 00b5d8f..fcc5af6 100644 --- a/web/lib/dashboard/load.ts +++ b/web/lib/dashboard/load.ts @@ -147,25 +147,35 @@ async function ungeleseneChats(p: EffectivePermissions): Promise { ] if (orte.length === 0) return [] - const zaehler = await Promise.all( - orte.map(async (ort) => { - const seit = markeVon.get(`${ort.scopeType}:${ort.scopeId}`) - const count = await db.chatMessage.count({ - where: { - AND: [ - visibleMessageWhere(p), - ort.wo, - { withdrawnAt: null }, - { authorId: { not: p.userId } }, - ...(seit ? [{ createdAt: { gt: seit } }] : []), - ], - }, - }) - return { ort, count } - }), + // **Eine** Abfrage statt einer je Ort. Vorher war das ein `count` pro Akte + // und pro Personalakte — bei hundert Orten hundert Hin- und Rückwege zur + // Datenbank, jeder für sich schnell und in der Summe die langsamste Stelle + // der Übersicht. Die Lesemarke steckt als Bedingung im ODER-Zweig des + // jeweiligen Ortes, gezählt wird über `groupBy`. + const zweige = orte.map((ort) => { + const seit = markeVon.get(`${ort.scopeType}:${ort.scopeId}`) + return seit ? { AND: [ort.wo, { createdAt: { gt: seit } }] } : ort.wo + }) + + const gruppen = await db.chatMessage.groupBy({ + by: ['caseId', 'personnelFileId'], + where: { + AND: [ + visibleMessageWhere(p), + { withdrawnAt: null }, + { authorId: { not: p.userId } }, + { OR: zweige }, + ], + }, + _count: { _all: true }, + }) + + const anzahlVon = new Map( + gruppen.map((gruppe) => [gruppe.caseId ?? gruppe.personnelFileId ?? '', gruppe._count._all]), ) - return zaehler + return orte + .map((ort) => ({ ort, count: anzahlVon.get(ort.scopeId) ?? 0 })) .filter((eintrag) => eintrag.count > 0) .sort((a, b) => b.count - a.count) .slice(0, 10) diff --git a/web/package.json b/web/package.json index cd1df05..5a1dbe9 100644 --- a/web/package.json +++ b/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "eslint app lib components tests --max-warnings 0", diff --git a/web/tests/uebersicht.test.ts b/web/tests/uebersicht.test.ts index 9448118..b98f5cb 100644 --- a/web/tests/uebersicht.test.ts +++ b/web/tests/uebersicht.test.ts @@ -130,4 +130,67 @@ suite('Übersicht und Beobachtungsliste', () => { const abgestiegen = perms(nutzer.stufe4, 1) expect((await listWatched(abgestiegen)).map((a) => a.id)).not.toContain(geheimId) }) + + it('zählt Ungelesenes je Ort und lässt Eigenes aus', async () => { + // Die Zählung lief einmal als eine Abfrage je Ort und ist jetzt eine + // einzige über alle. Geprüft wird deshalb, was dabei leicht verrutscht: + // die Lesemarke gilt **je Ort**, und eigene Nachrichten zählen nie mit. + const p = perms(nutzer.stufe1, 1) + const vorlage = await db.documentTemplate.findFirstOrThrow({ + where: { key: `${marke}-vorlage` }, + }) + const zweite = await db.case.create({ + data: { + templateId: vorlage.id, + templateKey: vorlage.key, + createdById: nutzer.stufe4, + caseNumber: `Ung-2026-${Date.now().toString(36)}`, + title: 'Zweiter Vorgang', + classification: 1, + status: 'OPEN', + }, + }) + await db.caseParticipant.create({ + data: { caseId: zweite.id, userId: nutzer.stufe1, role: 'INVESTIGATOR' }, + }) + + const erste = await db.case.findFirstOrThrow({ where: { caseNumber: offenNummer } }) + const frueher = new Date(Date.now() - 60_000) + + await db.chatMessage.createMany({ + data: [ + // Vor der Lesemarke der ersten Akte: zählt nicht. + { + id: `${marke}-alt`, + caseId: erste.id, + authorId: nutzer.stufe4, + body: 'alt', + createdAt: frueher, + }, + // Danach: zählt. + { id: `${marke}-neu`, caseId: erste.id, authorId: nutzer.stufe4, body: 'neu' }, + // Eigene Nachricht: zählt nie. + { id: `${marke}-eigen`, caseId: erste.id, authorId: nutzer.stufe1, body: 'eigen' }, + // Zweite Akte ohne Lesemarke: alles zählt. + { id: `${marke}-z1`, caseId: zweite.id, authorId: nutzer.stufe4, body: 'a' }, + { id: `${marke}-z2`, caseId: zweite.id, authorId: nutzer.stufe4, body: 'b' }, + ], + }) + + await db.readMarker.create({ + data: { + userId: nutzer.stufe1, + scopeType: 'case', + scopeId: erste.id, + lastReadAt: new Date(Date.now() - 30_000), + }, + }) + + const daten = await loadDashboard(p) + const zuErster = daten.ungelesen.find((u) => u.scopeId === erste.id) + const zuZweiter = daten.ungelesen.find((u) => u.scopeId === zweite.id) + + expect(zuErster?.count).toBe(1) + expect(zuZweiter?.count).toBe(2) + }) }) diff --git a/web/tsconfig.json b/web/tsconfig.json index a0d47a3..a4d6ceb 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -24,9 +24,13 @@ "include": [ "**/*.ts", "**/*.tsx", + ".next-build/types/**/*.ts", + ".next-mess1/types/**/*.ts", + ".next-mess2/types/**/*.ts", + ".next-mess3/types/**/*.ts", ".next/types/**/*.ts", "next-env.d.ts", - ".next-build/types/**/*.ts" + ".next-mess4/types/**/*.ts" ], "exclude": [ "node_modules",