From addef20e16981364356f2c44bcf45b2f7bfe8dcb 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. --- .../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 ++++++++++ 26 files changed, 1381 insertions(+), 85 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/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 ( +