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 ( +