Phase 9 abgeschlossen: Übersicht, Beobachtungsliste, helles Thema, Messung

Die Übersicht beantwortet vier Fragen, die man morgens hat: woran arbeite ich,
was ist fällig, wo hat jemand geschrieben, was hat sich getan. Jede Liste
läuft durch visibleCaseWhere - das ist der Ort, an dem eine unachtsame
Abfrage am ehesten eine Akte verrät. Ein Test prüft jede einzeln gegen eine zu
hoch eingestufte Akte. Der bisherige Inhalt ("Dein Zugang") steht jetzt unter
/zugang.

Beobachten ist eine Merkliste, kein Recht: nur was sichtbar ist, lässt sich
merken, und steigt eine Akte später über die eigene Stufe, verschwindet sie
aus der Liste, ohne dass jemand aufräumt.

Das helle Thema tauscht nur die semantische Ebene; die Grundpalette bleibt.
Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, im Farbton
aber dieselben - eine Stufe muss in beiden Themen wiedererkennbar sein. Drei
Zustände, "wie das System" ist der Auslieferungszustand, und ein Skript im
Kopf setzt die Wahl, bevor das erste Mal gemalt wird.

Die Messung misst nicht den Nutzer. web-vitals schickt Zahlen und das
Routenmuster, keine Kennung und keinen konkreten Pfad: wer wann welche Akte
geöffnet hat, steht im Zugriffsprotokoll, und eine zweite Spur daneben wäre
eine Überwachungsfunktion ohne Auftrag. Ausgewertet wird Median und 75.
Perzentil, nicht der Mittelwert.

Mobil: die breiten Tabellen scrollen in sich, statt die Seite breiter zu
machen. Ein e2e-Test hält fest, dass keine Seite waagerecht überläuft.

scripts/restore-test.sh spielt eine Sicherung in eine Wegwerf-Datenbank und
prüft ausdrücklich, dass Regeln und Trigger mitkommen: eine Wiederherstellung
ohne entry_no_delete wäre eine Datenbank ohne die Zusicherung, um derentwillen
das System gebaut wird. Einmal durchgespielt, alles angekommen.

