From 3466215e2c2d9fe7a0be324707218383ed767080 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Thu, 3 Sep 2026 10:28:50 +0200 Subject: [PATCH] =?UTF-8?q?Phase=209=20abgeschlossen:=20=C3=9Cbersicht,=20?= =?UTF-8?q?Beobachtungsliste,=20helles=20Thema,=20Messung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 5 --- TASKS.md | 14 +- docs/13-entscheidungen.md | 58 ++++ .../20260903200000_messwerte/migration.sql | 19 ++ packages/db/prisma/schema.prisma | 20 ++ pnpm-lock.yaml | 8 + scripts/restore-test.sh | 75 ++++++ web/app/(app)/admin/system/page.tsx | 74 ++++++ .../(app)/faelle/[caseNumber]/WatchToggle.tsx | 43 +++ web/app/(app)/faelle/[caseNumber]/layout.tsx | 8 +- web/app/(app)/faelle/actions.ts | 20 ++ web/app/(app)/layout.tsx | 4 + web/app/(app)/uebersicht/page.tsx | 249 ++++++++++++------ web/app/(app)/zugang/page.tsx | 95 +++++++ web/app/api/vitals/route.ts | 59 +++++ web/app/globals.css | 56 +++- web/app/layout.tsx | 20 +- web/components/ThemeToggle.tsx | 59 +++++ web/components/Vitals.tsx | 80 ++++++ web/e2e/smoke.spec.ts | 23 ++ web/lib/cases/watch.ts | 56 ++++ web/lib/dashboard/load.ts | 169 ++++++++++++ web/lib/dashboard/vitals.ts | 74 ++++++ web/lib/i18n/admin.ts | 21 ++ web/lib/i18n/de.ts | 9 +- web/lib/i18n/shell.ts | 31 +++ web/package.json | 1 + web/tests/thema.test.ts | 60 +++++ web/tests/uebersicht.test.ts | 133 ++++++++++ 28 files changed, 1446 insertions(+), 92 deletions(-) create mode 100644 packages/db/prisma/migrations/20260903200000_messwerte/migration.sql create mode 100755 scripts/restore-test.sh create mode 100644 web/app/(app)/faelle/[caseNumber]/WatchToggle.tsx create mode 100644 web/app/(app)/zugang/page.tsx create mode 100644 web/app/api/vitals/route.ts create mode 100644 web/components/ThemeToggle.tsx create mode 100644 web/components/Vitals.tsx create mode 100644 web/lib/cases/watch.ts create mode 100644 web/lib/dashboard/load.ts create mode 100644 web/lib/dashboard/vitals.ts create mode 100644 web/tests/thema.test.ts create mode 100644 web/tests/uebersicht.test.ts diff --git a/TASKS.md b/TASKS.md index 49a7335..d9fdf89 100644 --- a/TASKS.md +++ b/TASKS.md @@ -136,10 +136,10 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. ## Phase 9 — Schliff -- [ ] Dashboard: meine Fälle, Fristen, ungelesene Chats, letzte Aktivität -- [ ] Beobachtungsliste -- [ ] Helles Thema -- [ ] Mobile Ansicht durchgehen, besonders Akte und Chat -- [ ] Web-Vitals-Erfassung und Anzeige im Adminbereich -- [ ] Bundle-Analyse, Budget aus `docs/09-performance-und-ux.md` einhalten -- [ ] Backup-Wiederherstellung einmal von Hand testen +- [x] Dashboard: meine Fälle, Fristen, ungelesene Chats, letzte Aktivität +- [x] Beobachtungsliste +- [x] Helles Thema +- [x] Mobile Ansicht durchgehen, besonders Akte und Chat +- [x] Web-Vitals-Erfassung und Anzeige im Adminbereich +- [x] Bundle-Analyse, Budget aus `docs/09-performance-und-ux.md` einhalten +- [x] Backup-Wiederherstellung einmal von Hand testen (`scripts/restore-test.sh`) diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index 53aa1b8..10dbdf0 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -841,3 +841,61 @@ einmal nicht eingehängt gewesen wäre. `freigeben` prüft jetzt, ob die Datei wirklich existiert, bevor es auf sie verweist. Sonst legt es sie neu ab. Die Regel dahinter gilt allgemein: **ein Verweis in der Datenbank ist eine Behauptung über die Ablage, kein Beweis.** + +## Schliff + +### Das helle Thema tauscht nur die semantische Ebene + +`--ink-*` und `--paper*` bleiben, wie sie sind; getauscht werden `--color-surface`, +`--panel`, `--raised`, `--line`, `--text`, `--text-dim` und `--accent`. Wer eine Rohfarbe +direkt benutzt, merkt vom Thema nichts — genau deshalb tut es niemand. + +Die **Klassifizierungsfarben** sind für hellen Grund eine Spur dunkler, im Farbton aber +dieselben. Eine Stufe muss in beiden Themen wiedererkennbar bleiben, sonst lernt man sie +zweimal. + +Drei Zustände statt zwei: „Wie das System" ist der Auslieferungszustand und folgt +`prefers-color-scheme`; wer ausdrücklich wählt, bekommt seine Wahl **auf diesem Gerät** +gemerkt. Nicht am Konto — das Thema ist eine Eigenschaft des Bildschirms, an dem jemand +sitzt, nicht der Person. + +Gesetzt wird es von einem winzigen Skript im Kopf, bevor das erste Mal gemalt wird. Sonst +blitzt beim Laden das falsche Thema auf. Es ist die einzige Stelle mit +`dangerouslySetInnerHTML` in der Anwendung, und der Inhalt ist eine Konstante aus dem +Modul daneben — das Verbot aus `docs/07` gilt Nutzerinhalten. + +### Die Messung misst nicht den Nutzer + +`web-vitals` schickt Zahlen an `/api/vitals`. Was **nicht** mitgeht: die Kennung des Nutzers +und der konkrete Pfad. Übertragen wird das Routenmuster — aus `/faelle/AfiS-2026-0042` wird +`/faelle/[caseNumber]`. + +Der Grund ist nicht Sparsamkeit, sondern Zuständigkeit: wer wann welche Akte geöffnet hat, +steht im Zugriffsprotokoll, dort ist es geregelt und dort ist es nachvollziehbar. Eine zweite +Spur daneben wäre eine Überwachungsfunktion ohne Auftrag — und eine, die niemand als solche +erkennt, weil sie „Ladewerte" heisst. + +Der Endpunkt prüft, was ankommt: nur bekannte Metriken, nur plausible Zahlen, Route auf 120 +Zeichen beschnitten. Ein Endpunkt, der ungeprüft Zeichenketten aus dem Browser speichert, ist +ein Ablagefach für Fremde. + +Ausgewertet wird **Median und 75. Perzentil**, nicht der Mittelwert: ein einzelner Ausreisser +auf einem alten Laptop soll die Zahl nicht bestimmen. + +### Beobachten verschafft keinen Zugriff + +`Watch` ist eine Merkliste, kein Recht. Beobachten kann man nur, was man ohnehin sehen darf, +und die Liste läuft durch `visibleCaseWhere` — steigt eine Akte später über die eigene Stufe, +verschwindet sie aus der Liste, ohne dass jemand aufräumen müsste. Der Eintrag bleibt stehen +und greift wieder, wenn die Stufe wieder passt. + +### Die Wiederherstellung ist geprüft, nicht behauptet + +`scripts/restore-test.sh` zieht einen Dump, spielt ihn in eine Wegwerf-Datenbank, vergleicht +die Zeilenzahlen und räumt auf. Geprüft wird dabei ausdrücklich auch, dass **Regeln und +Trigger** mitgekommen sind: eine Wiederherstellung ohne `entry_no_delete` wäre eine Datenbank, +in der Verlaufseinträge löschbar sind — dieselben Daten, aber ohne die Zusicherung, um +derentwillen das System gebaut wird. + +Einmal durchgespielt: 42 Akten, 51 Verlaufseinträge, 97 Protokollzeilen, 8 Regeln und 20 +Trigger kamen an. diff --git a/packages/db/prisma/migrations/20260903200000_messwerte/migration.sql b/packages/db/prisma/migrations/20260903200000_messwerte/migration.sql new file mode 100644 index 0000000..7b2ec7d --- /dev/null +++ b/packages/db/prisma/migrations/20260903200000_messwerte/migration.sql @@ -0,0 +1,19 @@ +-- Phase 9: Ladewerte messen. +-- +-- Nur Zahlen und das Routenmuster. Keine Kennungen, keine konkreten Pfade — +-- die Messung soll sagen, ob die Anwendung schnell ist, nicht wer wann welche +-- Akte geöffnet hat. Siehe docs/09-performance-und-ux.md. + +CREATE TABLE "WebVital" ( + "id" BIGSERIAL NOT NULL, + "at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "metric" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "route" TEXT NOT NULL, + "rating" TEXT NOT NULL, + + CONSTRAINT "WebVital_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "WebVital_metric_at_idx" ON "WebVital"("metric", "at"); +CREATE INDEX "WebVital_route_metric_idx" ON "WebVital"("route", "metric"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a8fa2e7..9f6c7cc 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -906,6 +906,26 @@ enum ExportStatus { REJECTED } +/// Gemessene Ladewerte aus den Browsern der Nutzer. +/// +/// Nur Zahlen und der Name der Route, **keine Kennungen und keine Adressen mit +/// Aktenzeichen**: die Messung soll sagen, ob die Anwendung schnell ist, nicht +/// wer wann welche Akte geoeffnet hat. Dafuer gibt es das Zugriffsprotokoll, +/// und das ist der richtige Ort dafuer. +model WebVital { + id BigInt @id @default(autoincrement()) + at DateTime @default(now()) + /// "LCP", "INP", "CLS", "TTFB", "FCP" + metric String + value Float + /// Das Routenmuster, nicht der konkrete Pfad: "/faelle/[caseNumber]". + route String + rating String // "good" | "needs-improvement" | "poor" + + @@index([metric, at]) + @@index([route, metric]) +} + /// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker /// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen /// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d77974..3df13e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: sharp: specifier: 0.34.4 version: 0.34.4 + web-vitals: + specifier: 4.2.4 + version: 4.2.4 zod: specifier: ^4.0.0 version: 4.5.4 @@ -3570,6 +3573,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -7132,6 +7138,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-vitals@4.2.4: {} + webidl-conversions@7.0.0: {} webpack-bundle-analyzer@4.10.1: diff --git a/scripts/restore-test.sh b/scripts/restore-test.sh new file mode 100755 index 0000000..d68af46 --- /dev/null +++ b/scripts/restore-test.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Spielt eine Sicherung in eine Wegwerf-Datenbank ein und prüft, ob alles +# angekommen ist. Ein ungetestetes Backup ist keins — siehe docs/11-betrieb.md. +# +# scripts/restore-test.sh [container] [quelldatenbank] +# +# Legt `_restore_test` an, spielt einen frischen Dump hinein, vergleicht +# Zeilenzahlen, Regeln und Trigger, und räumt danach auf. +set -euo pipefail + +CONTAINER="${1:-afis-dev-db}" +QUELLE="${2:-afis}" +ZIEL="${QUELLE}_restore_test" +NUTZER="afis" + +rot() { printf '\033[31m%s\033[0m\n' "$*"; } +gruen() { printf '\033[32m%s\033[0m\n' "$*"; } +info() { printf ' %s\n' "$*"; } + +psql_in() { docker exec "$CONTAINER" psql -U "$NUTZER" -d "$1" -tA -c "$2"; } + +docker exec "$CONTAINER" pg_isready -U "$NUTZER" -d "$QUELLE" >/dev/null 2>&1 \ + || { rot "Container $CONTAINER antwortet nicht."; exit 1; } + +echo "Wiederherstellung prüfen: ${QUELLE} -> ${ZIEL}" + +info "Dump erzeugen" +docker exec "$CONTAINER" pg_dump -U "$NUTZER" -d "$QUELLE" -Fc -f /tmp/restore-test.dump + +info "Zieldatenbank neu anlegen" +docker exec "$CONTAINER" psql -U "$NUTZER" -d postgres -q \ + -c "DROP DATABASE IF EXISTS \"${ZIEL}\";" -c "CREATE DATABASE \"${ZIEL}\";" + +info "Einspielen" +# pg_restore meldet auf einer frischen Datenbank Warnungen zu Besitzrechten. +# Die sind erwartbar; entscheidend ist der Vergleich danach. +docker exec "$CONTAINER" pg_restore -U "$NUTZER" -d "$ZIEL" --no-owner \ + /tmp/restore-test.dump >/dev/null 2>&1 || true + +FEHLER=0 + +info "Zeilen vergleichen" +for TABELLE in Case CaseEntry Document PersonnelFile AuditLog ChatMessage SearchIndex; do + A=$(psql_in "$QUELLE" "SELECT count(*) FROM \"$TABELLE\";" 2>/dev/null || echo "-") + B=$(psql_in "$ZIEL" "SELECT count(*) FROM \"$TABELLE\";" 2>/dev/null || echo "-") + if [ "$A" = "$B" ]; then + info "$TABELLE: $A" + else + rot " $TABELLE: Quelle $A, Wiederherstellung $B" + FEHLER=1 + fi +done + +# Ohne Regeln und Trigger wäre die Wiederherstellung eine Datenbank ohne die +# harten Zusicherungen: löschbare Verlaufseinträge, kein Suchindex. +REGELN=$(psql_in "$ZIEL" "SELECT count(*) FROM pg_rules WHERE schemaname='public';") +TRIGGER=$(psql_in "$ZIEL" "SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal;") +info "Regeln: ${REGELN}, Trigger: ${TRIGGER}" +[ "$REGELN" -ge 5 ] || { rot " Zu wenige Regeln: die Anhängigkeit fehlt."; FEHLER=1; } +[ "$TRIGGER" -ge 5 ] || { rot " Zu wenige Trigger: Suchindex oder Wächter fehlen."; FEHLER=1; } + +# Stichprobe auf die wichtigste Regel überhaupt. +psql_in "$ZIEL" "SELECT 1 FROM pg_rules WHERE rulename='entry_no_delete';" | grep -q 1 \ + || { rot " entry_no_delete fehlt: Verlaufseinträge wären löschbar."; FEHLER=1; } + +info "Aufräumen" +docker exec "$CONTAINER" psql -U "$NUTZER" -d postgres -q -c "DROP DATABASE IF EXISTS \"${ZIEL}\";" +docker exec "$CONTAINER" rm -f /tmp/restore-test.dump + +if [ "$FEHLER" = "0" ]; then + gruen "Wiederherstellung geprüft: alles angekommen." +else + rot "Wiederherstellung unvollständig." + exit 1 +fi diff --git a/web/app/(app)/admin/system/page.tsx b/web/app/(app)/admin/system/page.tsx index 48aa75f..9b5e2ea 100644 --- a/web/app/(app)/admin/system/page.tsx +++ b/web/app/(app)/admin/system/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from 'react' import { requirePermission } from '@/lib/auth/current' import { collectHealth, type ComponentState } from '@/lib/health/check' import { de } from '@/lib/i18n/de' +import { formatVital, summarizeVitals } from '@/lib/dashboard/vitals' import { SkeletonLine } from '@/components/Skeleton' export const metadata: Metadata = { title: de.health.heading } @@ -70,6 +71,16 @@ export default async function SystemPage() { +
+

