From 87e6a5f564d113f4c0f7963d0aabb3ce9aebc088 Mon Sep 17 00:00:00 2001 From: kelle Date: Thu, 16 Jul 2026 22:22:44 +0200 Subject: [PATCH] Spezifikation v0.2 als lebendes Dokument + Ansicht in der App Neue Funktionale/Technische Spezifikation (SPEZIFIKATION.md) mit Aenderungshistorie; ersetzt die bisherigen FDD/TDD-Dokumente v1-v5. In der App unter dem Menueintrag "SPEZIFIKATION" (Sidebar, unter den Plaenen) lesbar: /api/spec liefert das Markdown, SpecView rendert es. Styling ueber die bestehenden Farb-Tokens, folgt damit allen drei Schemata. Die MD-Datei war ueber .dockerignore (*.md) vom Image ausgeschlossen -> Ausnahme ergaenzt und Kopie in die Runner-Stage aufgenommen. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 2 + Dockerfile | 2 + SPEZIFIKATION.md | 1688 +++++++++++++++++++++++++++++++++++ package-lock.json | 1522 ++++++++++++++++++++++++++++++- package.json | 3 + src/app/api/spec/route.ts | 20 + src/app/globals.css | 99 ++ src/components/AppShell.tsx | 46 +- src/components/SpecView.tsx | 50 ++ 9 files changed, 3422 insertions(+), 10 deletions(-) create mode 100644 SPEZIFIKATION.md create mode 100644 src/app/api/spec/route.ts create mode 100644 src/components/SpecView.tsx diff --git a/.dockerignore b/.dockerignore index 9948155..d5b88cd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,5 @@ npm-debug.log* Info Dateien .claude *.md +# Die Spezifikation wird in der App unter /spezifikation ausgeliefert und muss ins Image. +!SPEZIFIKATION.md diff --git a/Dockerfile b/Dockerfile index 531b569..2c254d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,8 @@ COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/next.config.ts ./next.config.ts COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/prisma ./prisma +# Wird zur Laufzeit von /api/spec gelesen und in der App gerendert. +COPY --from=builder /app/SPEZIFIKATION.md ./SPEZIFIKATION.md COPY docker-entrypoint.sh ./docker-entrypoint.sh RUN chmod +x ./docker-entrypoint.sh diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md new file mode 100644 index 0000000..3468b68 --- /dev/null +++ b/SPEZIFIKATION.md @@ -0,0 +1,1688 @@ +# FPT – Financial Planning Tool +## Funktionale und Technische Spezifikation + +| | | +|---|---| +| **Dokument** | Funktionale und Technische Spezifikation FPT | +| **Version** | 0.2 | +| **Datum** | 2026-07-16 | +| **Status** | Lebendes Dokument | +| **Codestand** | Arbeitsstand nach `f768e01` inkl. Fixes zu Phaseninflation, Tilgungsraten und Vorbezugssteuer (Branch `main`) | +| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | +| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | + +--- + +## Änderungshistorie + +| Version | Datum | Autor | Änderung | +|---|---|---|---| +| 0.2 | 2026-07-16 | Claude (Opus 4.8) | Drei Fixes umgesetzt und dokumentiert: **(1)** `Phase.inflationRate` ersatzlos entfernt (DB-Migration, API, Typen, UI) – die Inflation liegt seit V5 plan-weit; das Feld war wirkungslos. **(2)** Amortisation und Tilgung stoppen neu, sobald Hypothek bzw. Schuld abbezahlt sind – sie belasten danach weder Cash noch Sparquote (Kap. 4.6.5, 4.6.7, 4.7, 4.8). **(3)** Kapitalbezugssteuer greift neu auch bei PK-/3a-Vorbezügen vor der Pensionierung, inkl. Steuerfeld und Netto-Vorschau im UI (Kap. 3.5.2, 4.9.1, 4.9.2). Kennzahl `plannedSaveRate` ist neu die Rate des **ersten** Phasenjahres. Drei Regressionstests ergänzt (10 → 13). Abschnitt 9 neu nummeriert (erledigte Punkte entfernt, „Sparraten werden nicht indexiert" ergänzt). | +| 0.1 | 2026-07-16 | Claude (Opus 4.8) | Erstfassung. Vollständige Neuerstellung aus dem Code (Stand `f768e01`). Ersetzt die bisherigen FDD/TDD-Dokumente v1–v5 vollständig. | + +> **Pflegehinweis:** Dieses Dokument ist ein lebendes Dokument. Bei jeder Aktualisierung wird +> (a) die Änderungshistorie um eine Zeile ergänzt, (b) die Versionsnummer im Kopf um 0.1 erhöht +> und (c) der referenzierte Codestand (Commit) aktualisiert. + +--- + +## Inhaltsverzeichnis + +1. [Einleitung und Abgrenzung](#1-einleitung-und-abgrenzung) +2. [Fachliche Grundkonzepte](#2-fachliche-grundkonzepte) +3. [Funktionale Spezifikation](#3-funktionale-spezifikation) +4. [Berechnungsmodell](#4-berechnungsmodell) +5. [Technische Spezifikation](#5-technische-spezifikation) +6. [API-Referenz](#6-api-referenz) +7. [Betrieb und Deployment](#7-betrieb-und-deployment) +8. [Qualitätssicherung](#8-qualitätssicherung) +9. [Bekannte Einschränkungen und Modellentscheide](#9-bekannte-einschränkungen-und-modellentscheide) +10. [Glossar](#10-glossar) + +--- + +# 1. Einleitung und Abgrenzung + +## 1.1 Zweck des Tools + +Das FPT ist eine Webanwendung zur persönlichen Finanzplanung über Lebensabschnitte hinweg, +ausgelegt auf Schweizer Verhältnisse (AHV, Pensionskasse, Säule 3a, Grundstückgewinnsteuer). +Ein Benutzer bildet seine finanzielle Situation als Kette von **Lebensphasen** ab, hinterlegt +**finanzielle Elemente** (Einkommen, Ausgaben, Vorsorge, Immobilien, Vermögen, Schulden) und +trifft an den **Übergängen** zwischen den Phasen bewusste Entscheide (halten, verkaufen, +Kapital beziehen, verrenten). Das Tool rechnet daraus Jahr für Jahr das Vermögen, den +Cash-Bestand, Spar- bzw. Verzehrquoten sowie ein allfälliges Ruinalter fort. + +## 1.2 Was das Tool nicht ist + +Aus dem Code direkt ableitbare Abgrenzungen: + +- **Keine Steuerberechnung** ausser den drei explizit modellierten Sätzen + (Kapitalbezugssteuer, Grundstückgewinnsteuer, PK-Umwandlungssatz). Einkommens- und + Vermögenssteuern sind nicht modelliert und müssen vom Benutzer in den Ausgaben erfasst werden. +- **Keine Monte-Carlo-Simulation / keine Stochastik.** Alle Renditen sind deterministische + Jahresprozentsätze. +- **Keine Hypothekarzinsen.** Eine Hypothek reduziert nur den Nettowert der Immobilie; + Zinskosten sind vom Benutzer in den Ausgaben zu erfassen. +- **Keine Wertentwicklung von Immobilien.** Der Kaufpreis ist über die Phasendauer konstant + (Details siehe [4.6.5](#465-real_estate-immobilie)). +- **Keine Mehrbenutzer-Kollaboration.** Pläne gehören genau einem Benutzer. + +## 1.3 Kernprinzip: Plan als selbsttragende Einheit + +Seit dem V3-Rework (Migration `20260713150000_profile_to_plan_v3`) trägt **jeder Plan sein +eigenes Grundprofil**: Haushaltsform, Personen (Alter, Pensionsalter, Name) und +Inflationsannahme. Es gibt keine übergeordnete Haushalts-Entität mehr. Ein Szenario ist +deshalb eine vollständige Deep-Copy und vom Ursprungsplan unabhängig veränderbar. + +Referenz: `prisma/schema.prisma` Zeilen 8–10, `src/lib/types.ts` Zeilen 38–49. + +--- + +# 2. Fachliche Grundkonzepte + +## 2.1 Die vier Ebenen + +``` +User + └── Plan (Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert) + ├── Person[] (1 bei SINGLE, 2 bei COUPLE) + ├── Phase[] (geordnete Kette: sequenceNumber 1..n, je mit Dauer in Jahren) + └── FinancialElement[] (plan-weit, kategorisiert, optional personenzugeordnet) + ├── ElementPhaseValue[] (Werte je Phase, JSON) + └── ElementTransitionValue[] (Entscheide je Übergang, JSON) +``` + +Das zentrale Designprinzip (Element-Rework 07/2026, Migration `20260713100000_element_model_rework`): +**Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität.** Eine +Pensionskasse "PK Arbeitgeber" existiert einmal pro Plan; sie hat pro Phase einen Werte-Satz und +pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eines Vermögensgegenstands +über die Zeit erhalten – Voraussetzung für die Fortschreibung (Carry) und für die +Vermögensaufteilungs-Grafik. + +Referenz: `prisma/schema.prisma` Zeilen 1–10, 114–151. + +## 2.2 Die Matrix als Leitmetapher + +Die Hauptansicht ist eine Tabelle: + +- **Zeilen** = finanzielle Elemente, gruppiert nach Kategorie; zuoberst die systemseitige, + read-only Zeile **Cash**. +- **Spalten** = abwechselnd **Phasenspalten** und **Übergangsspalten** + (Phase 1 → Übergang → Phase 2 → Übergang → Phase 3 …). Der letzten Phase folgt keine + Übergangsspalte. +- **Zellen** = anklickbar; Phasenzellen öffnen die Werte-Eingabe, Übergangszellen den + Entscheid-Dialog. + +Referenz: `src/components/PlanView.tsx` Zeilen 111–120 (Spaltenaufbau), 333–477 (Matrix). + +## 2.3 Phasentyp – abgeleitet, nicht gespeichert + +Der Typ einer Phase wird **nie gespeichert**, sondern in jeder Berechnung aus Alter und +Pensionsalter der Personen abgeleitet: + +| Bedingung (zu Phasenbeginn) | Typ | +|---|---| +| alle Personen `startAge < retirementAge` | `ERWERB` | +| alle Personen `startAge >= retirementAge` | `PENSION` | +| gemischt | `MIXED` | + +Referenz: `src/lib/calculations.ts` Zeilen 158–172; Kommentar in `prisma/schema.prisma` Zeilen 94–95. + +## 2.4 Cash als Ausgleichskonto + +Cash ist kein vom Benutzer erfassbares Element, sondern das systemseitige Ausgleichskonto: + +- Es startet mit `Plan.initialCash` (Phase 1). +- Es nimmt jährlich die **Spar-/Verzehrquote** (Einkommen − nominale Ausgaben) auf. +- Es finanziert die **geplanten Sparraten** (3a-Beiträge, Sparbeiträge, Amortisationen, Tilgungen). +- Es empfängt die **Bezugsraten** aus Sonstigem Vermögen. +- Es empfängt an Übergängen **Kapitalzuflüsse** (Verkäufe, PK-/3a-Bezüge) und finanziert + **Sofort-Tilgungen** sowie **Zusatzinvestitionen** der Folgephase. +- Es darf **negativ werden** – dies ist die Definition einer Liquiditätslücke und wird rot + markiert, aber nicht automatisch korrigiert. + +Referenz: `src/lib/calculations.ts` Zeilen 365–425, 599. + +--- + +# 3. Funktionale Spezifikation + +## 3.1 Authentifizierung und Benutzerkonto + +### 3.1.1 Registrierung + +- Öffentlich zugänglich (kein Einladungscode, keine Freischaltung). +- Benutzername: Muster `^[a-zA-Z0-9._-]{3,32}$` – 3 bis 32 Zeichen, Buchstaben, Zahlen, Punkt, + Unterstrich, Bindestrich. Muss plan-übergreifend eindeutig sein (`User.username @unique`). +- Passwort: mindestens 6 Zeichen; wird mit bcrypt (Cost-Faktor 12) gehasht. +- Bei Erfolg wird sofort eine Session gesetzt (Login inbegriffen), HTTP 201. +- Bei belegtem Benutzernamen: HTTP 409 mit Meldung „Dieser Benutzername ist bereits vergeben." + +Referenz: `src/lib/users.ts` Zeilen 6–22, `src/app/api/auth/register/route.ts`. + +### 3.1.2 Anmeldung + +- Benutzername + Passwort; Prüfung via `bcrypt.compare`. +- Fehlermeldung ist bewusst unspezifisch: „Benutzername oder Passwort falsch." (HTTP 401) – + verrät nicht, ob der Benutzer existiert. +- Bei Erfolg: JWT (HS256, Payload `{ userId }`, Gültigkeit 30 Tage) im HttpOnly-Cookie + `fpt_session` (`sameSite=lax`, `secure` nur in Produktion, `maxAge` 30 Tage). + +Referenz: `src/app/api/auth/login/route.ts`, `src/lib/auth.ts`. + +### 3.1.3 Abmeldung + +`POST /api/auth/logout` löscht das Cookie. Da die Session zustandslos ist (JWT), bleibt ein +bereits kopiertes Token bis zum Ablauf technisch gültig – es gibt keine serverseitige +Token-Sperrliste. + +### 3.1.4 Passwortänderung + +Im Profilmenü. Erfordert das aktuelle Passwort; das neue Passwort muss ≥ 6 Zeichen haben und +wird im Dialog gegen eine Wiederholung geprüft (Client-seitig). Nach Erfolg erscheint 1.2 s lang +„Passwort geändert.", dann schliesst der Dialog. + +Referenz: `src/app/api/auth/change-password/route.ts`, `src/components/ProfileMenu.tsx` Zeilen 117–140. + +### 3.1.5 Zugriffsschutz + +Zweistufig: + +1. **Middleware** (`src/middleware.ts`, Edge-Runtime): schützt alle Pfade ausser + `/login`, `/api/auth/login`, `/api/auth/register`, `/_next/*`, `/favicon*`. + Ohne gültiges Token → API-Aufrufe erhalten HTTP 401, Seitenaufrufe werden nach + `/login?next=` umgeleitet. +2. **Ownership-Check in jeder API-Route**: `getCurrentUserId()` plus eine Abfrage, die den + Datensatz nur zurückgibt, wenn er dem Benutzer gehört (`getOwnedPlan`, `getOwnedPhase`, + `getOwnedElement` in `src/lib/queries.ts`). Ein fremder Datensatz führt zu HTTP 404 + (nicht 403) – die Existenz wird nicht preisgegeben. + +## 3.2 Plan-Verwaltung + +### 3.2.1 Plan erstellen + +Dialog mit Name plus dem vollständigen Grundprofil: + +| Feld | Typ | Default | Wertebereich | +|---|---|---|---| +| Name des Plans | Text | „Basisplan" | 1–120 Zeichen | +| Haushaltsform | Auswahl | `SINGLE` | `SINGLE` / `COUPLE` | +| Name je Person | Text (optional) | leer | ≤ 60 Zeichen | +| Aktuelles Alter | Zahl | 35 | 0–120 | +| Pensionierungsalter | Zahl | 65 | 30–100 | +| Erwartete Inflationsrate (%) | Zahl | 1.5 | −20 bis 50 | + +Konsistenzregel: `SINGLE` erfordert genau eine Person, `COUPLE` genau zwei (Person A und B). +Verletzung → HTTP 400 mit Klartextmeldung. + +Referenz: `src/components/PlanProfileFields.tsx` Zeilen 1012–1018, `src/app/api/plans/route.ts` +Zeilen 6–35. + +Ein neu erstellter Plan hat **keine Phasen und keine Elemente**; `initialCash` ist 0. + +### 3.2.2 Grundprofil ändern + +Über „Einstellungen" in der Planansicht. Wichtig: Die Personen werden serverseitig in einer +Transaktion **gelöscht und neu angelegt** (`deleteMany` + `create`). Die Person-IDs ändern sich +dadurch. Da Elemente über `ownerRole` (nicht über `personId`) zugeordnet sind, bleibt die +Zuordnung erhalten. + +Ein Wechsel von `COUPLE` auf `SINGLE` entfernt Person B. Elemente mit `ownerRole = PERSON_B` +bleiben in der Datenbank bestehen, finden aber keinen Owner mehr – siehe +[9.2](#92-verwaiste-person_b-elemente). + +Referenz: `src/app/api/plans/[planId]/route.ts` Zeilen 55–70. + +### 3.2.3 Cash-Anfangswert + +Klick auf die erste Zelle der Cash-Zeile öffnet den Dialog „Cash-Anfangswert". Wertebereich +0 bis 1'000'000'000, wird auf ganze Franken gerundet. + +Referenz: `src/components/PlanView.tsx` Zeilen 1130–1156, `src/app/api/plans/[planId]/route.ts` Zeile 36. + +### 3.2.4 Plan löschen + +Aus der Übersichtskachel oder der Planansicht, mit Browser-`confirm()`. Löscht per Datenbank-Cascade +Personen, Phasen, Elemente und alle Werte. Szenarien, die auf diesen Plan als `parentPlanId` +zeigen, werden **nicht** gelöscht – ihre `parentPlanId` wird auf `NULL` gesetzt +(`onDelete: SetNull`). + +Referenz: `prisma/schema.prisma` Zeile 82. + +### 3.2.5 Szenarien + +Ein Szenario ist eine **Deep-Copy eines Plans bis zu einer gewählten Verzweigungsphase (inklusive)**. + +Kopiert werden: +- Grundprofil (Haushaltsform, Inflation, `initialCash`) und alle Personen +- alle Phasen mit `sequenceNumber <= branchPhase.sequenceNumber` +- **alle** Elemente (unabhängig von der Verzweigungsphase) +- Phasenwerte nur für kopierte Phasen; Übergangswerte nur für Übergänge aus kopierten Phasen + +Gesetzt werden `parentPlanId` (Ursprungsplan) und `branchFromPhaseId` (die **neue** ID der +letzten kopierten Phase). Der Benutzer landet direkt im neuen Szenario, das anschliessend +unabhängig weiterentwickelt wird. + +In der Planliste erscheint ein Szenario mit dem Zusatz „· Szenario". + +Referenz: `src/app/api/plans/[planId]/scenario/route.ts`. + +## 3.3 Lebensphasen + +### 3.3.1 Phase anlegen + +Neue Phasen werden **immer am Ende der Kette** angehängt (`sequenceNumber = Anzahl + 1`). + +**Automatische Dauer-Kappung:** Die Dauer wird ans nächste Pensionsereignis gekappt. Formel +(`maxPhaseDuration`): für jede Person, die zu Phasenbeginn noch erwerbstätig ist, gilt +`retirementAge − (age + yearsBefore)`; das Minimum dieser Werte ist die Obergrenze. Ist keine +Person mehr erwerbstätig, gibt es keine Obergrenze (`null`). Diese Kappung ist im Dialog +sichtbar („max. N") **und** wird serverseitig erzwungen. + +Fachliche Begründung: Eine Phase darf keine Pensionierung überspannen, weil der Phasentyp +und die AHV-/PK-Renten am Phasenbeginn ausgewertet werden. + +**Default-Dauer:** die Kappung, sonst 10 Jahre. +**Default-Name:** Phase 1 → „Erste Lebensphase"; sonst „Pensionsphase" wenn zu Phasenbeginn +mindestens eine Person pensioniert ist, sonst „Erwerbsphase". + +**Vorbelegung der Elemente:** Beim Anlegen einer Phase wird für jedes noch aktive Element +(nicht `SOLD`, nicht `SETTLED`) ein `ElementPhaseValue` mit den **editierbaren** Feldern der +Vorphase erzeugt (`buildCarryData`): + +| Kategorie | Übernommene Felder | +|---|---| +| `INCOME`, `EXPENSE` | nur `teuerungsausgleich` (Basis wird live fortgeschrieben) | +| `AHV` | `gapYears: 0` | +| `PENSION_FUND`, `PILLAR_3A`, `OTHER_ASSET` | `annualContribution`, `expectedReturn` | +| `REAL_ESTATE` | `purchasePrice`, `amortization` (Resthypothek wird live fortgeschrieben) | +| `OTHER_DEBT` | `annualRepayment` | + +Bestände (PK-/3a-/Vermögenswert, Resthypothek, Restschuld) werden **bewusst nicht als Snapshot +gespeichert**, sondern in jeder Berechnung live aus der Vorphase fortgeschrieben. Damit wirken +sich nachträgliche Änderungen an frühen Phasen automatisch auf alle Folgephasen aus. + +Referenz: `src/app/api/plans/[planId]/phases/route.ts`. + +### 3.3.2 Phase bearbeiten + +Klick auf einen Phasenkopf öffnet ein Detail-Panel unterhalb der Matrix mit Bezeichnung und +Dauer. Die Dauer wird auch hier gekappt. Eine phasenspezifische Inflationsrate gibt es nicht; +das Panel weist darauf hin: „Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen +gesetzt." + +Referenz: `src/components/PhaseDetail.tsx`. + +### 3.3.3 Phase löschen + +**Nur die letzte Phase** kann gelöscht werden – serverseitig geprüft (HTTP 400: „Nur die letzte +Phase kann geloescht werden."). Damit bleibt die Kette der `sequenceNumber` lückenlos. Der +Löschen-Button erscheint im Detail-Panel nur bei der letzten Phase. + +Referenz: `src/app/api/phases/[phaseId]/route.ts` Zeilen 55–77. + +## 3.4 Finanzielle Elemente + +### 3.4.1 Kategorien und Personenzuordnung + +| Kategorie | Label | Zuordnung | +|---|---|---| +| `INCOME` | Einkommen | frei: Gemeinsam / Person A / Person B | +| `EXPENSE` | Ausgaben | frei | +| `AHV` | AHV | **zwingend genau eine Person** | +| `PENSION_FUND` | Pensionskasse | **zwingend genau eine Person** | +| `PILLAR_3A` | Säule 3a | **zwingend genau eine Person** | +| `REAL_ESTATE` | Immobilie | frei | +| `OTHER_ASSET` | Sonstiges Vermögen | frei | +| `OTHER_DEBT` | Sonstige Schulden | frei | + +Die Vorsorge-Kategorien (`PERSON_ONLY_CATEGORIES`) sind zwingend personengebunden, weil AHV-Rente, +PK-Verrentung und 3a-Bezug am **individuellen** Pensionierungszeitpunkt hängen. Wird für diese +Kategorien kein `PERSON_A`/`PERSON_B` übergeben → HTTP 400. + +Für die übrigen Kategorien gilt: fehlt die Zuordnung, wird serverseitig `HOUSEHOLD` gesetzt. + +Referenz: `src/lib/elements.ts` Zeilen 19–23, `src/app/api/plans/[planId]/elements/route.ts` Zeilen 43–54. + +### 3.4.2 Element anlegen + +Der Dialog „Finanzielles Element" fragt Kategorie, Zuordnung, Bezeichnung **und direkt die +Werte der ersten Lebensphase** ab. Nach dem Anlegen (`POST .../elements`) werden die Werte per +`PUT .../phase/` gespeichert, sofern mindestens ein Feld gesetzt wurde. Der +Bezeichnungs-Default ist das Kategorie-Label. + +`orderIndex` = bisheriges Maximum + 1; bestimmt die Reihenfolge innerhalb der Kategoriegruppe. + +Referenz: `src/components/PlanView.tsx` Zeilen 799–947. + +### 3.4.3 Element bearbeiten und löschen + +Ein Klick auf eine **Phasenzelle** öffnet den Dialog „Lebensphase: " mit den +kategorie- und kontextabhängigen Feldern (siehe [3.4.4](#344-feldkatalog-je-kategorie)) sowie +dem Button „Element löschen". Löschen entfernt das Element **aus allen Phasen** +(Browser-`confirm()`, dann Cascade auf `ElementPhaseValue` und `ElementTransitionValue`). + +Umbenennen ist per API möglich (`PATCH /api/elements/`), im aktuellen UI aber nicht +angebunden. + +### 3.4.4 Feldkatalog je Kategorie + +Die angezeigten Felder hängen von drei Kontextgrössen ab: +- **`carried`** – ob der Basiswert aus der Vorphase fortgeschrieben wird (ab Phase 2) +- **`ownerWorking`** – ob der zugeordnete Besitzer in dieser Phase erwerbstätig ist +- **`durationYears`** – Phasendauer (begrenzt z. B. die Ausfalljahre) + +Referenz: `src/components/ElementDetail.tsx` Zeilen 124–340. + +#### INCOME (Einkommen) + +Hinweistext: „Einkommen wird NOMINAL erfasst (die Zahl auf dem Lohnausweis)." + +| Feld | JSON | Semantik | +|---|---|---| +| Jahreseinkommen NOMINAL (erstes Jahr) | `amount` | Basiswert Jahr 1. Ab Phase 2 vorbelegt mit dem fortgeschriebenen Wert, bewusst änderbar (Teilzeit, Beförderung). | +| ≈ real (heutige Kaufkraft) | – | Read-only Info: `amount / deflatorStart` | +| Nominale Lohnerhöhung (%/Jahr) | `teuerungsausgleich` | Default 0 %. 0 % = nominal gleichbleibend, real sinkend. | + +#### EXPENSE (Ausgaben) + +Hinweistext: „Ausgaben werden REAL erfasst (in heutiger Kaufkraft)." + +| Feld | JSON | Semantik | +|---|---|---| +| Jahresausgaben REAL (erstes Jahr) | `amount` | Basiswert in heutiger Kaufkraft. | +| ≈ nominal (in diesem Jahr) | – | Read-only Info: `amount * deflatorStart` | +| Reale Mehrausgaben (%/Jahr) | `teuerungsausgleich` | **Zusätzlich** zur Inflation. 0 % = gleicher Lebensstandard. | + +Die Asymmetrie (Einkommen nominal, Ausgaben real) ist der Kern des V5-Modells: Man kennt seinen +Lohn nominal, aber seinen Lebensstandard real. + +#### AHV + +| Zustand | Anzeige | +|---|---| +| Besitzer erwerbstätig | Eingabefeld **Geplante Ausfalljahre** (`gapYears`), 0 bis Phasendauer. Hilfetext: „Jedes Ausfalljahr kuerzt die spaetere Rente um 1/44." | +| Besitzer pensioniert | Nur Hinweistext – die Rente wird automatisch berechnet. | + +#### PENSION_FUND (Pensionskasse) + +| Zustand | Felder | +|---|---| +| erwerbstätig, Phase 1 | **Aktueller PK-Wert** (`currentValue`), **Jährliche Einzahlung** (`annualContribution`), **Erwartete Rendite** (`expectedReturn`) | +| erwerbstätig, ab Phase 2 | **Startwert (fortgeschrieben)** (read-only), **Zusatzeinlage aus Kapital** (`additionalInvestment`), Einzahlung, Rendite | +| pensioniert | Hinweistext (Rente aus Umwandlungssatz bzw. „Vollständig bezogen") | + +Wichtig (Hilfetext im UI): Die PK-Einzahlung „Fliesst NICHT in die Sparquote ein (bereits in den +Ausgaben beruecksichtigt)" – Lohnabzüge sind im Nettoeinkommen bereits weg. + +#### PILLAR_3A (Säule 3a) + +Wie PK, aber: +- Die Einzahlung **zählt** zur Sparquote (verlässt das Cash). +- Das Feld ist auf `PILLAR_3A_MAX_ANNUAL` = **7'258 CHF** (2026, mit PK) hart geklammert. +- Bei Pensionierung: „Die Saeule 3a wird beim Pensions-Uebergang vollstaendig bezogen." + +#### REAL_ESTATE (Immobilie) + +| Zustand | Felder | +|---|---| +| Phase 1 / Neukauf | **Kaufpreis** (`purchasePrice`), **Hypothek** (`mortgage`), **Amortisation CHF/Jahr** (`amortization`) | +| ab Phase 2, fortgeschrieben | **Startwert Netto (fortgeschrieben)** (read-only = Kaufpreis − Resthypothek), **Amortisation** | + +#### OTHER_ASSET (Sonstiges Vermögen) + +| Feld | JSON | Semantik | +|---|---|---| +| Startwert / Startwert (fortgeschrieben) | `startValue` | ab Phase 2 read-only | +| Zusatzinvestition aus Kapital | `additionalInvestment` | nur ab Phase 2 | +| Erwartete Rendite (%/Jahr) | `expectedReturn` | | +| Jährlicher Sparbeitrag | `annualContribution` | fliesst ins Vermögen, verlässt das Cash → **geplante Sparrate** | +| Jährliche Bezugsrate | `annualWithdrawal` | Entnahme aus dem Vermögen ins Cash → **geplante Verzehrrate** | + +Die Bezugsrate ist das Instrument für den Kapitalverzehr im Alter. + +#### OTHER_DEBT (Sonstige Schulden) + +| Feld | JSON | Semantik | +|---|---|---| +| Restschuld / (fortgeschrieben) | `startValue` | ab Phase 2 read-only | +| Jährliche Tilgung | `annualRepayment` | verlässt das Cash → geplante Sparrate | + +Schulden gehen mit **negativem** Vorzeichen ins Vermögen ein. + +## 3.5 Übergänge + +### 3.5.1 Konzept + +Zwischen zwei Phasen liegt ein Übergang. Er ist der Ort, an dem einmalige Entscheide getroffen +werden. Nur fünf Kategorien haben Übergangs-Entscheide (`TRANSITION_CATEGORIES`): +`PENSION_FUND`, `PILLAR_3A`, `REAL_ESTATE`, `OTHER_ASSET`, `OTHER_DEBT`. + +Für `INCOME`, `EXPENSE` und `AHV` erscheint: „Fuer diese Kategorie gibt es im Uebergang keine +Eingaben." + +### 3.5.2 Normaler Übergang vs. Pensions-Übergang + +Ein Übergang ist für ein Element ein **Pensions-Übergang**, wenn dessen Besitzer in der Von-Phase +erwerbstätig und in der Ziel-Phase pensioniert ist (`isRetirementTransition`). Nur +personenzugeordnete Elemente (nicht `HOUSEHOLD`) können das auslösen. + +| Kategorie | Normaler Übergang (Vorbezug) | Pensions-Übergang | +|---|---|---| +| `PENSION_FUND` | Bezug? Kein Bezug / Bezug + Bruttobetrag (max. Endwert) + **Kapitalbezugssteuer (%)** | **Bezugsart**: Rente / Kapitalbezug / Kombination | +| `PILLAR_3A` | Bezug? Kein Bezug / Bezug + Bruttobetrag + **Kapitalbezugssteuer (%)** | Nur **Kapitalbezugssteuer (%)** – Bezug ist zwingend vollständig | +| `REAL_ESTATE` | Halten / Verkaufen (+ Verkaufspreis, Grundstückgewinnsteuer) | identisch | +| `OTHER_ASSET` | Halten / Verkaufen | identisch | +| `OTHER_DEBT` | Sofortige Tilgung (CHF) | identisch | + +Ein **Vorbezug** (vor der Pensionierung, z. B. für Wohneigentum oder Selbstständigkeit) ist wie +der Bezug bei Pensionierung kapitalbezugssteuerpflichtig. Der eingegebene Betrag ist der +**Bruttobezug**: er wird in voller Höhe dem Vorsorgekapital entnommen, ins Cash fliesst der +Betrag nach Abzug der Steuer. Der Dialog zeigt die resultierende Netto-Auszahlung als +read-only Vorschau an. + +Bei PK-Bezugsart: +- **Rente**: Feld Umwandlungssatz (Default 6 %) +- **Kapitalbezug**: Feld Kapitalbezugssteuer (Default 8 %) +- **Kombination**: beide Felder plus „Davon Kapitalbezug (CHF)", geklammert am Endwert + +Referenz: `src/components/ElementDetail.tsx` Zeilen 342–462. + +### 3.5.3 Ampel-Logik: „offene" Entscheide + +Ein Entscheid gilt als **beantwortet** (`isTransitionAnswered`), wenn das jeweilige +Entscheidungsfeld gesetzt ist: + +| Kategorie | Beantwortet, wenn | +|---|---| +| `REAL_ESTATE`, `OTHER_ASSET` | `decision` gesetzt | +| `PENSION_FUND` | Pensions-Übergang: `payoutMode` gesetzt; sonst: `withdrawalMode` gesetzt | +| `PILLAR_3A` | Pensions-Übergang: **immer** beantwortet; sonst: `withdrawalMode` gesetzt | +| alle anderen | immer beantwortet | + +Der Übergangs-Spaltenkopf zeigt entweder „N offen" (Akzentfarbe) oder „geprüft" (grün, Häkchen). +Offene Zellen sind farblich hervorgehoben und zeigen „?". + +**Ein Element ist am Übergang inaktiv** (`transitionInactive`), wenn es bereits verkauft/getilgt +ist **oder** wenn es eine PK/3a ist, deren Besitzer schon zu Beginn der Von-Phase pensioniert war +(dann ist bereits bezogen/verrentet). Inaktive Zellen zeigen „–" und sind nicht anklickbar. + +Referenz: `src/components/PlanView.tsx` Zeilen 200–232, `src/components/ElementDetail.tsx` Zeilen 107–120. + +### 3.5.4 Geführter Übergang (Review-Dialog) + +Klick auf einen Übergangs-Spaltenkopf öffnet „Übergang prüfen: ". Der Dialog +listet **alle** noch aktiven Elemente der Übergangs-Kategorien untereinander mit ihren +Entscheidfeldern und kontextabhängigen Hinweisen: + +- PK/3a, normaler Übergang: „Hier könnten Sie optional Kapital beziehen." +- PK, Pensionierung: „Pensionierung: Bezugsart wählen (Rente / Kapital / Kombination)." +- 3a, Pensionierung: „Wird bei Pensionierung vollständig bezogen." + +„Alle speichern" schreibt jeden Entscheid einzeln per `PUT`. Entscheidend: Die Formulare sind mit +`withTransitionDefaults` vorbelegt (Halten / Kein Bezug / Rente), damit ein blosses Speichern den +**sichtbaren** Default auch tatsächlich persistiert und die Ampel auf grün geht. + +Referenz: `src/components/PlanView.tsx` Zeilen 1040–1127, `src/components/ElementDetail.tsx` Zeilen 89–105. + +## 3.6 Auswertung und Visualisierung + +### 3.6.1 Anzeigemodus nominal / beide / real + +Ein Umschalter oben in der Planansicht steuert die Darstellung aller Geldbeträge in Matrix und +Phasenköpfen: + +| Modus | Darstellung | +|---|---| +| Nominal | `1'234'567` | +| Beide | `1'234'567 (890'123)` – nominal, real in Klammern | +| Real | `890'123` | + +„real" bedeutet kaufkraftbereinigt auf den **Planbeginn**: `nominal / Deflator`. Die Wahl wird in +`localStorage` unter `fpt-value-mode` gespeichert. + +Es werden zwei verschiedene Deflatoren verwendet – siehe [4.5.3](#453-die-drei-deflatoren). + +Referenz: `src/components/PlanView.tsx` Zeilen 665–699. + +### 3.6.2 Zeitachse + +Horizontaler Balken über das Alter (von jüngster Person bis Planende) mit: +- Flaggen-Marker je Person am Pensionsalter (Farbe: Person A indigo, Person B hellblau) +- Trennstriche an den Phasengrenzen +- rotem „Ruin "-Marker, falls zutreffend + +Referenz: `src/components/Timeline.tsx`. + +### 3.6.3 Phasenkopf-Kennzahlen + +Jeder Phasenkopf zeigt kompakt: + +| Kennzahl | Bedeutung | +|---|---| +| Name + Status-Icon | grünes Häkchen oder rotes Warnsymbol (Liquiditätslücke) | +| Typ-Badge + Dauer | Erwerb / Pension / Misch, „N J." | +| Alter je Person | ` ` | +| Einkommen | Jahr 1 → letztes Jahr | +| Ausgaben | Jahr 1 → letztes Jahr (nominal) | +| **Quote** bzw. **Verzehr** | Einkommen − Ausgaben; Label wechselt auf „Verzehr", wenn Jahr 1 negativ | +| Geplante Sparrate | 3a + Sparbeitrag + Amortisation + Tilgung, im **ersten** Phasenjahr | +| Geplante Verzehrrate | Summe der Bezugsraten | +| Kapitalzufluss | nur wenn > 0: Verkäufe + PK-/3a-Bezüge aus dem Übergang **in** diese Phase | +| Kapitalinvestitionen | nur wenn > 0: Zusatzinvestitionen + Sofort-Tilgungen | +| Vermögen | Start → Ende (inkl. Cash) | + +Referenz: `src/components/PlanView.tsx` Zeilen 701–769. + +### 3.6.4 Dashboard + +Erscheint unterhalb der Matrix, sobald mindestens eine Phase existiert. + +**Kennzahl-Karten:** Endvermögen nominal, Endvermögen real, Geschätzter Nachlass +(= Endvermögen der letzten Phase, „potenziell vererbbar"). + +**Grafik 1 – Einkommen vs. Ausgaben pro Jahr** (`SparquoteChart`): Ein Datenpunkt pro Jahr +über alle Phasen. Grüne Linie = Einkommen nominal (inkl. Renten), rote Linie = Ausgaben nominal, +graue gestrichelte Linie = Ausgaben real. Die Fläche zwischen Einkommen und nominalen Ausgaben +ist grün (Sparquote) oder rot (Verzehr) eingefärbt. Der Keil zwischen roter und grauer Linie ist +anschaulich „das, was die Inflation frisst". + +**Grafik 2 – Vermögensverlauf nach Alter** (`WealthChart`): Liniendiagramm über das Alter von +Person A. Je Plan eine durchgezogene Linie (nominal) und eine gestrichelte (real). Datenpunkte: +Startvermögen Phase 1 plus je ein Endwert pro Phase. Über Checkboxen lassen sich **andere Pläne +überlagern** (Szenariovergleich); deren Daten werden bei Bedarf nachgeladen und im Client +zwischengespeichert. + +**Grafik 3 – Vermögensaufteilung pro Phase**: Gestapeltes Balkendiagramm mit zwei Balken je Phase +(Beginn / Ende). Gestapelt werden alle Elemente der Kategorien PK, 3a, Immobilie, Sonstiges +Vermögen, die irgendwann einen positiven Wert haben. Stapelung nach `elementId` (nicht Name), +damit gleichnamige Elemente nicht kollidieren. Negative Werte werden auf 0 geklammert. + +Referenz: `src/components/Dashboard.tsx`, `src/components/WealthChart.tsx`, `src/components/SparquoteChart.tsx`. + +### 3.6.5 CSV-Export + +`GET /api/plans//export` liefert eine semikolon-getrennte CSV, eine Zeile pro Phase: + +``` +Phase;Typ;Dauer;Einkommen (Beginn);Ausgaben (Beginn);Quote (Beginn);Quote (Ende);Cash (Ende);Endvermoegen (nominal);Endvermoegen (real) +``` + +Bei vorhandenem Ruin folgt eine Schlusszeile `Ruin: Kapital aufgebraucht mit Alter `. +Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt. + +Referenz: `src/lib/calculations.ts` Zeilen 620–648. + +## 3.7 Bedienoberfläche + +### 3.7.1 Layout + +- **Sidebar** (Desktop ab `lg` fix, mobil als Overlay): Logo, „Übersicht", Planliste mit + Phasenanzahl, „+"-Button für neuen Plan. +- **Header**: Menü-Button (mobil), Plantitel, Profilmenü. +- **Hauptbereich**: Übersicht (Plan-Kacheln) oder Planansicht (Umschalter, Zeitachse, + Grundprofil-Leiste, Aktionsbuttons, Ruin-Banner, Matrix, Detail-Panel, Dashboard). + +### 3.7.2 Farbschemata + +Drei Themes: **Hell**, **Dunkel**, **Warm** (cremefarben, Koralle-Akzent). Wahl im Profilmenü, +persistiert in `localStorage` (`fpt-theme`), gesetzt als `data-theme` am ``. Ohne +gespeicherte Wahl folgt die Oberfläche `prefers-color-scheme`. Ein Inline-Script im `` +setzt das Attribut vor dem ersten Paint (verhindert FOUC). + +Alle Farben laufen über semantische CSS-Variablen (`--bg`, `--surface`, `--accent`, `--danger`, …). + +Referenz: `src/lib/theme.ts`, `src/app/globals.css`, `src/app/layout.tsx` Zeilen 1181–1196. + +### 3.7.3 Geldeingabefelder + +Die `MoneyInput`-Komponente ist ein spezialisiertes Betragsfeld: +- Unfokussiert Anzeige mit Apostroph-Tausendertrennung (`1'234'567`), fokussiert reine Ziffern +- Ein Default-Wert 0 wird beim Fokussieren geleert, sonst der Text markiert +- Pfeiltasten: ↑/↓ = ±1, Shift+↑/↓ = ±100 +- Pfeil-Buttons mit **Klick-und-Halten-Beschleunigung**: nach 400 ms Wiederholung im 70-ms-Takt, + Schrittweite wächst mit der Haltedauer (1 → 10 → 100 → 1'000 → 10'000) +- Optionale harte Klammerung über `min` (Default 0) und `max` + +**Zahlenformat:** Bewusst nicht über `toLocaleString("de-CH")`, weil dessen Trennzeichen das +typografische Apostroph (U+2019) ist. Das Tool verwendet durchgehend das gerade Apostroph. + +Referenz: `src/components/FormField.tsx` Zeilen 60–200, `src/lib/format.ts`. + +### 3.7.4 Hilfe-Bubbles + +Feldbeschriftungen können ein Info-Icon tragen (`InfoBubble`), das per Hover oder Klick einen +Erklärtext einblendet. Wird durchgehend für die fachlich heiklen Felder verwendet. + +### 3.7.5 Reaktivität + +Die Anwendung rechnet **nicht im Client**. Jede Änderung führt zu einem `PUT`/`PATCH`/`POST`, +gefolgt von `onChanged()` → `loadDetail(planId, silent = true)`. Der „silent"-Refresh lädt Plan +und Berechnung neu, ohne die `PlanView` zu demontieren – so bleibt die Scrollposition nach dem +Schliessen eines Popups erhalten. + +Referenz: `src/components/AppShell.tsx` Zeilen 46–74. + +--- + +# 4. Berechnungsmodell + +> Dies ist der fachliche Kern des Tools. Der gesamte Abschnitt beschreibt +> `src/lib/calculations.ts`, Funktion `computePlan(plan: PlanInput): PlanComputed`. +> Die Funktion ist **rein** (keine I/O, keine Datenbank) und dadurch isoliert testbar. + +## 4.1 Ablauf im Überblick + +``` +für jede Phase i (nach sequenceNumber sortiert): + 1. Personen-Infos berechnen (Alter, erwerbstätig?), Phasentyp ableiten + 2. AHV: Ausfalljahre kumulieren, Renten der Pensionierten berechnen, plafonieren + 3. Element-Setup: je Element Startwerte/Raten bestimmen, in Arbeitslisten einordnen + 4. Investitionen vom Cash abziehen → cashStart + 5. Jahresschleife t = 1..duration: + Flows indexieren, Vermögen verzinsen, Bezugsraten entnehmen, + Cash fortschreiben, Ruin prüfen, YearPoint anfügen + 6. Endwerte je Element setzen, Phasen-Kennzahlen zusammenstellen + 7. Übergang: Entscheide anwenden, Carry aktualisieren, Cash der Folgephase bilden +``` + +## 4.2 Zustand über Phasengrenzen: der Carry + +Zwischen den Phasen wird pro Element ein `Carry`-Objekt fortgeschrieben: + +| Feld | Bedeutung | +|---|---| +| `status` | `ACTIVE` / `SOLD` / `SETTLED` | +| `value` | Aktiven-Saldo (PK / 3a / Sonstiges Vermögen) am Ende der Vorphase | +| `mortgage` | Immobilie: Resthypothek | +| `owed` | Schulden: Restschuld (positiv geführt) | +| `pkPensionAnnual` | PK: jährliche Rente nach Verrentung | +| `flowBasis` | Einkommen/Ausgaben: indexierter Basiswert der nächsten Phase | +| `hasCarry` | ob überhaupt eine Vorphase existiert (steuert read-only vs. Eingabe im UI) | + +Zusätzlich laufen phasenübergreifend mit: `cashCarryIn`, `cumulativeInflation`, +`gapYearsByPerson`, `yearsBefore`, `ruinAge`, `incomingInflow`, `incomingImmediateRepay`. + +Referenz: `src/lib/calculations.ts` Zeilen 103–115, 132–147. + +## 4.3 Personen und Phasentyp + +Für jede Person und jede Phase: + +``` +startAge = person.age + yearsBefore +endAge = startAge + duration +working = startAge < retirementAge +retiresAtStart = startAge === retirementAge +``` + +`yearsBefore` ist die Summe der Dauern aller vorangehenden Phasen. **Alter wird also relativ +gezählt** – es gibt keine Kalenderdaten im Modell, nur „Jahre ab Planbeginn". + +`maxDurationYears` = Minimum über `retirementAge − startAge` aller noch erwerbstätigen Personen +(nur positive Werte), sonst `null`. + +## 4.4 AHV-Rente + +Zwei Durchgänge pro Phase über alle AHV-Elemente: + +**1. Ausfalljahre kumulieren** – für Elemente, deren Besitzer in dieser Phase **erwerbstätig** ist: + +``` +gapYearsByPerson[owner] += max(0, round(phaseData.gapYears)) +``` + +Die Ausfalljahre akkumulieren also über alle Erwerbsphasen hinweg. + +**2. Rente berechnen** – für Elemente, deren Besitzer in dieser Phase **pensioniert** ist: + +``` +factor = max(0, (44 − gapYears) / 44) +rente = round(32'760 × factor) +``` + +- `AHV_MAX_ANNUAL_SINGLE = 32'760` – maximale einfache Altersrente pro Jahr inkl. 13. Rente + (2'520/Monat × 13), Stand 2026, Quelle BSV. +- `AHV_FULL_CONTRIBUTION_YEARS = 44` – volle Beitragsdauer (Rentenskala 44). + +**3. Ehepaar-Plafonierung** – nur bei `householdType = COUPLE` **und** wenn für **beide** +Personen eine Rente vorliegt: + +``` +cap = 32'760 × 1.5 = 49'140 +falls (renteA + renteB) > cap: + beide Renten proportional kürzen: rente × cap / summe +``` + +Die Rente ist danach **nominal fix** – sie wird über die Phasen hinweg nicht indexiert und +verliert damit real an Kaufkraft. Sie fliesst in `renteTotal` und wird im Einkommen mitgeführt. + +Referenz: `src/lib/calculations.ts` Zeilen 180–202, `src/lib/constants.ts`. + +## 4.5 Nominal, real und die Deflatoren + +### 4.5.1 Das V5-Modell + +| Grösse | Erfassung | Indexierung über die Phasenjahre | +|---|---|---| +| Einkommen | **nominal** | `basis × (1 + Lohnerhöhung)^(t−1)` | +| Renten (AHV, PK) | **nominal** | keine – konstant | +| Ausgaben | **real** | real: `basis × (1 + reale Mehrausgaben)^(t−1)`, dann **× kumulierte Inflation** | + +Die Inflation ist seit V5 **plan-weit** (`plan.inflationRateDefault`) und gilt einheitlich für +alle Phasen; eine phasenspezifische Überschreibung existiert nicht (mehr). + +### 4.5.2 Kumulierte Inflation + +``` +cumInflStart(Phase 1) = 1 +cumInflStart(Phase n) = cumInflStart(Phase n−1) × (1 + infl/100)^duration(n−1) +``` + +### 4.5.3 Die drei Deflatoren + +Ein subtiler, aber wichtiger Punkt: Bestandswerte und Flow-Werte haben am Phasenende **nicht +denselben** Deflator, weil ein Flow im Jahr `duration` anfällt, ein Bestand aber **nach** dem +Jahr `duration` gemessen wird. + +| Deflator | Formel | Verwendung | +|---|---|---| +| `cumulativeInflationStart` | s. o. | Bestände zu Phasenbeginn, Flows im Jahr 1 | +| `cumulativeInflationEnd` | `cumInflStart × (1+infl)^duration` | Bestände am Phasenende (Cash, Vermögen) | +| `flowDeflatorEnd` | `cumInflStart × (1+infl)^(duration−1)` | Flow-Endwerte (Einkommen, Ausgaben, Quote) – **eine Kaufkraft-Stufe weniger** | + +Referenz: `src/lib/calculations.ts` Zeilen 427–429; Anwendung in `src/components/PlanView.tsx` Zeilen 686–687, 716–717. + +## 4.6 Element-Setup je Kategorie + +Für jedes Element (sortiert nach `orderIndex`) wird ein `ElementPhaseComputed` erzeugt. +Vorab-Abbruch: Ist der Carry-Status `SOLD` → Notiz „Verkauft"; ist er `SETTLED` und die Kategorie +`OTHER_DEBT` → „Getilgt". Solche Elemente werden nicht weiter gerechnet. + +### 4.6.1 INCOME / EXPENSE + +``` +idx = phaseData.teuerungsausgleich ?? 0 +baseValue = hasCarry ? round(carry.flowBasis) : round(phaseData.amount) +basis = !hasCarry → round(phaseData.amount) + phaseData.amount ist Zahl → round(phaseData.amount) // bewusster Override + sonst → baseValue // live vererbt +carry.flowBasis = basis × (1 + idx/100)^duration // für die Folgephase +``` + +**Die Vererbungsregel** (`src/components/ElementDetail.tsx` Zeilen 480–490): Beim Speichern wird +das Feld `amount` **gelöscht**, wenn es exakt dem fortgeschriebenen Wert entspricht. Dadurch +bleibt der Wert „live vererbt" – eine spätere Änderung in einer früheren Phase wirkt sich weiter +durch. Nur ein bewusst abweichender Wert wird fix gespeichert. + +Man beachte den Exponenten-Unterschied: der **Endwert** der Phase nutzt `duration − 1` +(letztes Jahr), der **Carry** für die Folgephase nutzt `duration` (ein Jahr weiter). + +### 4.6.2 AHV + +Erwerbstätig → nur Zusammenfassung („N Ausfalljahre" / „Keine Ausfalljahre"), kein Wert. +Pensioniert → `startValue = endValue = rente`, Summand in `renteTotal`. + +### 4.6.3 PENSION_FUND + +| Fall | Verhalten | +|---|---| +| pensioniert, `pkPensionAnnual > 0` | Rente: `startValue = endValue = pkPensionAnnual`, Summand in `renteTotal` | +| pensioniert, keine Rente | Notiz „Vollständig bezogen" | +| erwerbstätig | `base = hasCarry ? carry.value : phaseData.currentValue`; `topUp = hasCarry ? additionalInvestment : 0`; `start = base + topUp`; Rate = `annualContribution` | + +Die PK-Rate wird **nicht** zu `fixedRatesTotal` addiert – sie belastet das Cash nicht. +`topUp` wird ab Phase 2 zu `investmentsFromCash` addiert. + +### 4.6.4 PILLAR_3A + +Identisch zu PK, mit zwei Unterschieden: +- Die Rate **wird** zu `fixedRatesTotal` addiert (belastet das Cash). +- Ist der Besitzer pensioniert, gibt es nie eine Rente, nur „Vollständig bezogen". + +### 4.6.5 REAL_ESTATE (Immobilie) + +``` +purchase = round(phaseData.purchasePrice) // in JEDER Phase aus den Phasendaten +mortgageStart = hasCarry ? carry.mortgage : round(phaseData.mortgage) +amort = round(phaseData.amortization) // wird JÄHRLICH am Restsaldo gekappt +equity = purchase − mortgageStart → startValue, wealthStart +``` + +Die Hypothek wird als **laufender Saldo** in der Jahresschleife geführt (siehe 4.7), nicht per +Linearformel. Sobald sie 0 erreicht, entfällt die Amortisationsrate. + +Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase): +`investmentsFromCash += max(0, equity)` – das Eigenkapital wird aus dem Cash finanziert. + +Der Wert der Immobilie ist über die Phasendauer **konstant der Kaufpreis**; nur die Hypothek +sinkt. Es gibt keine Wertsteigerung – der Verkaufspreis wird erst am Übergang erfasst. + +### 4.6.6 OTHER_ASSET + +``` +base = hasCarry ? carry.value : round(phaseData.startValue) +topUp = hasCarry ? round(phaseData.additionalInvestment) : 0 +start = base + topUp +rate = round(phaseData.annualContribution) → fixedRatesTotal += rate +withdrawal = round(phaseData.annualWithdrawal) → plannedWithdrawTotal += withdrawal +``` + +### 4.6.7 OTHER_DEBT + +``` +owedStart = hasCarry ? carry.owed : round(phaseData.startValue) +repay = round(phaseData.annualRepayment) // wird JÄHRLICH am Restsaldo gekappt +startValue = −owedStart // negatives Vorzeichen im Vermögen +``` + +Wie bei der Immobilie ist die Restschuld ein **laufender Saldo**; ist sie getilgt, entfällt die +Tilgungsrate. + +## 4.7 Die Jahresschleife + +Zunächst wird der Cash-Startwert gebildet: + +``` +cash = cashCarryIn − (isFirstPhase ? 0 : investmentsFromCash) +cashStart = cash +``` + +Die Investitionen werden also **am Phasenanfang** abgezogen. Grund (Kommentar im Code, Fix in +Commit `f768e01`): Der Cash-Startwert zeigt damit den Bestand **nach** den Investitionen – die +investierten Mittel erscheinen im Vermögen und nicht doppelt auch im Cash. + +Dann für `t = 1 .. duration`: + +``` +// 1. Einkommen (nominal) +incomeFlow = renteTotal + Σ (inc.basis × (1 + inc.idx/100)^(t−1)) + +// 2. Ausgaben (real → nominal) +inflFactor = cumInflStart × (1 + infl/100)^(t−1) +expenseReal = Σ (exp.basis × (1 + exp.idx/100)^(t−1)) +expenseNominal = expenseReal × inflFactor + +// 3. Quote +quote = incomeFlow − expenseNominal + +// 4. YearPoint für die Grafik anfügen (year, age Person A, income, expenseNominal, expenseReal) + +// 5. Vermögen: verzinsen, Sparbeitrag, Bezugsrate +für jedes Asset a: + grown = a.value × (1 + a.r/100) + a.rate // Zins zuerst, dann Einzahlung (nachschüssig) + w = min(a.withdrawal, max(0, grown)) // Bezug am Bestand gekappt + a.value = grown − w + cashFromWithdraw += w + +// 6. Amortisation und Tilgung – jeweils am Restsaldo gekappt +debtRates = 0 +für jede Immobilie re: + pay = min(re.amort, re.mortgage) // nie mehr als die Restschuld + re.mortgage −= pay + debtRates += pay +für jede Schuld d: + pay = min(d.repay, d.owed) + d.owed −= pay + debtRates += pay +falls t === 1 → plannedSaveRate = fixedRatesTotal + debtRates + +// 7. Cash fortschreiben +cash += quote − fixedRatesTotal − debtRates + cashFromWithdraw +falls cash < 0 → cashNegative = true + +// 8. Ruin prüfen (Gesamtvermögen zum Jahresende) +total = cash + Σ asset.value + Σ (re.purchase − re.mortgage) + Σ (−d.owed) +falls ruinAge === null und total < 0 → ruinAge = age(Person A) + yearsBefore + t +``` + +**Wichtige Details:** +- Verzinsung ist **nachschüssig**: der Sparbeitrag des Jahres wird nicht mitverzinst. +- **Amortisation und Tilgung enden mit der Schuld.** Hypothek und Restschuld sind laufende + Salden; die Rate ist pro Jahr auf den Restsaldo gekappt (`min(rate, saldo)`). Ist die Schuld + abbezahlt, fliesst kein Franken mehr ab – weder aus dem Cash noch in die Sparrate. Im letzten + Zahlungsjahr wird nur noch der Restbetrag fällig, nicht die volle Rate. +- `plannedSaveRate` (Kopf-Kennzahl) ist die tatsächliche Rate des **ersten** Phasenjahres. In + späteren Jahren kann sie tiefer liegen, wenn eine Schuld ausläuft. +- `cashNegative` wird gesetzt, sobald der Cash-Bestand **irgendwann innerhalb** der Phase unter 0 + fällt – auch wenn er am Phasenende wieder positiv ist. +- Der Ruin bezieht sich auf das **Gesamtvermögen inkl. Immobilien**, nicht auf das Cash. +- `ruinAge` wird nur **einmal** gesetzt (erstes Auftreten, plan-weit). + +Referenz: `src/lib/calculations.ts` Zeilen 365–425. + +## 4.8 Endwerte und Phasen-Kennzahlen + +``` +Einkommen: startValue = basis + endValue = basis × (1 + idx/100)^(duration−1) +Ausgaben: startValue = basis × cumInflStart + endValue = basis × (1 + idx/100)^(duration−1) × flowDeflatorEnd +Assets: endValue = a.value (nach der Jahresschleife) +Immobilie: endValue = purchase − re.mortgage (laufender Saldo nach der Jahresschleife) +Schulden: endValue = −d.owed (0, falls getilgt; + Notiz „Wird getilgt") +``` + +Aggregate: + +``` +startWealthNominal = Σ Element-Startwerte + cashStart +endWealthNominal = Σ Element-Endwerte + cashEnd +endWealthReal = endWealthNominal / cumulativeInflationEnd +isConsumption = quotaStart < 0 +incomplete = cashNegative // „roter Status" = Liquiditätslücke +capitalInflow = incomingInflow // aus dem Übergang IN diese Phase +capitalInvest = investmentsFromCash + incomingImmediateRepay +``` + +## 4.9 Der Übergang + +Nach jeder Phase (auch nach der letzten) läuft die Übergangs-Logik. Sie liest +`transitionValues[phase.id]` – der Übergang ist also am **Von**-Phasen-Schlüssel gespeichert. + +``` +ownerRetiresNext = owner existiert + ∧ nextPhase existiert + ∧ owner ist in DIESER Phase erwerbstätig + ∧ owner.age + yearsBefore + duration >= owner.retirementAge +``` + +`INCOME`/`EXPENSE` sowie nicht-aktive Elemente überspringen die Logik (nur `hasCarry = true`). + +### 4.9.1 PENSION_FUND + +**Pensions-Übergang** (`ownerRetiresNext`), `value = ec.endValue`, Default-Modus `PENSION`: + +| `payoutMode` | Wirkung | +|---|---| +| `CAPITAL` | `txInflow += round(value × (1 − capitalTaxRate/100))`; `carry.value = 0`; `pkPensionAnnual = 0` | +| `PENSION` | `carry.pkPensionAnnual = round(value × conversionRate / 100)`; `carry.value = 0` | +| `COMBI` | `capital = min(value, capitalAmount)`; `txInflow += round(capital × (1 − tax/100))`; `pkPensionAnnual = round((value − capital) × conversionRate / 100)`; `carry.value = 0` | + +**Normaler Übergang (Vorbezug)** – brutto entnommen, netto ins Cash: +``` +withdrawal = min(ec.endValue, round(td.withdrawal)) // brutto +carry.value = ec.endValue − withdrawal +txInflow += round(withdrawal × (1 − capitalTaxRate/100)) +``` + +### 4.9.2 PILLAR_3A + +**Pensions-Übergang**: immer vollständiger Bezug – +`txInflow += round(ec.endValue × (1 − capitalTaxRate/100))`; `carry.value = 0`. +**Normaler Übergang (Vorbezug)**: wie PK – Bruttoentnahme, Netto-Zufluss nach +Kapitalbezugssteuer. + +### 4.9.3 OTHER_ASSET + +`decision = "SELL"` → `txInflow += ec.endValue`; `carry.status = "SOLD"` (kein Steuerabzug). +Sonst → `carry.value = ec.endValue`. + +### 4.9.4 REAL_ESTATE + +Die Resthypothek wird zurückgerechnet: `restMortgage = purchase − ec.endValue`. + +`decision = "SELL"`: +``` +gain = max(0, salePrice − purchase) +tax = gain × (saleTaxRate / 100) +txInflow += round(salePrice − restMortgage − tax) +carry.status = "SOLD" +``` +Der Nettoerlös ist also Verkaufspreis minus Hypothekenablösung minus Grundstückgewinnsteuer. +Ein Verlustverkauf erzeugt keine Steuer (`gain` bei 0 geklammert). + +Sonst → `carry.mortgage = restMortgage`. + +### 4.9.5 OTHER_DEBT + +``` +carry.owed = −ec.endValue +immediate = min(carry.owed, round(td.immediateRepayment)) +falls immediate > 0: + carry.owed −= immediate + txImmediateRepay += immediate +falls carry.owed === 0 → carry.status = "SETTLED" +``` + +### 4.9.6 Abschluss des Übergangs + +``` +cashCarryIn = cashEnd + txInflow − txImmediateRepay +incomingInflow = txInflow // Kopf-Kennzahl der Folgephase +incomingImmediateRepay = txImmediateRepay +yearsBefore += duration +``` + +## 4.10 Ergebnisstruktur + +```ts +PlanComputed { + phases: PhaseComputed[] // alle Kennzahlen je Phase, inkl. elements[] + yearly: YearPoint[] // ein Punkt pro Jahr über alle Phasen (Grafik) + nachlass: number // = endWealthNominal der letzten Phase, sonst 0 + ruinAge: number | null // Alter Person A beim ersten Gesamtvermögen < 0 +} +``` + +## 4.11 Systemparameter + +Zentral in `src/lib/constants.ts` geführt, weil sie sich periodisch durch Bundesanpassungen ändern: + +| Konstante | Wert | Bedeutung | +|---|---|---| +| `AHV_MAX_ANNUAL_SINGLE` | 32'760 | Max. einfache AHV-Altersrente/Jahr inkl. 13. Rente (2026) | +| `AHV_COUPLE_CAP_FACTOR` | 1.5 | Ehepaar-Plafonierung: 150 % der Einzel-Maximalrente | +| `AHV_FULL_CONTRIBUTION_YEARS` | 44 | Volle Beitragsdauer (Rentenskala 44) | +| `PILLAR_3A_MAX_ANNUAL` | 7'258 | Max. 3a-Beitrag/Jahr für PK-Versicherte (2026) | +| `DEFAULT_PK_CONVERSION_RATE` | 6 % | Umwandlungssatz | +| `DEFAULT_CAPITAL_TAX_RATE` | 8 % | Kapitalbezugssteuer | +| `DEFAULT_PROPERTY_GAINS_TAX_RATE` | 20 % | Grundstückgewinnsteuer | + +Die drei Default-Sätze werden **sowohl als UI-Vorschlag als auch in der Berechnung als Fallback** +verwendet (`num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)`). Grund laut Code-Kommentar: Damit +ein nicht angetippter Wert nicht fälschlich als 0 gerechnet wird. + +--- + +# 5. Technische Spezifikation + +## 5.1 Technologie-Stack + +| Bereich | Technologie | Version | +|---|---|---| +| Framework | Next.js (App Router) | 16.2.10 | +| UI | React | 19.2.4 | +| Sprache | TypeScript | ^5 | +| Styling | Tailwind CSS | ^4 (via `@tailwindcss/postcss`) | +| Icons | lucide-react | ^1.24.0 | +| Charts | recharts | ^3.9.2 | +| ORM | Prisma | ^7.8.0 (Client-Output nach `src/generated/prisma`) | +| Datenbank | PostgreSQL | 16-alpine | +| DB-Treiber | `pg` + `@prisma/adapter-pg` | ^8.22.0 / ^7.8.0 | +| Validierung | Zod | ^4.4.3 | +| Auth | jose (JWT) + bcryptjs | ^6.2.3 / ^3.0.3 | +| Tests | Vitest | ^4.1.10 | +| Build | Docker (multi-stage), `output: "standalone"` | | + +> **Hinweis für Entwickler:** Gemäss `AGENTS.md` weicht diese Next.js-Version von verbreiteten +> Konventionen ab. Vor Änderungen ist der relevante Guide unter `node_modules/next/dist/docs/` +> zu konsultieren. + +## 5.2 Verzeichnisstruktur + +``` +FPT/ +├── prisma/ +│ ├── schema.prisma Datenmodell +│ └── migrations/ 8 Migrationen (chronologisch) +├── src/ +│ ├── app/ +│ │ ├── api/ Route Handlers (siehe Kapitel 6) +│ │ ├── login/page.tsx Login-/Registrierseite +│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell) +│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata +│ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes) +│ ├── components/ 12 React-Komponenten (alle "use client") +│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren) +│ ├── lib/ Domänenlogik (siehe 5.3) +│ └── middleware.ts Zugriffsschutz (Edge-Runtime) +├── Info Dateien/ Fachdokumente, Roadmap (historisch) +├── docker-compose.yml, Dockerfile, docker-entrypoint.sh +└── .gitea/workflows/deploy.yaml CI/CD +``` + +## 5.3 Schichtenmodell und `src/lib` + +Ein bewusster Entkopplungs-Entscheid (`src/lib/types.ts` Zeilen 1–4): Die Berechnungslogik +arbeitet auf **eigenen Domänentypen**, nicht auf den generierten Prisma-Typen. Dadurch ist +`computePlan` ohne Datenbank testbar. + +``` +Prisma-Modelle (DB) + │ toPlanInput() ← queries.ts: parst + validiert die JSON-Felder + ▼ +PlanInput (types.ts) ← reine Domänentypen + │ computePlan() ← calculations.ts: pure function + ▼ +PlanComputed ← an den Client geliefert +``` + +| Datei | Verantwortung | +|---|---| +| `calculations.ts` | Berechnungskern + CSV-Export. Keine I/O. | +| `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` | +| `types.ts` | Domänentypen für API und Berechnung | +| `constants.ts` | Schweizer Systemparameter | +| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen | +| `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) | +| `auth.ts` | JWT erzeugen/prüfen (Edge-kompatibel via `jose`) | +| `session.ts` | `getCurrentUserId()` aus dem Cookie (Node-Runtime) | +| `users.ts` | Registrierung, Credential-Prüfung, Passwortwechsel (bcrypt). **Nie aus der Middleware importieren** – Edge-Runtime hat keinen DB-Zugriff. | +| `format.ts` | `formatChf` / `parseChfInput` | +| `theme.ts` | Theme-Verwaltung (localStorage + `data-theme`) | +| `api-client.ts` | Typisierter `fetch`-Wrapper mit einheitlicher Fehlerextraktion | + +## 5.4 Datenmodell + +### 5.4.1 Tabellen + +**User** + +| Feld | Typ | Constraints | +|---|---|---| +| `id` | String | PK, `cuid()` | +| `username` | String | **unique** | +| `passwordHash` | String | bcrypt, Cost 12 | +| `createdAt` | DateTime | `now()` | + +**Person** + +| Feld | Typ | Constraints | +|---|---|---| +| `id` | String | PK, `cuid()` | +| `planId` | String | FK → Plan, **Cascade** | +| `role` | `PersonRole` | `PERSON_A` \| `PERSON_B` | +| `name` | String? | optional | +| `age` | Int | aktuelles Alter | +| `retirementAge` | Int | plan-eigenes Pensionsalter | +| | | `@@unique([planId, role])` | + +**Plan** + +| Feld | Typ | Constraints | +|---|---|---| +| `id` | String | PK, `cuid()` | +| `userId` | String | FK → User, **Cascade** | +| `name` | String | | +| `householdType` | `HouseholdType` | `SINGLE` \| `COUPLE` | +| `inflationRateDefault` | Float | plan-weite Inflation in % | +| `initialCash` | Float | Default 0 | +| `parentPlanId` | String? | FK → Plan (Self-Relation „PlanScenarios"), **SetNull** | +| `branchFromPhaseId` | String? | ID der letzten kopierten Phase (**keine** FK-Constraint) | +| `createdAt` / `updatedAt` | DateTime | | + +**Phase** + +| Feld | Typ | Constraints | +|---|---|---| +| `id` | String | PK, `cuid()` | +| `planId` | String | FK → Plan, **Cascade** | +| `sequenceNumber` | Int | 1-basiert, lückenlos | +| `name` | String | | +| `durationYears` | Int | 1–80 | +| `createdAt` / `updatedAt` | DateTime | | +| | | `@@unique([planId, sequenceNumber])` | + +**FinancialElement** + +| Feld | Typ | Constraints | +|---|---|---| +| `id` | String | PK, `cuid()` | +| `planId` | String | FK → Plan, **Cascade** | +| `category` | `ElementCategory` | 8 Werte | +| `name` | String | | +| `ownerRole` | `OwnerRole?` | `PERSON_A` \| `PERSON_B` \| `HOUSEHOLD` | +| `orderIndex` | Int | Default 0 | +| `createdAt` | DateTime | | + +**ElementPhaseValue** + +| Feld | Typ | Constraints | +|---|---|---| +| `id` | String | PK, `cuid()` | +| `elementId` | String | FK → FinancialElement, **Cascade** | +| `phaseId` | String | FK → Phase, **Cascade** | +| `data` | Json | Payload gemäss `PhaseData` | +| | | `@@unique([elementId, phaseId])` | + +**ElementTransitionValue** + +| Feld | Typ | Constraints | +|---|---|---| +| `id` | String | PK, `cuid()` | +| `elementId` | String | FK → FinancialElement, **Cascade** | +| `fromPhaseId` | String | FK → Phase, **Cascade** | +| `data` | Json | Payload gemäss `TransitionData` | +| | | `@@unique([elementId, fromPhaseId])` | + +### 5.4.2 Warum JSON? + +Die kategoriespezifischen Felder liegen als JSON, weil sich sonst pro Kategorie eine eigene +Tabelle (oder eine breite Sparse-Tabelle) ergäbe. Die Typisierung und Validierung findet in der +**Applikationsschicht** statt (`src/lib/elements.ts`): `PhaseData` / `TransitionData` als +TypeScript-Interfaces, `phaseDataSchema` / `transitionDataSchema` als Zod-Schemas an der +API-Grenze. + +Die Interfaces sind bewusst **tolerant** getippt (alle Felder optional): die Berechnung liest +defensiv über `num(value, fallback)`, das UI zeigt kontextabhängig nur die relevanten Felder. + +Referenz: `prisma/schema.prisma` Zeilen 4–6, `src/lib/elements.ts` Zeilen 48–51. + +### 5.4.3 JSON-Payload `PhaseData` + +| Feld | Kategorien | Zod-Regel | +|---|---|---| +| `amount` | INCOME, EXPENSE | ≥ 0 | +| `teuerungsausgleich` | INCOME, EXPENSE | −20 bis 50 | +| `gapYears` | AHV | Integer ≥ 0 | +| `currentValue` | PENSION_FUND, PILLAR_3A | ≥ 0 | +| `startValue` | OTHER_ASSET, OTHER_DEBT | ≥ 0 | +| `expectedReturn` | PK, 3a, OTHER_ASSET | −50 bis 100 | +| `annualContribution` | PK, 3a, OTHER_ASSET | ≥ 0 | +| `annualWithdrawal` | OTHER_ASSET | ≥ 0 | +| `additionalInvestment` | PK, 3a, OTHER_ASSET (ab Phase 2) | ≥ 0 | +| `purchasePrice` | REAL_ESTATE | ≥ 0 | +| `mortgage` | REAL_ESTATE | ≥ 0 | +| `amortization` | REAL_ESTATE | ≥ 0 | +| `annualRepayment` | OTHER_DEBT | ≥ 0 | + +### 5.4.4 JSON-Payload `TransitionData` + +| Feld | Kategorien | Zod-Regel | +|---|---|---| +| `withdrawalMode` | PK, 3a (normal) | `NONE` \| `AMOUNT` | +| `withdrawal` | PK, 3a (normal) | ≥ 0, **brutto** | +| `payoutMode` | PK (Pensionierung) | `CAPITAL` \| `PENSION` \| `COMBI` | +| `capitalAmount` | PK (COMBI) | ≥ 0 | +| `conversionRate` | PK | 0–20 | +| `capitalTaxRate` | PK + 3a, **sowohl Vorbezug als auch Pensionierung** | 0–100 | +| `decision` | REAL_ESTATE, OTHER_ASSET | `HOLD` \| `SELL` | +| `salePrice` | REAL_ESTATE | ≥ 0 | +| `saleTaxRate` | REAL_ESTATE | 0–100 | +| `immediateRepayment` | OTHER_DEBT | ≥ 0 | + +Beide Schemas verwenden `.strip()` – **unbekannte Felder werden verworfen**, nicht abgelehnt. +Beim Lesen aus der DB gilt zusätzlich: schlägt `safeParse` fehl, wird `{}` zurückgegeben +(`parsePhaseData` / `parseTransitionData`) – korrupte Daten führen also nie zu einem Absturz, +sondern zu leeren Werten. + +### 5.4.5 Migrationshistorie + +| Migration | Inhalt | +|---|---| +| `20260708171600_init` | Initiales Schema | +| `20260709010000_add_transition_automation` | Übergangs-Automatik | +| `20260709120000_rework_realestate` | Immobilien-Überarbeitung | +| `20260711090000_multi_user` | User-Entität, Ownership | +| `20260713100000_element_model_rework` | Elemente auf Plan-Ebene, Phase-/Transition-Werte als JSON | +| `20260713150000_profile_to_plan_v3` | Grundprofil von Household auf Plan verschoben | +| `20260714120000_person_name` | `Person.name` | +| `20260715120000_plan_initial_cash` | `Plan.initialCash` | +| `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) | + +## 5.5 Frontend-Architektur + +### 5.5.1 Datenfluss + +``` +page.tsx (Client) + └─ GET /api/auth/me → username + └─ AppShell + ├─ GET /api/plans → Planliste (Sidebar, Kacheln) + ├─ GET /api/plans/ → { plan: PlanInput, computed: PlanComputed } + │ └─ PlanView (Matrix, Dialoge) → onChanged() → silent reload + │ └─ Dashboard (Kennzahlen, 3 Grafiken, Vergleich, Export) + └─ Dialoge: PlanDialog, ScenarioDialog +``` + +Es gibt **keinen State-Management-Layer** (kein Redux/Zustand/React Query). Der Zustand lebt in +`AppShell` (Planliste, ausgewählter Plan, Detail) und lokal in den Dialogen. Nach jeder Mutation +wird der Plan neu geladen; die Berechnung kommt immer vom Server. + +### 5.5.2 Komponenten + +| Komponente | Zeilen | Rolle | +|---|---|---| +| `AppShell` | 433 | Layout, Sidebar, Planliste, Laden, Plan-/Szenario-Dialoge | +| `PlanView` | 1304 | Matrix, Spaltenaufbau, Kontextbildung, alle Zell-Dialoge | +| `ElementDetail` | 548 | Feldgruppen je Kategorie/Kontext, Speicherlogik, Übergangs-Defaults | +| `Dashboard` | 187 | Kennzahlkarten, 3 Grafiken, Planvergleich, CSV-Link | +| `FormField` | 275 | `NumberField`, `MoneyInput`/`MoneyField`, `TextField`, `SelectField` | +| `ProfileMenu` | 166 | Benutzer, Theme-Wahl, Passwortwechsel, Logout | +| `Timeline` | 108 | Zeitachse mit Pensions- und Ruin-Markern | +| `WealthChart` | 106 | Vermögensverlauf, mehrere Serien | +| `PlanProfileFields` | 101 | Wiederverwendete Grundprofil-Felder | +| `PhaseDetail` | 95 | Phase bearbeiten/löschen | +| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern | +| `InfoBubble` | 28 | Hilfe-Tooltip | + +### 5.5.3 Wiederverwendungsmuster + +`ElementPhaseFields` und `ElementTransitionFields` sind aus `ElementDetail` **exportiert** und +werden an drei Stellen wiederverwendet: im Zell-Dialog, im Erstell-Dialog („Werte (erste +Lebensphase)") und im geführten Übergangs-Review. Dadurch gibt es genau eine Definition der +Felder je Kategorie. + +Der `CellContext` ist die einheitliche Kontext-Schnittstelle dieser Feldgruppen; er wird in +`PlanView` durch `buildPhaseContext` / `buildTransitionContext` aus der Berechnung befüllt. + +### 5.5.4 React-Detail: der `key` auf Dialogen + +Zell-Dialoge tragen einen `key` aus `elementId` + `phaseId`. Grund (Kommentar Zeilen 479–480): +Beim Wechsel von Zelle zu Zelle wird ein Neuaufbau erzwungen, damit der lokale Formularzustand +nicht vom vorher geöffneten Element übrig bleibt. + +## 5.6 Sicherheit + +| Aspekt | Umsetzung | +|---|---| +| Passwortspeicherung | bcrypt, Cost 12 | +| Session | JWT HS256, 30 Tage, HttpOnly-Cookie, `sameSite=lax`, `secure` in Produktion | +| Secret | `SESSION_SECRET` aus der Umgebung; Fehlen wirft beim ersten Zugriff | +| Autorisierung | Middleware (grob) + Ownership-Query je Route (fein) | +| Information Disclosure | Fremde/nicht existierende Ressourcen → einheitlich 404; Login-Fehler unspezifisch | +| Eingabevalidierung | Zod an jeder API-Grenze; `.strip()` gegen Mass-Assignment | +| SQL-Injection | Prisma (parametrisiert) | +| XSS | React-Escaping; einziges `dangerouslySetInnerHTML` ist das statische Theme-Init-Script | +| CSRF | Kein Token. Schutz beruht allein auf `sameSite=lax` – siehe 9.4 | + +## 5.7 Konfiguration + +| Variable | Zweck | +|---|---| +| `DATABASE_URL` | Postgres-Connection-String | +| `SESSION_SECRET` | JWT-Signaturschlüssel (z. B. `openssl rand -hex 32`) | +| `POSTGRES_PASSWORD` | Nur für docker-compose: Passwort des `db`-Containers | +| `NODE_ENV` | Steuert u. a. das `secure`-Flag des Cookies und den Prisma-Global-Cache | + +Es gibt **kein** konfiguriertes Login-Passwort: Konten werden über die Registrierung angelegt. + +--- + +# 6. API-Referenz + +Alle Routen liefern JSON. Fehlerformat einheitlich: `{ "error": "" }` (bei +Zod-Fehlern in `POST /api/plans`: `{ "error": }`). +Alle Routen ausser `login`/`register` erfordern ein gültiges Session-Cookie. + +## 6.1 Authentifizierung + +| Methode | Pfad | Body | Antwort | +|---|---|---|---| +| POST | `/api/auth/register` | `{ username, password }` | 201 `{ ok, username }` + Cookie · 400 Validierung · 409 Name vergeben | +| POST | `/api/auth/login` | `{ username, password }` | 200 `{ ok, username }` + Cookie · 400 · 401 | +| POST | `/api/auth/logout` | – | 200 `{ ok }`, Cookie gelöscht | +| GET | `/api/auth/me` | – | 200 `{ user: { id, username, createdAt } }` · 401 · 404 | +| POST | `/api/auth/change-password` | `{ currentPassword, newPassword }` | 200 `{ ok }` · 400 · 401 | + +## 6.2 Pläne + +### `GET /api/plans` +Liste der eigenen Pläne, sortiert nach `createdAt` aufsteigend. +```json +{ "plans": [ { "id", "name", "parentPlanId", "branchFromPhaseId", "createdAt", + "phases": [ { "id", "name", "sequenceNumber" } ] } ] } +``` + +### `POST /api/plans` +```json +{ "name": "Basisplan", "householdType": "COUPLE", "inflationRateDefault": 1.5, + "persons": [ { "role": "PERSON_A", "name": "Anna", "age": 40, "retirementAge": 65 }, + { "role": "PERSON_B", "name": null, "age": 38, "retirementAge": 64 } ] } +``` +Validierung: `name` 1–120; `inflationRateDefault` −20…50; `persons` 1–2 Einträge; +`age` 0–120; `retirementAge` 30–100; `name` je Person ≤ 60. Zusätzlich Konsistenzregel +SINGLE=1 / COUPLE=2 Personen. → 201 `{ plan: { id } }` + +### `GET /api/plans/` +Liefert **Eingabe und Berechnung** in einem Zug: +```json +{ "plan": , "computed": } +``` +Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd. + +### `PATCH /api/plans/` +Akzeptiert eine **Union** von zwei Formen: +1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` – ersetzt + die Personen in einer Transaktion. +2. Teilaktualisierung: `{ name?, initialCash? }` – `initialCash` 0…1'000'000'000, gerundet. + +Die Unterscheidung erfolgt über das Vorhandensein von `householdType`. +→ 200 `{ plan: { id, name } }` + +### `DELETE /api/plans/` +→ 200 `{ ok: true }`, Cascade-Löschung. + +### `POST /api/plans//scenario` +```json +{ "name": "Frühpensionierung", "branchFromPhaseId": "" } +``` +→ 201 `{ planId: "" }` + +### `GET /api/plans//export` +→ `text/csv; charset=utf-8`, `Content-Disposition: attachment`. + +## 6.3 Phasen + +### `POST /api/plans//phases` +Body optional: `{ name?, durationYears? }`. +Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbelegte +`ElementPhaseValue` für alle aktiven Elemente an (alles in einer Transaktion). +→ 201 `{ phase: { id } }` + +### `PUT /api/phases/` +`{ name?, durationYears? }` – `durationYears` 1–80, wird gekappt. +→ 200 `{ phase: { id } }` + +### `DELETE /api/phases/` +Nur die letzte Phase. → 200 `{ ok }` · 400 „Nur die letzte Phase kann geloescht werden." + +## 6.4 Elemente + +### `POST /api/plans//elements` +`{ category, name, ownerRole? }` → 201 `{ element: { id } }` +- `PERSON_ONLY_CATEGORIES` ohne Person → 400 +- fehlendes `ownerRole` sonst → `HOUSEHOLD` +- `orderIndex` = Max + 1 + +### `PATCH /api/elements/` +`{ name }` (1–120). → 200 `{ ok }` + +### `DELETE /api/elements/` +→ 200 `{ ok }`, Cascade auf alle Phasen-/Übergangswerte. + +### `PUT /api/elements//phase/` +Body = `PhaseData`. **Upsert** auf `@@unique([elementId, phaseId])`. Prüft zusätzlich, dass die +Phase zum selben Plan gehört wie das Element. → 200 `{ ok }` + +### `PUT /api/elements//transition/` +Body = `TransitionData`. **Upsert** auf `@@unique([elementId, fromPhaseId])`. → 200 `{ ok }` + +--- + +# 7. Betrieb und Deployment + +## 7.1 Container + +**Dockerfile** – vierstufiger Multi-Stage-Build auf `node:20-alpine`: +1. `base` – Arbeitsverzeichnis `/app` +2. `deps` – `npm ci` (mit `prisma/` für den `postinstall`-Hook `prisma generate`) +3. `builder` – `npx prisma generate` + `npm run build` +4. `runner` – nur Laufzeit-Artefakte; `ENTRYPOINT ./docker-entrypoint.sh`, `CMD npm start`, Port 3000 + +**`docker-entrypoint.sh`** führt vor dem Start `npx prisma migrate deploy` aus – Migrationen +laufen also automatisch bei jedem Container-Start. + +## 7.2 docker-compose + +Zwei Services: +- **`app`** – hängt an zwei Netzwerken: `agent-net` (extern, Traefik) und `internal` (DB). +- **`db`** – `postgres:16-alpine`, nur im `internal`-Netz, persistiert auf Volume `fpt_db_data`. + Die Datenbank ist von aussen **nicht** erreichbar. + +**Traefik-Labels:** +``` +traefik.enable=true +traefik.docker.network=agent-net ← kritisch, siehe unten +traefik.http.routers.fpt.rule=Host(`fpt.aicds.ch`) +traefik.http.routers.fpt.entrypoints=websecure +traefik.http.routers.fpt.tls.certresolver=myresolver +traefik.http.services.fpt.loadbalancer.server.port=3000 +``` + +Zwei projektspezifische Fallstricke sind im Code dokumentiert bzw. durch die Konventionen gesetzt: +1. **`traefik.docker.network=agent-net` ist zwingend.** Der Container hängt an zwei Netzwerken; + ohne diese Angabe wählt Traefik zufällig eines – landet es im internen DB-Netzwerk, ist das + Backend unerreichbar (Timeout). (Kommentar in `docker-compose.yml`.) +2. **Der Router-Name muss projektspezifisch sein** (hier `fpt`, nicht `app`), sonst kollidiert er + mit anderen Projekten auf derselben VM. + +## 7.3 CI/CD + +`.gitea/workflows/deploy.yaml`: **Push auf `main` = automatisches Live-Deployment** +(bewusst kein Review-Gate). Ablauf: +1. Checkout +2. `.env` aus den Gitea-Secrets `SESSION_SECRET` und `POSTGRES_PASSWORD` schreiben +3. Code nach `/opt/aicds/apps/FPT/` kopieren +4. `docker compose down` → `docker compose up -d --build` → `docker image prune -f` + +Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gitea unter +`git.aicds.ch`. + +--- + +# 8. Qualitätssicherung + +## 8.1 Teststrategie + +Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das +Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 13 Tests („V5 Golden Tests"), +ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). +Es gibt **keine** Komponenten-, API- oder E2E-Tests. + +## 8.2 Testfälle + +| Test | Prüft | +|---|---| +| Test 1 – Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash | +| Test 2 – Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` | +| Test 3 – Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ | +| Test 4 – Liquiditätslücke | Sparrate 10'000: `cashEnd === -5436`, `cashNegative === true` | +| Ausgaben real → nominal | Jahr 1 nominal = real; Jahr 5 = `100'000 × 1.02^4` | +| Einkommen flach | Lohnerhöhung 0 % → `incomeStart === incomeEnd === 80'000` | +| Bezugsrate | 3 × 10'000 Entnahme → `cashEnd === 30'000`, Asset-Endwert 0, `plannedWithdrawRate === 10'000` | +| Kapitalzufluss/-investition | Verkauf in P1, Reinvestition in P2: `capitalInflow === 10'000`, `capitalInvest === 10'000`, `cashStart === 0`, `startWealthNominal === 10'000` (kein Doppelzählen) | +| Cash-Anfangswert | `initialCash` 50'000 fliesst in Phase 1 ein | +| **Tilgung stoppt** | Schuld 25'000, Tilgung 10'000/J., 5 Jahre: Gesamtabfluss 25'000 (nicht 50'000), `cashEnd === 75'000`, Restschuld 0 | +| **Amortisation stoppt** | Hypothek 15'000, Amortisation 10'000/J., 4 Jahre: `cashEnd === 85'000` (nicht 60'000), Immobilie schuldenfrei | +| **Vorbezugssteuer** | PK-Vorbezug 100'000 brutto @ 8 %: `capitalInflow === 92'000`, Restkapital 200'000 (brutto entnommen) | +| Fortschreibung | Einkommens-Basiswert P1 → Startwert P2 = `100'000 × 1.02^5`; `cashStart(P2) === cashEnd(P1)` | + +## 8.3 Ausführung + +```bash +npm test # vitest run +npm run lint # eslint +npm run build # next build +``` + +**Verifikationseinschränkung:** Lokal steht keine Datenbank/Docker zur Verfügung. Die +Verifikation erfolgt über `npm run build` und die isolierten Berechnungstests; End-to-End-Prüfung +erst gegen das Deployment (`fpt.aicds.ch`). + +--- + +# 9. Bekannte Einschränkungen und Modellentscheide + +Dieser Abschnitt hält fest, was im Code steht und beim Weiterentwickeln bekannt sein muss. + +## 9.1 Cash wird nicht automatisch ausgeglichen + +Wird das Cash negativ, meldet das Tool eine Liquiditätslücke (rotes Icon, `incomplete = true`), +greift aber nicht ein – es wird kein Vermögen automatisch verkauft und kein Kredit aufgenommen. +Negatives Cash geht mit negativem Vorzeichen ins Gesamtvermögen ein. + +## 9.2 Verwaiste `PERSON_B`-Elemente + +Die Haushaltsform ist eine **Plan-Eigenschaft** und lässt sich im Dialog „Plan-Einstellungen" +auch bei einem bestehenden Plan nachträglich ändern (nicht pro Phase – innerhalb eines Plans +gilt sie durchgehend). Wechselt ein Plan dabei von `COUPLE` auf `SINGLE`, schneidet +`PlanProfileFields` die Personen auf eine zusammen und `PATCH /api/plans/` löscht Person B +aus der Datenbank. Elemente mit `ownerRole = "PERSON_B"` bleiben bestehen. In der Berechnung +liefert `personByRole` dann `null`: + +- `AHV`: wird per `if (!owner || …) continue;` übersprungen – keine Rente. +- `PENSION_FUND` / `PILLAR_3A`: `owner` ist `null`, deshalb fällt `ownerWorking` auf `anyWorking` + zurück, d. h. das Element folgt dem Erwerbsstatus des Haushalts statt der (nicht mehr + existierenden) Person. + +Es gibt keine Bereinigung und keine Warnung im UI. Da die Haushaltsform normalerweise nicht +nachträglich geändert wird, ist der Fall selten – er ist aber erreichbar. + +## 9.3 Immobilien ohne Wertentwicklung + +Der Immobilienwert ist in jeder Phase der aus den Phasendaten gelesene `purchasePrice`. Der +Kaufpreis wird beim Anlegen einer Folgephase mitkopiert (`buildCarryData`). Eine Wertsteigerung +lässt sich nur indirekt abbilden, indem man den `purchasePrice` in einer späteren Phase +manuell erhöht – was dann allerdings auch die Berechnung der Grundstückgewinnsteuer beim Verkauf +beeinflusst (`gain = salePrice − purchase` liest den `purchasePrice` der Verkaufsphase). + +## 9.4 Kein CSRF-Token + +Zustandsändernde Requests sind allein durch `sameSite=lax` geschützt. Das deckt klassische +Cross-Site-Formular-POSTs ab, ist aber schwächer als ein expliziter Token. + +## 9.5 Logout invalidiert das Token nicht serverseitig + +Das JWT ist zustandslos und bis zu 30 Tage gültig. `logout` löscht nur das Cookie. + +## 9.6 Spar- und Bezugsraten werden nicht indexiert + +`annualContribution`, `annualWithdrawal`, `amortization` und `annualRepayment` sind flache +Nominalbeträge, die über die Phasenjahre **konstant** bleiben. Eine Sparrate von 10'000 bleibt +20 Jahre lang 10'000 nominal und verliert dabei real an Gewicht. Wer eine mitwachsende Rate +abbilden will, muss die Phase teilen und den Betrag in der Folgephase erhöhen. + +## 9.7 `PILLAR_3A_MAX_ANNUAL` wird nur im UI erzwungen + +Das Feld ist per `max`-Prop hart geklammert. Das Zod-Schema kennt für `annualContribution` nur +`≥ 0` – ein direkter API-Aufruf kann die Obergrenze überschreiten. + +## 9.8 Kleinere Beobachtungen + +- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht. +- Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) – ein Rest der + früheren Struktur mit mehreren Auswahlarten. +- `Plan.branchFromPhaseId` hat keine Fremdschlüssel-Constraint; die Phase kann gelöscht werden, + ohne dass das Feld bereinigt wird. +- Die Element-Umbenennung ist als API vorhanden, im UI aber nicht erreichbar. +- Die `README.md` ist noch der unveränderte `create-next-app`-Text. +- `npm run lint` meldet einen bestehenden Fehler in `ProfileMenu.tsx` Zeile 15 + (`react-hooks/set-state-in-effect`); an vergleichbaren Stellen ist die Regel andernorts + bewusst per `eslint-disable` deaktiviert. + +--- + +# 10. Glossar + +| Begriff | Bedeutung im FPT | +|---|---| +| **Plan** | Selbsttragende Planungseinheit: Grundprofil + Phasenkette + Elemente | +| **Szenario** | Deep-Copy eines Plans bis zu einer Verzweigungsphase; danach unabhängig | +| **Grundprofil** | Haushaltsform, Personen (Alter, Pensionsalter, Name), Inflationsannahme | +| **Lebensphase** | Zeitabschnitt mit fester Dauer; darf keine Pensionierung überspannen | +| **Phasentyp** | `ERWERB` / `PENSION` / `MIXED`; abgeleitet, nie gespeichert | +| **Finanzielles Element** | Plan-weite Entität einer der 8 Kategorien, über alle Phasen identisch | +| **Übergang** | Grenze zwischen zwei Phasen; Ort der einmaligen Entscheide | +| **Pensions-Übergang** | Übergang, bei dem der Besitzer des Elements pensioniert wird | +| **Carry / Fortschreibung** | Live-Übertragung des Endwerts einer Phase in die nächste | +| **Cash** | Systemseitiges Ausgleichskonto; darf negativ werden (Liquiditätslücke) | +| **Quote** | Einkommen − nominale Ausgaben eines Jahres; negativ = **Verzehr** | +| **Geplante Sparrate** | 3a-Beiträge + Sparbeiträge + Amortisationen + Tilgungen | +| **Geplante Verzehrrate** | Summe der Bezugsraten aus Sonstigem Vermögen | +| **Kapitalzufluss** | Verkaufserlöse + PK-/3a-Bezüge aus dem Übergang in die Phase | +| **Kapitalinvestition** | Zusatzinvestitionen + Sofort-Tilgungen | +| **Nominal** | Betrag in Franken des jeweiligen Jahres | +| **Real** | Kaufkraftbereinigt auf den Planbeginn (`nominal / Deflator`) | +| **Deflator** | Kumulierte Inflation seit Planbeginn | +| **Ausfalljahr** | Jahr ohne AHV-Beiträge; kürzt die Rente um 1/44 | +| **Plafonierung** | Deckelung der Ehepaar-AHV auf 150 % der Einzel-Maximalrente | +| **Umwandlungssatz** | Prozentsatz zur Verrentung des PK-Kapitals | +| **Ruin(alter)** | Alter von Person A, in dem das Gesamtvermögen erstmals unter 0 fällt | +| **Nachlass** | Endvermögen der letzten Phase (nominal) | + +--- + +*Ende der Spezifikation v0.1* diff --git a/package-lock.json b/package-lock.json index e131424..9830196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,10 @@ "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-markdown": "^10.1.0", "recharts": "^3.9.2", + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.1", "zod": "^4.4.3" }, "devDependencies": { @@ -2471,6 +2474,15 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2482,9 +2494,26 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2499,6 +2528,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -2538,6 +2582,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2839,6 +2889,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", @@ -3611,6 +3667,16 @@ "node": ">= 0.4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3811,6 +3877,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -3838,6 +3914,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", @@ -3900,6 +4016,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4126,7 +4252,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4146,6 +4271,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4213,6 +4351,15 @@ "node": ">=0.10" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -4229,6 +4376,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4963,6 +5123,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -5005,6 +5175,12 @@ "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -5372,6 +5548,12 @@ "giget": "dist/cli.mjs" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5540,6 +5722,72 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-heading-rank": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", + "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -5566,6 +5814,16 @@ "node": ">=16.9.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -5635,6 +5893,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5659,6 +5923,30 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5817,6 +6105,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -5892,6 +6190,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5945,6 +6253,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -6584,6 +6904,16 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6641,6 +6971,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6651,6 +6991,288 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6661,6 +7283,569 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6702,7 +7887,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mysql2": { @@ -7112,6 +8296,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7441,6 +8650,16 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7525,6 +8744,33 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", @@ -7650,6 +8896,89 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", + "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "github-slugger": "^2.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remeda": { "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", @@ -8110,6 +9439,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -8276,6 +9615,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8299,6 +9652,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -8463,6 +9834,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8662,6 +10053,93 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -8764,6 +10242,34 @@ } } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -9178,6 +10684,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index ff01337..af21fb4 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,10 @@ "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-markdown": "^10.1.0", "recharts": "^3.9.2", + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/app/api/spec/route.ts b/src/app/api/spec/route.ts new file mode 100644 index 0000000..3228eae --- /dev/null +++ b/src/app/api/spec/route.ts @@ -0,0 +1,20 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { NextResponse } from "next/server"; +import { getCurrentUserId } from "@/lib/session"; + +// Liefert die Spezifikation (SPEZIFIKATION.md aus dem Projekt-Root) als Rohtext an die App. +// Die Datei liegt im Docker-Image neben package.json (siehe Dockerfile). +export async function GET() { + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 }); + } + + try { + const markdown = await readFile(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8"); + return NextResponse.json({ markdown }); + } catch { + return NextResponse.json({ error: "Spezifikation nicht gefunden." }, { status: 404 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index a469e43..660881c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -128,3 +128,102 @@ body { color: var(--fg); font-family: Arial, Helvetica, sans-serif; } + +/* ------------------------------------------------------------------------- + Dokument-Darstellung fuer gerenderten Markdown (Spezifikation, /spezifikation). + Bewusst ueber die semantischen Tokens, damit alle drei Farbschemata greifen. + ------------------------------------------------------------------------- */ + +.md-doc { + color: var(--fg); + line-height: 1.65; + font-size: 0.9rem; + max-width: 62rem; + overflow-wrap: break-word; +} + +.md-doc > *:first-child { margin-top: 0; } +.md-doc > *:last-child { margin-bottom: 0; } + +.md-doc h1, +.md-doc h2, +.md-doc h3, +.md-doc h4 { + color: var(--fg); + font-weight: 600; + line-height: 1.3; + scroll-margin-top: 1rem; +} +.md-doc h1 { font-size: 1.6rem; margin: 2.4rem 0 1rem; } +.md-doc h2 { + font-size: 1.25rem; + margin: 2.2rem 0 0.85rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--border); +} +.md-doc h3 { font-size: 1.05rem; margin: 1.7rem 0 0.6rem; } +.md-doc h4 { font-size: 0.95rem; margin: 1.3rem 0 0.5rem; color: var(--muted); } + +.md-doc p { margin: 0.75rem 0; } +.md-doc strong { font-weight: 600; color: var(--fg); } +.md-doc em { font-style: italic; } + +.md-doc a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } +.md-doc a:hover { color: var(--accent-hover); } + +.md-doc ul, +.md-doc ol { margin: 0.75rem 0; padding-left: 1.4rem; } +.md-doc ul { list-style: disc; } +.md-doc ol { list-style: decimal; } +.md-doc li { margin: 0.3rem 0; } +.md-doc li::marker { color: var(--faint); } + +.md-doc code { + font-family: var(--font-mono), monospace; + font-size: 0.84em; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 0.25rem; + padding: 0.1em 0.35em; +} +.md-doc pre { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 0.6rem; + padding: 0.9rem 1rem; + margin: 1rem 0; + overflow-x: auto; + line-height: 1.5; +} +.md-doc pre code { + background: none; + border: none; + padding: 0; + font-size: 0.82rem; + white-space: pre; +} + +/* Breite Tabellen scrollen in ihrem eigenen Container, die Seite bleibt ruhig. */ +.md-doc .md-table-wrap { overflow-x: auto; margin: 1rem 0; } +.md-doc table { border-collapse: collapse; width: 100%; font-size: 0.82rem; } +.md-doc th, +.md-doc td { + border: 1px solid var(--border); + padding: 0.4rem 0.6rem; + text-align: left; + vertical-align: top; +} +.md-doc th { background: var(--surface-2); font-weight: 600; white-space: nowrap; } +.md-doc tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface-2) 45%, transparent); } + +.md-doc blockquote { + margin: 1rem 0; + padding: 0.6rem 1rem; + border-left: 3px solid var(--accent); + background: var(--accent-soft); + color: var(--accent-soft-fg); + border-radius: 0 0.4rem 0.4rem 0; +} +.md-doc blockquote p { margin: 0.3rem 0; } + +.md-doc hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 6b22b51..57c8c5b 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { + FileText, FolderKanban, LayoutDashboard, Menu, @@ -12,6 +13,7 @@ import { } from "lucide-react"; import { PlanView } from "@/components/PlanView"; import { Dashboard } from "@/components/Dashboard"; +import { SpecView } from "@/components/SpecView"; import { ProfileMenu } from "@/components/ProfileMenu"; import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields"; import { api } from "@/lib/api-client"; @@ -34,6 +36,8 @@ export function AppShell({ username }: { username: string }) { const [sidebarOpen, setSidebarOpen] = useState(false); const [showNewPlan, setShowNewPlan] = useState(false); const [showScenario, setShowScenario] = useState(false); + // Die Spezifikation ist eine eigene Ansicht neben Uebersicht und Plan (schliessen sich aus). + const [showSpec, setShowSpec] = useState(false); const loadPlans = useCallback(async (preferId?: string) => { const data = await api.get<{ plans: PlanListItem[] }>("/api/plans"); @@ -95,10 +99,13 @@ export function AppShell({ username }: { username: string }) { type="button" onClick={() => { setSelectedPlanId(null); + setShowSpec(false); setSidebarOpen(false); }} className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${ - selectedPlanId === null ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2" + selectedPlanId === null && !showSpec + ? "bg-accent-soft text-accent-soft-fg" + : "text-muted hover:bg-surface-2" }`} > @@ -123,10 +130,13 @@ export function AppShell({ username }: { username: string }) { type="button" onClick={() => { setSelectedPlanId(p.id); + setShowSpec(false); setSidebarOpen(false); }} className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${ - selectedPlanId === p.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2" + selectedPlanId === p.id && !showSpec + ? "bg-accent-soft font-medium text-accent-soft-fg" + : "text-muted hover:bg-surface-2" }`} > @@ -134,6 +144,23 @@ export function AppShell({ username }: { username: string }) { {p.phases.length} ))} + +
+ +
); @@ -173,25 +200,30 @@ export function AppShell({ username }: { username: string }) {

- {activePlan ? activePlan.name : "Uebersicht"} + {showSpec ? "Spezifikation" : activePlan ? activePlan.name : "Uebersicht"}

- {loading &&

Laedt…

} + {showSpec && } - {!loading && selectedPlanId === null && ( + {!showSpec && loading &&

Laedt…

} + + {!showSpec && !loading && selectedPlanId === null && ( { + setSelectedPlanId(id); + setShowSpec(false); + }} onCreate={() => setShowNewPlan(true)} onDelete={handleDeletePlan} /> )} - {!loading && detail && selectedPlanId && ( + {!showSpec && !loading && detail && selectedPlanId && (
{detail.plan.phases.length > 0 && ( diff --git a/src/components/SpecView.tsx b/src/components/SpecView.tsx new file mode 100644 index 0000000..82fb793 --- /dev/null +++ b/src/components/SpecView.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeSlug from "rehype-slug"; +import { FileText } from "lucide-react"; +import { api } from "@/lib/api-client"; + +// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber +// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema. +export function SpecView() { + const [markdown, setMarkdown] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + api + .get<{ markdown: string }>("/api/spec") + .then((data) => setMarkdown(data.markdown)) + .catch((e) => setError(e instanceof Error ? e.message : "Laden fehlgeschlagen.")); + }, []); + + if (error) return

{error}

; + if (markdown === null) return

Laedt…

; + + return ( +
+
+ +

Spezifikation

+
+
+ ( +
+ {children}
+
+ ), + }} + > + {markdown} +
+
+
+ ); +}