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",