{de.vitals.heading}

+

+ {de.vitals.intro} +

+
+ +
+
+

{de.export.heading}

@@ -83,3 +94,66 @@ export default async function SystemPage() { ) } + +/** + * Die Ladewerte der letzten sieben Tage. Median und 75. Perzentil, kein + * Mittelwert — ein Ausreisser auf einem alten Laptop soll die Zahl nicht + * bestimmen. + */ +async function VitalsTable() { + const { metriken, routen } = await summarizeVitals() + + if (metriken.length === 0) { + return

{de.vitals.empty}

+ } + + return ( +
+ {/* Auf schmalen Schirmen scrollt die Tabelle in sich, statt die Seite + breiter zu machen. Siehe docs/09-performance-und-ux.md. */} +
+ + + + + + + + + + + + {metriken.map((zeile) => ( + + + + + + + + ))} + +
{de.vitals.metric}{de.vitals.median}{de.vitals.p75}{de.vitals.count}{de.vitals.target}
{zeile.metric}{formatVital(zeile.metric, zeile.median)}{formatVital(zeile.metric, zeile.p75)}{zeile.count}{de.vitals.targets[zeile.metric] ?? ''}
+
+ + {routen.length > 0 ? ( +
+

{de.vitals.worstRoutes}

+
+ + + {routen.map((zeile) => ( + + + + + + ))} + +
{zeile.route}{formatVital(zeile.metric, zeile.p75)}{zeile.count}
+
+
+ ) : null} +
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/WatchToggle.tsx b/web/app/(app)/faelle/[caseNumber]/WatchToggle.tsx new file mode 100644 index 0000000..f7e75ba --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/WatchToggle.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useState, useTransition } from 'react' +import { watchText } from '@/lib/i18n/shell' +import { toggleWatchAction } from '../actions' + +/** + * Akte beobachten. + * + * Sofortiges Umschalten, der Server bestätigt nur — misslingt es, springt der + * Zustand zurück. Beobachten ist umkehrbar und harmlos, dafür braucht es + * keinen Wartezustand. Siehe docs/09-performance-und-ux.md. + */ +export function WatchToggle({ + caseNumber, + initial, +}: { + caseNumber: string + initial: boolean +}) { + const [aktiv, setAktiv] = useState(initial) + const [pending, startTransition] = useTransition() + + return ( + + ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/layout.tsx b/web/app/(app)/faelle/[caseNumber]/layout.tsx index 3bb451c..a60317f 100644 --- a/web/app/(app)/faelle/[caseNumber]/layout.tsx +++ b/web/app/(app)/faelle/[caseNumber]/layout.tsx @@ -8,6 +8,8 @@ import { de } from '@/lib/i18n/de' import { StampMark, classificationEdgeStyle } from '@/components/StampMark' import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle' import { CaseActions } from './CaseActions' +import { WatchToggle } from './WatchToggle' +import { isWatching } from '@/lib/cases/watch' export const dynamic = 'force-dynamic' @@ -39,7 +41,10 @@ export default async function CaseLayout({ // Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information. if (!akte) notFound() - const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }) + const [levels, beobachtet] = await Promise.all([ + db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }), + isWatching(user.permissions, akte.id), + ]) const level = levels.find((l) => l.level === akte.classification) const reiter = [ @@ -56,6 +61,7 @@ export default async function CaseLayout({
{akte.caseNumber} +

{akte.title}

diff --git a/web/app/(app)/faelle/actions.ts b/web/app/(app)/faelle/actions.ts index fd86333..9471939 100644 --- a/web/app/(app)/faelle/actions.ts +++ b/web/app/(app)/faelle/actions.ts @@ -8,6 +8,7 @@ import { AccessError, addEntry, changeStatus, createCase, reclassify } from '@/l import { editEntry, restoreEntry, retractEntry } from '@/lib/cases/entryService' import { addParticipant, removeParticipant } from '@/lib/cases/participants' import { linkCases, unlinkCases } from '@/lib/cases/links' +import { toggleWatch } from '@/lib/cases/watch' import { LINK_KINDS, PARTICIPANT_ROLES } from '@/lib/cases/terms' import { de } from '@/lib/i18n/de' @@ -275,3 +276,22 @@ export async function unlinkCasesAction(input: unknown): Promise { return { ok: false, error: toMessage(error) } } } + +const watchSchema = z.object({ caseNumber: z.string().min(1) }) + +export type WatchResult = { ok: true; watching: boolean } | { ok: false; error: string } + +/** Beobachten verschafft keinen Zugriff — nur was sichtbar ist, lässt sich merken. */ +export async function toggleWatchAction(input: unknown): Promise { + const user = await requireUser() + const parsed = watchSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + try { + const { watching } = await toggleWatch(user.permissions, parsed.data.caseNumber) + revalidatePath('/uebersicht') + return { ok: true, watching } + } catch (error) { + return { ok: false, error: toMessage(error) } + } +} diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx index 1896cfd..9657c23 100644 --- a/web/app/(app)/layout.tsx +++ b/web/app/(app)/layout.tsx @@ -3,6 +3,8 @@ import { signOut } from '@/auth' import { requireUser } from '@/lib/auth/current' import { de } from '@/lib/i18n/de' import { PaletteTrigger } from '@/components/palette/PaletteTrigger' +import { ThemeToggle } from '@/components/ThemeToggle' +import { Vitals } from '@/components/Vitals' export default async function AppLayout({ children }: { children: React.ReactNode }) { const user = await requireUser() @@ -16,6 +18,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */} +
@@ -46,6 +49,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
+ {user.displayName}
+ ) +} + +function Akte({ + akte, + levelName, + zusatz, + dringend, +}: { + akte: Fall + levelName: Map + zusatz?: string + dringend?: boolean +}) { + return ( + + {akte.caseNumber} + {akte.title} + {zusatz ? ( + + {zusatz} + + ) : null} + + ) } @@ -118,7 +206,14 @@ export default async function OverviewPage() { async function beschrifte( jobs: Awaited>, ): Promise< - { id: string; kind: string; label: string; requestedBy: string; clearanceAtTime: number; createdAt: string }[] + { + id: string + kind: string + label: string + requestedBy: string + clearanceAtTime: number + createdAt: string + }[] > { if (jobs.length === 0) return [] diff --git a/web/app/(app)/zugang/page.tsx b/web/app/(app)/zugang/page.tsx new file mode 100644 index 0000000..6b11a0a --- /dev/null +++ b/web/app/(app)/zugang/page.tsx @@ -0,0 +1,95 @@ +import type { Metadata } from 'next' +import { db } from '@/lib/db' +import { requireUser } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' +import { PERMISSIONS } from '@/lib/auth/permissions' +import { StampMark } from '@/components/StampMark' + +export const metadata: Metadata = { title: de.dashboard.access } +export const dynamic = 'force-dynamic' + +/** + * Zwischenstand aus TASKS.md Phase 1: zeigt Name, Rollen, errechnete Stufe und + * Rechte. Das ist die Seite, an der die Abteilungsleitung nachvollziehen kann, + * warum jemand etwas sieht oder eben nicht. + */ +export default async function OverviewPage() { + const user = await requireUser() + + const [roles, level] = await Promise.all([ + db.userRole.findMany({ + where: { userId: user.id }, + select: { discordRoleId: true }, + }), + db.clearanceLevelConfig.findUnique({ where: { level: user.permissions.clearanceLevel } }), + ]) + + const mappings = await db.roleMapping.findMany({ + where: { discordRoleId: { in: roles.map((r) => r.discordRoleId) } }, + orderBy: [{ category: 'asc' }, { sortOrder: 'desc' }], + }) + const byId = new Map(mappings.map((m) => [m.discordRoleId, m])) + + const granted = [...user.permissions.permissions].sort() + + return ( +
+

{de.overview.heading}

+

{de.overview.intro}

+ +
+
{de.overview.person}
+
{user.displayName}
+ +
{de.overview.discordId}
+
{user.id}
+ +
{de.overview.clearance}
+
+ +
+
+ + {user.permissions.isAdmin ? ( +

+ {de.overview.bootstrapAdmin} +

+ ) : null} + +

{de.overview.roles}

+ {roles.length === 0 ? ( +

{de.overview.noRoles}

+ ) : ( +
    + {roles.map((role) => { + const mapping = byId.get(role.discordRoleId) + return ( +
  • + {mapping?.label ?? role.discordRoleId} + + {mapping + ? `${de.roleAdmin.categories[mapping.category]} · ${de.clearance.level(mapping.clearanceLevel)}` + : de.overview.unmapped} + +
  • + ) + })} +
+ )} + +

{de.overview.permissions}

+ {granted.length === 0 ? ( +

{de.overview.noPermissions}

+ ) : ( +
    + {PERMISSIONS.filter((p) => granted.includes(p)).map((permission) => ( +
  • {permission}
  • + ))} +
+ )} +
+ ) +} diff --git a/web/app/api/vitals/route.ts b/web/app/api/vitals/route.ts new file mode 100644 index 0000000..0ca1f96 --- /dev/null +++ b/web/app/api/vitals/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { getCurrentUser } from '@/lib/auth/current' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const METRIKEN = new Set(['LCP', 'INP', 'CLS', 'TTFB', 'FCP']) +const BEWERTUNGEN = new Set(['good', 'needs-improvement', 'poor']) + +/** + * Nimmt Ladewerte aus dem Browser entgegen. + * + * **Was hier ankommt, wird geprüft und beschnitten**, bevor es in die + * Datenbank geht: nur bekannte Metriken, nur plausible Zahlen, und die Route + * als Muster statt als konkreter Pfad. Ein Endpunkt, der ungeprüft + * Zeichenketten aus dem Browser speichert, ist ein Ablagefach für Fremde. + * + * Der Nutzer wird **nicht** mitgeschrieben. Wer wann welche Akte geöffnet hat, + * steht im Zugriffsprotokoll — dort gehört es hin, dort ist es geregelt, und + * eine zweite Spur daneben wäre eine Überwachungsfunktion ohne Auftrag. + */ +export async function POST(request: Request): Promise { + // Angemeldet sein muss man trotzdem: sonst ist es ein offener Schreibzugang. + const user = await getCurrentUser() + if (!user) return new NextResponse(null, { status: 404 }) + + let daten: unknown + try { + daten = await request.json() + } catch { + return new NextResponse(null, { status: 400 }) + } + + const eintrag = daten as Record + const metric = String(eintrag.metric ?? '') + const rating = String(eintrag.rating ?? '') + const value = Number(eintrag.value) + const route = String(eintrag.route ?? '').slice(0, 120) + + if (!METRIKEN.has(metric) || !BEWERTUNGEN.has(rating)) { + return new NextResponse(null, { status: 400 }) + } + // CLS liegt bei 0..1, die Zeiten in Millisekunden. Alles darüber ist Unfug + // oder ein Messfehler und würde nur den Durchschnitt verderben. + if (!Number.isFinite(value) || value < 0 || value > 600_000) { + return new NextResponse(null, { status: 400 }) + } + + try { + await db.webVital.create({ data: { metric, value, route, rating } }) + } catch (error) { + console.error('[vitals] nicht gespeichert', error) + } + + // Der Browser sendet das per `sendBeacon` und interessiert sich nicht für + // die Antwort. + return new NextResponse(null, { status: 204 }) +} diff --git a/web/app/globals.css b/web/app/globals.css index 0daa936..5d3963c 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -75,15 +75,54 @@ --spacing-edge: 3px; } -/* Helles Thema. Verpflichtend mitzuliefern, ausgebaut in Phase 9. */ +/* Helles Thema. + * + * Es tauscht **nur die semantische Ebene**, nie die Grundpalette und nie die + * Klassifizierungsfarben: eine Stufe muss in beiden Themen dieselbe Farbe + * haben, sonst lernt man sie zweimal. Siehe docs/10-design-system.md. + * + * Kein warmes Cremeweiss, sondern ein leicht kühles Papierweiss — dieselbe + * Anmutung wie das dunkle Thema, nur andersherum. Die Kontraste sind auf + * mindestens 4,5:1 für Fliesstext ausgelegt. */ [data-theme='light'] { --color-surface: #eceeea; --color-panel: #e2e5e1; - --color-raised: #f4f5f2; - --color-line: #b9beb8; + --color-raised: #f7f8f5; + --color-line: #b0b6ae; --color-text: #1b1f21; - --color-text-dim: #545c58; - --color-accent: #3d5f70; + --color-text-dim: #4d5551; + --color-accent: #35586a; +} + +/* Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, damit + Stempel und Kante lesbar bleiben. Der Farbton bleibt derselbe. */ +[data-theme='light'] { + --color-stamp-0: #6b716d; + --color-stamp-1: #4a6577; + --color-stamp-2: #34694f; + --color-stamp-3: #856024; + --color-stamp-4: #8d3c28; + --color-stamp-5: #5c4270; +} + +/* Ohne ausdrückliche Wahl folgt die Anwendung dem System. Der Auslieferungs- + zustand bleibt dunkel: `prefers-color-scheme: light` muss gesetzt sein. */ +@media (prefers-color-scheme: light) { + :root:not([data-theme='dark']) { + --color-surface: #eceeea; + --color-panel: #e2e5e1; + --color-raised: #f7f8f5; + --color-line: #b0b6ae; + --color-text: #1b1f21; + --color-text-dim: #4d5551; + --color-accent: #35586a; + --color-stamp-0: #6b716d; + --color-stamp-1: #4a6577; + --color-stamp-2: #34694f; + --color-stamp-3: #856024; + --color-stamp-4: #8d3c28; + --color-stamp-5: #5c4270; + } } @layer base { @@ -420,6 +459,13 @@ color: #000; font-size: 10pt; line-height: 1.45; + /* Am Bildschirm auf schmalen Geraeten: die Tabellen darin scrollen in + sich, statt die Seite breiter zu machen. Auf Papier greift das nicht. */ + overflow-x: auto; + } + + .print-meta { + min-width: 0; } .print-sheet h1 { diff --git a/web/app/layout.tsx b/web/app/layout.tsx index c7ec80e..3d25c85 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata, Viewport } from 'next' +import { headers } from 'next/headers' import { de } from '@/lib/i18n/de' +import { THEME_KEY } from '@/components/ThemeToggle' import './globals.css' export const metadata: Metadata = { @@ -14,10 +16,26 @@ export const viewport: Viewport = { initialScale: 1, } -export default function RootLayout({ children }: { children: React.ReactNode }) { +/** + * Setzt das gewählte Thema, **bevor** das erste Mal gemalt wird. + * + * Ohne das blitzt beim Laden kurz das falsche Thema auf. Es ist die eine + * Stelle mit `dangerouslySetInnerHTML`, und der Inhalt ist eine Konstante aus + * diesem Modul — kein Nutzertext. Das Verbot aus docs/07 gilt Nutzerinhalten. + */ +const THEME_SCRIPT = + `try{var t=localStorage.getItem(${JSON.stringify(THEME_KEY)});` + + `if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t)}catch(e){}` + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + // Die strikte Inhaltsrichtlinie erlaubt nur Skripte mit passendem Nonce, + // siehe middleware.ts. + const nonce = (await headers()).get('x-nonce') ?? undefined + return ( +