186 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 54 e2e.
Grösste Route 116,2 von 120 kB.
This commit is contained in:
Harlekin
2026-09-03 10:28:50 +02:00
parent d2a7910861
commit addef20e16
26 changed files with 1381 additions and 85 deletions
+56
View File
@@ -0,0 +1,56 @@
import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { AccessError } from '@/lib/errors'
import { loadVisibleCase } from '@/lib/cases/service'
import type { EffectivePermissions } from '@/lib/auth/permissions'
/**
* Beobachtungsliste.
*
* Wer eine Akte beobachtet, sieht sie auf der Übersicht und bekommt ihre
* Meldungen, auch ohne beteiligt zu sein. **Zugriff verschafft das nicht**:
* beobachten kann man nur, was man ohnehin sehen darf, und fällt die Akte
* später über die eigene Stufe, verschwindet sie auch aus der Liste — die
* Abfrage läuft durch dieselbe Sichtbarkeitsprüfung wie die Fallliste.
*/
export async function listWatched(p: EffectivePermissions, take = 20) {
return db.case.findMany({
where: { AND: [visibleCaseWhere(p), { watches: { some: { userId: p.userId } } }] },
orderBy: [{ updatedAt: 'desc' }],
take,
select: {
id: true,
caseNumber: true,
title: true,
status: true,
classification: true,
updatedAt: true,
},
})
}
export async function isWatching(p: EffectivePermissions, caseId: string): Promise<boolean> {
const eintrag = await db.watch.findUnique({
where: { userId_caseId: { userId: p.userId, caseId } },
select: { userId: true },
})
return eintrag !== null
}
/** Umschalten. Gibt zurück, ob danach beobachtet wird. */
export async function toggleWatch(
p: EffectivePermissions,
caseNumber: string,
): Promise<{ watching: boolean }> {
const akte = await loadVisibleCase(p, caseNumber)
if (!akte) throw new AccessError('not-found')
const vorhanden = await isWatching(p, akte.id)
if (vorhanden) {
await db.watch.delete({ where: { userId_caseId: { userId: p.userId, caseId: akte.id } } })
return { watching: false }
}
await db.watch.create({ data: { userId: p.userId, caseId: akte.id } })
return { watching: true }
}
+169
View File
@@ -0,0 +1,169 @@
import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { listWatched } from '@/lib/cases/watch'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import type { Prisma } from '@afis/db'
/**
* Was auf der Übersicht steht.
*
* Vier Fragen, die man morgens hat: woran arbeite ich, was ist fällig, wo hat
* jemand geschrieben, was hat sich getan. Jede läuft durch
* `visibleCaseWhere` — die Übersicht ist der Ort, an dem eine unachtsame
* Abfrage am ehesten eine Akte verrät, die man nicht sehen darf.
*/
const AKTE = {
id: true,
caseNumber: true,
title: true,
status: true,
classification: true,
updatedAt: true,
dueAt: true,
} satisfies Prisma.CaseSelect
/** Fristen innerhalb dieser Anzahl Tage gelten als „steht an". */
const FRIST_TAGE = 14
export async function loadDashboard(p: EffectivePermissions) {
const sichtbar = visibleCaseWhere(p)
const jetzt = new Date()
const frist = new Date(jetzt.getTime() + FRIST_TAGE * 86_400_000)
const [meine, fristen, zuletzt, beobachtet, ungelesen] = await Promise.all([
db.case.findMany({
where: {
AND: [
sichtbar,
{ participants: { some: { userId: p.userId } } },
{ status: { in: ['DRAFT', 'OPEN', 'REVIEW'] } },
],
},
orderBy: { updatedAt: 'desc' },
take: 10,
select: AKTE,
}),
db.case.findMany({
where: {
AND: [
sichtbar,
{ dueAt: { not: null, lte: frist } },
{ status: { notIn: ['CLOSED', 'ARCHIVED', 'SEALED'] } },
],
},
orderBy: { dueAt: 'asc' },
take: 10,
select: AKTE,
}),
db.case.findMany({
where: sichtbar,
orderBy: { updatedAt: 'desc' },
take: 8,
select: AKTE,
}),
listWatched(p, 10),
ungeleseneChats(p),
])
return { meine, fristen, zuletzt, beobachtet, ungelesen }
}
export type UnreadChat = {
scopeType: 'case' | 'channel'
scopeId: string
label: string
href: string
count: number
}
/**
* Wo seit dem letzten Besuch geschrieben wurde.
*
* Gezählt wird über `ReadMarker`. Nur an Orten, an denen der Nutzer beteiligt
* ist, die er beobachtet, oder in Kanälen, die er sehen darf — eine Zählung
* über alle sichtbaren Akten wäre auf der Übersicht nur Lärm. Eigene
* Nachrichten zählen nicht mit.
*/
async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
const marken = await db.readMarker.findMany({
where: { userId: p.userId },
select: { scopeType: true, scopeId: true, lastReadAt: true },
})
const markeVon = new Map(marken.map((m) => [`${m.scopeType}:${m.scopeId}`, m.lastReadAt]))
const [meineAkten, kanaele] = await Promise.all([
db.case.findMany({
where: {
AND: [
visibleCaseWhere(p),
{
OR: [
{ participants: { some: { userId: p.userId } } },
{ watches: { some: { userId: p.userId } } },
],
},
],
},
select: { id: true, caseNumber: true },
take: 50,
}),
db.channel.findMany({
where: {
isArchived: false,
...(p.isAdmin ? {} : { minClearance: { lte: p.clearanceLevel } }),
},
select: { id: true, key: true },
take: 50,
}),
])
const orte = [
...meineAkten.map((akte) => ({
scopeType: 'case' as const,
scopeId: akte.id,
label: akte.caseNumber,
href: `/faelle/${akte.caseNumber}`,
wo: { caseId: akte.id } as Prisma.ChatMessageWhereInput,
})),
...kanaele.map((kanal) => ({
scopeType: 'channel' as const,
scopeId: kanal.id,
label: `#${kanal.key}`,
href: `/kanaele/${kanal.key}`,
wo: { channelId: kanal.id } as Prisma.ChatMessageWhereInput,
})),
]
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 }
}),
)
return zaehler
.filter((eintrag) => eintrag.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 10)
.map(({ ort, count }) => ({
scopeType: ort.scopeType,
scopeId: ort.scopeId,
label: ort.label,
href: ort.href,
count,
}))
}
+74
View File
@@ -0,0 +1,74 @@
import { db } from '@/lib/db'
/**
* Zusammenfassung der Ladewerte für den Adminbereich.
*
* Ausgewertet wird der **Median und das 75. Perzentil**, nicht der Mittelwert:
* ein einzelner Ausreisser auf einem alten Laptop soll die Zahl nicht
* bestimmen. Zielwerte aus docs/09-performance-und-ux.md.
*/
const TAGE = 7
export type VitalSummary = {
metric: string
count: number
median: number
p75: number
}
export async function summarizeVitals(): Promise<{
metriken: VitalSummary[]
routen: { route: string; metric: string; p75: number; count: number }[]
}> {
const seit = new Date(Date.now() - TAGE * 86_400_000)
const metriken = await db.$queryRaw<
{ metric: string; count: bigint; median: number; p75: number }[]
>`
SELECT "metric",
count(*) AS count,
percentile_cont(0.5) WITHIN GROUP (ORDER BY "value") AS median,
percentile_cont(0.75) WITHIN GROUP (ORDER BY "value") AS p75
FROM "WebVital"
WHERE "at" >= ${seit}
GROUP BY "metric"
ORDER BY "metric"
`
const routen = await db.$queryRaw<
{ route: string; metric: string; p75: number; count: bigint }[]
>`
SELECT "route", "metric",
percentile_cont(0.75) WITHIN GROUP (ORDER BY "value") AS p75,
count(*) AS count
FROM "WebVital"
WHERE "at" >= ${seit} AND "metric" = 'LCP'
GROUP BY "route", "metric"
HAVING count(*) >= 3
ORDER BY p75 DESC
LIMIT 8
`
return {
metriken: metriken.map((zeile) => ({
metric: zeile.metric,
count: Number(zeile.count),
median: zeile.median,
p75: zeile.p75,
})),
routen: routen.map((zeile) => ({
route: zeile.route,
metric: zeile.metric,
p75: zeile.p75,
count: Number(zeile.count),
})),
}
}
/** Millisekunden lesbar machen; CLS ist dimensionslos. */
export function formatVital(metric: string, wert: number): string {
if (metric === 'CLS') return wert.toFixed(3)
if (wert >= 1000) return `${(wert / 1000).toFixed(2)} s`
return `${Math.round(wert)} ms`
}
+21
View File
@@ -85,3 +85,24 @@ export const settingsText = {
OPEN: 'Jederzeit änderbar, sofern das Recht vorliegt',
} as Record<string, string>,
} as const
export const vitalsText = {
heading: 'Ladewerte',
intro:
'Gemessen in den Browsern der Nutzer, letzte sieben Tage. Nur Zahlen und das Routenmuster — wer wann welche Akte geöffnet hat, steht im Zugriffsprotokoll.',
empty: 'Noch nichts gemessen.',
metric: 'Wert',
median: 'Median',
p75: '75. Perzentil',
count: 'Messungen',
target: 'Ziel',
worstRoutes: 'Langsamste Routen',
route: 'Route',
targets: {
LCP: 'unter 1,5 s',
INP: 'unter 200 ms',
CLS: 'unter 0,05',
TTFB: 'unter 500 ms',
FCP: 'unter 1,0 s',
} as Record<string, string>,
} as const
+8 -1
View File
@@ -23,6 +23,9 @@ import {
notifyText,
searchText,
paletteText,
themeText,
dashboardText,
watchText,
} from '@/lib/i18n/shell'
import { commonText, actionErrorsText, errorsText, clearanceText } from '@/lib/i18n/common'
import {
@@ -36,7 +39,7 @@ import { chatText, channelText } from '@/lib/i18n/chat'
import { personnelText, assignmentText } from '@/lib/i18n/people'
import { documentText, attachmentText } from '@/lib/i18n/documents'
import { exportText } from '@/lib/i18n/export'
import { roleAdminText, templateAdminText, settingsText } from '@/lib/i18n/admin'
import { roleAdminText, templateAdminText, settingsText, vitalsText } from '@/lib/i18n/admin'
export const de = {
app: appText,
@@ -62,8 +65,12 @@ export const de = {
errors: errorsText,
templateAdmin: templateAdminText,
settings: settingsText,
vitals: vitalsText,
health: healthText,
notify: notifyText,
search: searchText,
palette: paletteText,
theme: themeText,
dashboard: dashboardText,
watch: watchText,
} as const
+31
View File
@@ -56,6 +56,30 @@ export const authText = {
backToSignIn: 'Zurück zur Anmeldung',
} as const
export const dashboardText = {
heading: 'Übersicht',
mine: 'Meine Fälle',
mineEmpty: 'Du bist an keiner offenen Akte beteiligt.',
due: 'Fristen',
dueEmpty: 'Nichts steht an.',
dueAt: (datum: string) => `fällig ${datum}`,
overdue: 'überfällig',
unread: 'Ungelesen',
unreadEmpty: 'Nichts Neues.',
unreadCount: (n: number) => (n === 1 ? '1 Nachricht' : `${n} Nachrichten`),
watched: 'Beobachtet',
watchedEmpty: 'Keine Akte beobachtet.',
recent: 'Zuletzt geändert',
recentEmpty: 'Noch keine Akte.',
access: 'Dein Zugang',
} as const
export const watchText = {
watch: 'Beobachten',
unwatch: 'Nicht mehr beobachten',
hint: 'Beobachtete Akten stehen auf deiner Übersicht und melden sich, auch ohne Beteiligung.',
} as const
export const overviewText = {
heading: 'Dein Zugang',
intro: 'Was das System über dich weiss und was daraus folgt.',
@@ -154,3 +178,10 @@ export const paletteText = {
{ href: '/einstellungen', label: 'Benachrichtigungen' },
],
} as const
export const themeText = {
label: 'Thema',
system: 'Wie das System',
dark: 'Dunkel',
light: 'Hell',
} as const