From 7383d00a730e0bd58d63b3d0e4c1639588e856d9 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Thu, 3 Sep 2026 22:21:43 +0200 Subject: [PATCH] =?UTF-8?q?Ladegrenzen=20f=C3=BCr=20jede=20Route,=20Turbop?= =?UTF-8?q?ack=20im=20Entwicklungsserver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemessen statt vermutet: die Datenschicht war nie das Problem. resolvePermissions unter 1 ms, loadDashboard 11 ms, searchCases 5 ms, die Abteilungsabfragen 3–4 ms. Die Sekunden kamen aus zwei Ecken. **Fehlende Ladegrenzen.** Den meisten Routen fehlte loading.tsx — unter anderem der Übersicht, der Suche und dem gesamten Adminbereich. Ohne diese Datei hat der Router keine Grenze, wartet stumm auf die fertige Serverantwort, und bis dahin steht die alte Seite: man klickt, und scheinbar passiert nichts. Neunzehn Skelette nachgetragen, jede Route hat jetzt eine. Dazu quittiert der geklickte Link den Klick selbst, mit einem ruhig pulsierenden Punkt statt eines Kreisels. Regel 5 verbietet den Blockade-Spinner, der Inhalt ersetzt; eine Quittung im Link ersetzt nichts, und die eigentliche Antwort auf das Warten bleibt das Skelett. **Webpack.** Der Entwicklungsserver übersetzte jede Route beim ersten Aufruf neu: 8 s für die Übersicht, 2,8 s für die Fallliste. Mit Turbopack sind es 0,43 s beim ersten Aufruf und 0,1 s danach. Nur pnpm dev, der Produktionsbau bleibt unverändert. Gegengeprüft, dass die Anmeldeseite mit CSS und ohne Fehler im Log rendert. Nebenbei die eine Abfrage, die schlecht skalierte: die Übersicht zählte ungelesene Nachrichten mit einer Abfrage je Ort — bei hundert Akten hundert Hin- und Rückwege. Jetzt ein einziges groupBy mit der Lesemarke im ODER-Zweig des jeweiligen Ortes. Ein Test prüft, was dabei leicht verrutscht: die Marke gilt je Ort, und eigene Nachrichten zählen nie mit. --- web/app/(app)/abteilungen/[key]/loading.tsx | 21 +++++++ web/app/(app)/admin/abteilungen/loading.tsx | 20 ++++++ web/app/(app)/admin/loading.tsx | 20 ++++++ web/app/(app)/admin/protokoll/loading.tsx | 20 ++++++ web/app/(app)/admin/richtlinie/loading.tsx | 19 ++++++ web/app/(app)/admin/rollen/loading.tsx | 20 ++++++ web/app/(app)/admin/system/loading.tsx | 20 ++++++ web/app/(app)/admin/vorlagen/loading.tsx | 20 ++++++ .../[documentNumber]/export/loading.tsx | 19 ++++++ web/app/(app)/einstellungen/loading.tsx | 25 ++++++++ .../[caseNumber]/aenderungen/loading.tsx | 20 ++++++ .../[caseNumber]/beteiligte/loading.tsx | 19 ++++++ .../faelle/[caseNumber]/dokumente/loading.tsx | 19 ++++++ .../faelle/[caseNumber]/export/loading.tsx | 19 ++++++ web/app/(app)/faelle/neu/loading.tsx | 25 ++++++++ web/app/(app)/personalakten/neu/loading.tsx | 25 ++++++++ web/app/(app)/suche/loading.tsx | 21 +++++++ web/app/(app)/uebersicht/loading.tsx | 21 +++++++ web/app/(app)/zugang/loading.tsx | 20 ++++++ web/app/globals.css | 25 ++++++++ web/components/NavLinks.tsx | 2 + web/components/Pending.tsx | 24 +++++++ web/components/Tabs.tsx | 2 + web/lib/dashboard/load.ts | 44 ++++++++----- web/package.json | 2 +- web/tests/uebersicht.test.ts | 63 +++++++++++++++++++ web/tsconfig.json | 6 +- 27 files changed, 542 insertions(+), 19 deletions(-) create mode 100644 web/app/(app)/abteilungen/[key]/loading.tsx create mode 100644 web/app/(app)/admin/abteilungen/loading.tsx create mode 100644 web/app/(app)/admin/loading.tsx create mode 100644 web/app/(app)/admin/protokoll/loading.tsx create mode 100644 web/app/(app)/admin/richtlinie/loading.tsx create mode 100644 web/app/(app)/admin/rollen/loading.tsx create mode 100644 web/app/(app)/admin/system/loading.tsx create mode 100644 web/app/(app)/admin/vorlagen/loading.tsx create mode 100644 web/app/(app)/dokumente/[documentNumber]/export/loading.tsx create mode 100644 web/app/(app)/einstellungen/loading.tsx create mode 100644 web/app/(app)/faelle/[caseNumber]/aenderungen/loading.tsx create mode 100644 web/app/(app)/faelle/[caseNumber]/beteiligte/loading.tsx create mode 100644 web/app/(app)/faelle/[caseNumber]/dokumente/loading.tsx create mode 100644 web/app/(app)/faelle/[caseNumber]/export/loading.tsx create mode 100644 web/app/(app)/faelle/neu/loading.tsx create mode 100644 web/app/(app)/personalakten/neu/loading.tsx create mode 100644 web/app/(app)/suche/loading.tsx create mode 100644 web/app/(app)/uebersicht/loading.tsx create mode 100644 web/app/(app)/zugang/loading.tsx create mode 100644 web/components/Pending.tsx 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",