Versionierung und Aenderungshistorie je Szenario
Deploy App / deploy (push) Successful in 1m48s

Version A.B: B automatisch je Bearbeitungssitzung (10-Minuten-Fenster,
unveraenderte Staende erzeugen keine), A manuell mit Pflichtkommentar.

Eine Version haelt den vollstaendigen Zustand als PlanInput-JSON -- dadurch
ist die Versionsauswahl in allen vier Analysewerkzeugen fast kostenlos,
bei Monte-Carlo je Szenario einzeln.

Wiederherstellen erhaelt die IDs (sonst verlieren Kind-Szenarien ihre
Diff-Basis) und legt den Stand selbst als neue Version an. Wo ein Bezug
trotzdem bricht, warnt der Dialog vorher namentlich.

Statischer Waechter-Test: jeder schreibende Endpunkt loest eine Version aus.
Migration gegen echtes Postgres verifiziert.

Spezifikation 0.19 (3.8 und 9.28 neu), 25 Tests (139 -> 164).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:09:10 +02:00
parent d04e07fdfb
commit d023534a03
28 changed files with 2021 additions and 13 deletions
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { phaseDataSchema } from "@/lib/elements";
// Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert).
@@ -29,5 +30,6 @@ export async function PUT(
update: { data: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
const patchSchema = z.object({ name: z.string().min(1).max(120) });
@@ -22,6 +23,7 @@ export async function PATCH(
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -37,5 +39,6 @@ export async function DELETE(
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
await prisma.financialElement.delete({ where: { id: element.id } });
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedElement } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { transitionDataSchema } from "@/lib/elements";
// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
@@ -29,5 +30,6 @@ export async function PUT(
update: { data: parsed.data },
});
await touchScenario(element.scenarioId, userId);
return NextResponse.json({ ok: true });
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOwnedPhase } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { cashTransitionSchema } from "@/lib/elements";
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
@@ -27,5 +28,6 @@ export async function PUT(
data: { cashTransition: parsed.data },
});
await touchScenario(phase.scenarioId, userId);
return NextResponse.json({ ok: true });
}
+3
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { maxPhaseDuration } from "@/lib/calculations";
const updatePhaseSchema = z.object({
@@ -47,6 +48,7 @@ export async function PUT(
durationYears: duration ?? undefined,
},
});
await touchScenario(existing.scenarioId, userId);
return NextResponse.json({ phase: { id: phase.id } });
}
@@ -70,5 +72,6 @@ export async function DELETE(
}
await prisma.phase.delete({ where: { id: phaseId } });
await touchScenario(phase.scenarioId, userId);
return NextResponse.json({ ok: true });
}
+3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
const personSchema = z.object({
role: z.enum(["PERSON_A", "PERSON_B"]),
@@ -81,6 +82,8 @@ export async function POST(request: NextRequest) {
include: { scenarios: true },
});
// Das frisch angelegte Basisszenario startet mit Version 1.0.
await touchScenario(plan.scenarios[0].id, userId);
return NextResponse.json(
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
{ status: 201 }
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
const copySchema = z.object({ name: z.string().min(1).max(120) });
@@ -90,5 +91,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return created.id;
});
// Das kopierte Szenario startet mit einer eigenen Version 1.0.
await touchScenario(newId, userId);
return NextResponse.json({ scenarioId: newId }, { status: 201 });
}
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
const createSchema = z.object({
@@ -65,5 +66,6 @@ export async function POST(
},
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ element: { id: element.id } }, { status: 201 });
}
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { Prisma } from "@/generated/prisma/client";
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
import { num, type PhaseData } from "@/lib/elements";
@@ -74,6 +75,7 @@ export async function POST(
return created;
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
}
@@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "@/lib/db";
import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { touchScenario } from "@/lib/versioning-db";
import { computePlan } from "@/lib/calculations";
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
@@ -77,6 +78,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
},
});
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
@@ -88,6 +90,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
"initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
},
});
await touchScenario(scenario.id, userId);
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { restoreImpact, restoreVersion } from "@/lib/versioning-db";
import type { PlanInput } from "@/lib/types";
async function ownedVersion(scenarioId: string, versionId: string, userId: string) {
return prisma.scenarioVersion.findFirst({
where: { id: versionId, scenarioId, scenario: { plan: { userId } } },
});
}
// Ein einzelner Stand: der Snapshot selbst, das daraus Gerechnete und -- als Vorwarnung für
// den Wiederherstellen-Knopf -- welche Kind-Szenarien dabei ihre Diff-Basis verlieren würden.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId, versionId } = await params;
const version = await ownedVersion(scenarioId, versionId, userId);
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
const snapshot = version.snapshot as unknown as PlanInput;
return NextResponse.json({
version: {
id: version.id,
major: version.major,
minor: version.minor,
comment: version.comment,
isMajor: version.isMajor,
createdAt: version.createdAt,
},
plan: snapshot,
computed: computePlan(snapshot),
impact: await restoreImpact(scenarioId, snapshot),
});
}
// Setzt das Szenario auf diesen Stand zurück. Löscht KEINE Historie: Der wiederhergestellte
// Stand wird selbst als neue Version mit Herkunftsvermerk festgehalten.
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId, versionId } = await params;
const version = await ownedVersion(scenarioId, versionId, userId);
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
const ref = await restoreVersion(scenarioId, versionId, userId);
if (!ref) return NextResponse.json({ error: "Wiederherstellen fehlgeschlagen." }, { status: 500 });
return NextResponse.json({ version: ref });
}
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { createMajorVersion } from "@/lib/versioning-db";
import { isValidMajorComment } from "@/lib/versioning";
async function ownedScenario(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
}
// Änderungshistorie eines Szenarios, neueste zuerst. Ohne die Snapshots -- die sind je
// Version zig Kilobyte gross und werden erst beim Anzeigen einzeln geladen.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await ownedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const rows = await prisma.scenarioVersion.findMany({
where: { scenarioId },
orderBy: [{ major: "desc" }, { minor: "desc" }],
select: {
id: true,
major: true,
minor: true,
comment: true,
isMajor: true,
createdAt: true,
updatedAt: true,
createdBy: { select: { username: true } },
},
});
return NextResponse.json({
currentMajor: scenario.currentMajor,
versions: rows.map((r) => ({
id: r.id,
major: r.major,
minor: r.minor,
comment: r.comment,
isMajor: r.isMajor,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
author: r.createdBy.username,
})),
});
}
const majorSchema = z.object({ comment: z.string().min(3).max(500) });
// Legt den aktuellen Stand als HAUPTVERSION fest. Der Kommentar ist Pflicht -- eine
// Hauptversion ohne Begründung wäre nur eine Zahl.
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await ownedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = majorSchema.safeParse(await request.json());
if (!parsed.success || !isValidMajorComment(parsed.data.comment)) {
return NextResponse.json({ error: "Bitte einen Kommentar zur Hauptversion angeben." }, { status: 400 });
}
const ref = await createMajorVersion(scenarioId, userId, parsed.data.comment);
if (!ref) return NextResponse.json({ error: "Hauptversion konnte nicht angelegt werden." }, { status: 500 });
return NextResponse.json({ version: ref }, { status: 201 });
}
+16
View File
@@ -9,6 +9,7 @@ import {
FileText,
FolderKanban,
GitBranch,
History,
LayoutDashboard,
Menu,
PiggyBank,
@@ -25,6 +26,7 @@ import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { LiveSimDialog } from "@/components/LiveSimDialog";
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
@@ -87,6 +89,7 @@ function AppShellInner({ username }: { username: string }) {
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const [showLiveSim, setShowLiveSim] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false);
const [showPalette, setShowPalette] = useState(false);
@@ -423,6 +426,10 @@ function AppShellInner({ username }: { username: string }) {
<BarChart3 className="h-4 w-4" />
Grafiken
</Button>
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
</Button>
<Button variant="secondary" onClick={() => setShowLiveSim(true)}>
<SlidersHorizontal className="h-4 w-4" />
Live-Simulation
@@ -540,6 +547,15 @@ function AppShellInner({ username }: { username: string }) {
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
)}
{showHistory && detail && (
<VersionHistoryDialog
scenarioId={detail.meta.id}
scenarioName={detail.meta.name}
onClose={() => setShowHistory(false)}
onRestored={refreshCurrent}
/>
)}
{showPlanTraces && detail && (
<PlanTraceDialog
computed={computePlan(detail.plan, undefined, { explain: true })}
+28 -4
View File
@@ -3,6 +3,8 @@
import { useMemo, useState } from "react";
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { computePlan } from "@/lib/calculations";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client";
@@ -16,8 +18,8 @@ interface PlanListItem {
}
export function Dashboard({
plan,
computed,
plan: currentPlan,
computed: currentComputed,
siblings,
}: {
plan: PlanInput;
@@ -25,6 +27,14 @@ export function Dashboard({
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
}) {
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
const computed = useMemo(
() => (versionId === "current" ? currentComputed : computePlan(plan)),
[versionId, currentComputed, plan]
);
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
@@ -55,6 +65,13 @@ export function Dashboard({
return (
<div className="flex flex-col gap-6">
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={setVersionId}
loading={versionLoading}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
@@ -78,12 +95,19 @@ export function Dashboard({
<LineChartIcon className="h-4 w-4 text-accent" />
Vermögensverlauf nach Alter
</h3>
{/* Der Export liest immer das Szenario aus der Datenbank -- also den aktuellen
Stand, nicht die betrachtete Version. Das wird beschriftet statt verschwiegen. */}
<a
href={`/api/scenarios/${plan.id}/export`}
href={`/api/scenarios/${currentPlan.id}/export`}
title={
versionId === "current"
? undefined
: "Der CSV-Export liefert immer den aktuellen Stand, nicht die betrachtete Version."
}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
>
<Download className="h-3.5 w-3.5" />
CSV-Export
CSV-Export{versionId === "current" ? "" : " (aktueller Stand)"}
</a>
</div>
{otherPlans.length > 0 && (
+14 -1
View File
@@ -6,6 +6,7 @@ import { AllocationChart } from "@/components/AllocationChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { WealthChart } from "@/components/WealthChart";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { computePlan } from "@/lib/calculations";
import { formatChf } from "@/lib/format";
import {
@@ -43,7 +44,9 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const [expandReturns, setExpandReturns] = useState(false);
const [values, setValues] = useState<SliderValues>({});
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
@@ -108,6 +111,16 @@ export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: ()
</button>
</div>
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={(id) => {
setVersionId(id);
setValues({});
}}
loading={versionLoading}
/>
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
gespeichert</strong> dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
+44 -2
View File
@@ -5,6 +5,8 @@ import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, Responsive
import { Dices, X } from "lucide-react";
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
import { computePlan } from "@/lib/calculations";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import {
@@ -137,6 +139,10 @@ export function MonteCarloDialog({
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
// Gewaehlter Stand je Szenario (Default: Arbeitsstand) und die dazu geladenen Snapshots.
const [versionByScenario, setVersionByScenario] = useState<Record<string, string>>({});
const [versionPlans, setVersionPlans] = useState<Record<string, PlanInput>>({});
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null);
// Fächer/Bänder stammen aus der historischen Welt.
const [fanRes, setFanRes] = useState<ScenarioMcResult[] | null>(null);
@@ -169,12 +175,40 @@ export function MonteCarloDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scenarioKey, meta.id]);
async function chooseVersion(scenarioId: string, versionId: string) {
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: versionId }));
clearResults();
if (versionId === CURRENT) return;
const key = `${scenarioId}:${versionId}`;
if (versionPlans[key]) return;
try {
const loadedPlan = await loadVersionPlan(scenarioId, versionId);
if (loadedPlan) setVersionPlans((prev) => ({ ...prev, [key]: loadedPlan }));
} catch {
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: CURRENT }));
}
}
const allLoaded = useMemo(
() => scenarios.map((s) => loaded[s.id]).filter((s): s is LoadedScenario => !!s),
[scenarios, loaded]
);
const groups = useMemo(() => buildElementGroups(allLoaded, selectedIds), [allLoaded, selectedIds]);
// Ersetzt je Szenario den Arbeitsstand durch den gewaehlten Snapshot. Der Snapshot ist
// bereits ein PlanInput, das Gerechnete entsteht lokal.
const resolved = useMemo(() => {
const out: Record<string, LoadedScenario> = {};
for (const sc of allLoaded) {
const vid = versionByScenario[sc.id] ?? CURRENT;
const snap = vid === CURRENT ? null : versionPlans[`${sc.id}:${vid}`];
out[sc.id] = snap ? { ...sc, plan: snap, computed: computePlan(snap) } : sc;
}
return out;
}, [allLoaded, versionByScenario, versionPlans]);
const resolvedList = useMemo(() => Object.values(resolved), [resolved]);
const groups = useMemo(() => buildElementGroups(resolvedList, selectedIds), [resolvedList, selectedIds]);
function draftFor(g: ElementGroup): ElementDraft {
return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" };
@@ -193,7 +227,7 @@ export function MonteCarloDialog({
clearResults();
}
const selected = selectedIds.map((id) => loaded[id]).filter((s): s is LoadedScenario => !!s);
const selected = selectedIds.map((id) => resolved[id]).filter((s): s is LoadedScenario => !!s);
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
@@ -362,6 +396,14 @@ export function MonteCarloDialog({
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
{!isLoaded && <span className="text-[11px] text-faint">lädt</span>}
</label>
{checked && (
<VersionSelect
compact
scenarioId={s.id}
value={versionByScenario[s.id] ?? CURRENT}
onChange={(vid) => void chooseVersion(s.id, vid)}
/>
)}
{checked && typeof nachlass === "number" && (
<span className="text-[11px] text-muted" title="Planungs-Endbetrag dieses Szenarios (read-only)">
Planungs-Endbetrag: <strong className="text-fg">{formatChf(Math.max(0, nachlass))}</strong>
+14 -1
View File
@@ -5,6 +5,7 @@ import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Toolt
import { Tornado, X } from "lucide-react";
import { RequiredNumberField, SelectField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { formatChf } from "@/lib/format";
import {
computeTornado,
@@ -34,7 +35,9 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
return `${sign(low)} ${suffix}${sign(high)} ${suffix}`;
}
export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
// Gerechnet wird wahlweise auf dem Arbeitsstand oder auf einer festgehaltenen Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
const [metric, setMetric] = useState<TornadoMetric>("real");
@@ -103,6 +106,16 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
</button>
</div>
<VersionBar
scenarioId={currentPlan.id}
versionId={versionId}
onChange={(id) => {
setVersionId(id);
setResult(null);
}}
loading={versionLoading}
/>
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
+327
View File
@@ -0,0 +1,327 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AlertTriangle, Eye, History, RotateCcw, Tag, X } from "lucide-react";
import { VersionMatrix } from "@/components/VersionMatrix";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
import type { RestoreImpact } from "@/lib/versioning";
export interface VersionRow {
id: string;
major: number;
minor: number;
comment: string | null;
isMajor: boolean;
createdAt: string;
updatedAt: string;
author: string;
}
interface VersionDetail {
version: { id: string; major: number; minor: number; comment: string | null; isMajor: boolean };
plan: PlanInput;
computed: PlanComputed;
impact: RestoreImpact;
}
const dt = (iso: string) =>
new Date(iso).toLocaleString("de-CH", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
export function VersionHistoryDialog({
scenarioId,
scenarioName,
onClose,
onRestored,
}: {
scenarioId: string;
scenarioName: string;
onClose: () => void;
onRestored: () => void;
}) {
const [rows, setRows] = useState<VersionRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<VersionDetail | null>(null);
const [busy, setBusy] = useState(false);
const [majorComment, setMajorComment] = useState("");
const [showMajorForm, setShowMajorForm] = useState(false);
const confirm = useConfirm();
const toast = useToast();
// Nachladen nach einer Aktion (Wiederherstellen, Hauptversion) -- nicht beim Öffnen.
const load = useCallback(async () => {
try {
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
setRows(data.versions);
} catch (e) {
setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
}
}, [scenarioId]);
// Erstes Laden. Das Cancel-Flag verhindert ein setState nach dem Schliessen des Dialogs.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
if (!cancelled) setRows(data.versions);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [scenarioId]);
async function openDetail(id: string) {
setBusy(true);
try {
setDetail(await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${id}`));
} catch (e) {
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
} finally {
setBusy(false);
}
}
async function restore(row: VersionRow) {
// Erst die Auswirkung holen: Kind-Szenarien können ihre Diff-Basis verlieren, und das
// muss VOR dem Klick auf dem Tisch liegen, nicht danach.
setBusy(true);
let impact: RestoreImpact;
try {
const d = await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${row.id}`);
impact = d.impact;
} catch (e) {
setBusy(false);
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
return;
}
setBusy(false);
const warn =
impact.affectedChildren.length > 0
? `\n\nAchtung: ${impact.affectedChildren.length === 1 ? "Das Szenario" : "Die Szenarien"} ` +
`«${impact.affectedChildren.join("», «")}» ${impact.affectedChildren.length === 1 ? "hängt" : "hängen"} ` +
`an diesem Szenario. Dieser Stand kannte ${impact.lostElementIds.length} Element(e) und ` +
`${impact.lostPhaseIds.length} Phase(n) noch nicht, auf die dort verwiesen wird die Abweichungs-Markierung ` +
`zeigt sie danach als «neu» statt als «geändert».`
: "";
const ok = await confirm({
title: `Auf Version ${row.major}.${row.minor} zurücksetzen?`,
message:
`Das Szenario «${scenarioName}» wird vollständig auf diesen Stand zurückgesetzt. ` +
`Es geht nichts verloren: Der wiederhergestellte Stand wird als neue Version festgehalten, ` +
`die bisherige Historie bleibt vollständig erhalten.${warn}`,
confirmLabel: "Wiederherstellen",
danger: impact.affectedChildren.length > 0,
});
if (!ok) return;
setBusy(true);
try {
const res = await api.post<{ version: { major: number; minor: number } }>(
`/api/scenarios/${scenarioId}/versions/${row.id}`,
{}
);
toast("success", `Wiederhergestellt aus ${row.major}.${row.minor} neue Version ${res.version.major}.${res.version.minor}.`);
await load();
onRestored();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Wiederherstellen fehlgeschlagen.");
} finally {
setBusy(false);
}
}
async function createMajor() {
setBusy(true);
try {
const res = await api.post<{ version: { major: number; minor: number } }>(
`/api/scenarios/${scenarioId}/versions`,
{ comment: majorComment }
);
toast("success", `Hauptversion ${res.version.major}.${res.version.minor} festgelegt.`);
setMajorComment("");
setShowMajorForm(false);
await load();
onRestored();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Hauptversion konnte nicht angelegt werden.");
} finally {
setBusy(false);
}
}
// --- Detailansicht (nur lesen) ---
if (detail) {
const last = detail.computed.phases[detail.computed.phases.length - 1];
return (
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={() => setDetail(null)}>
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-5xl flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Eye className="h-5 w-5 text-accent" />
Version {detail.version.major}.{detail.version.minor}
{detail.version.isMajor && (
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[10px] font-semibold text-accent-soft-fg">
Hauptversion
</span>
)}
</h2>
<p className="mt-0.5 text-xs text-muted">
Nur-Lese-Ansicht dieses Standes. {detail.version.comment && `«${detail.version.comment}»`}
</p>
</div>
<button type="button" onClick={() => setDetail(null)} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{last && (
<div className="flex flex-wrap gap-4 rounded-xl border border-border bg-surface-2 p-3 text-xs">
<span className="text-muted">
Endvermögen nominal: <strong className="text-fg">{formatChf(last.endWealthNominal)}</strong>
</span>
<span className="text-muted">
real: <strong className="text-fg">{formatChf(last.endWealthReal)}</strong>
</span>
<span className="text-muted">
Kapital reicht:{" "}
<strong className={detail.computed.ruinAge === null ? "text-success" : "text-danger"}>
{detail.computed.ruinAge === null ? "bis Planende" : `bis Alter ${detail.computed.ruinAge}`}
</strong>
</span>
</div>
)}
<VersionMatrix computed={detail.computed} />
</div>
</div>
);
}
// --- Liste ---
return (
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<History className="h-5 w-5 text-accent" /> Änderungshistorie
</h2>
<p className="mt-0.5 text-xs text-muted">
Szenario «{scenarioName}». Eine Nebenversion entsteht je Bearbeitungssitzung, nicht je
einzelner Änderung sonst wäre die Liste ein Tastenprotokoll.
</p>
</div>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{/* Hauptversion festlegen */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
{showMajorForm ? (
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-fg">
Wofür steht diese Hauptversion? <span className="text-danger">*</span>
</label>
<input
autoFocus
value={majorComment}
onChange={(e) => setMajorComment(e.target.value)}
placeholder="z. B. «Stand nach Beratungsgespräch, vor dem Hauskauf»"
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
<div className="flex items-center gap-2">
<Button size="sm" disabled={majorComment.trim().length < 3 || busy} onClick={createMajor}>
<Tag className="h-3.5 w-3.5" /> Als Hauptversion festlegen
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(false)}>
Abbrechen
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted">
Einen bewusst gesetzten Meilenstein festhalten mit Begründung.
</span>
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(true)}>
<Tag className="h-3.5 w-3.5" /> Aktuellen Stand als Hauptversion festlegen
</Button>
</div>
)}
</div>
{error && <p className="text-xs text-danger">{error}</p>}
{!rows && !error && <p className="text-xs text-muted">Historie wird geladen</p>}
{rows && rows.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Für dieses Szenario ist noch keine Version festgehalten. Die erste entsteht mit der
nächsten Änderung.
</p>
)}
{rows && rows.length > 0 && (
<div className="flex flex-col gap-2">
{rows.map((r, i) => (
<div
key={r.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
r.isMajor ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
}`}
>
<span className="flex w-16 shrink-0 items-center gap-1.5 font-semibold text-fg">
{r.isMajor && <Tag className="h-3.5 w-3.5 text-accent" />}
{r.major}.{r.minor}
</span>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted">
{r.author} · {dt(r.updatedAt)}
{i === 0 && (
<span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] text-faint">
aktueller Stand
</span>
)}
</div>
{r.comment && <div className="mt-0.5 truncate text-xs text-fg">«{r.comment}»</div>}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button size="sm" variant="secondary" disabled={busy} onClick={() => openDetail(r.id)}>
<Eye className="h-3.5 w-3.5" /> Anzeigen
</Button>
<Button size="sm" variant="secondary" disabled={busy || i === 0} onClick={() => restore(r)}>
<RotateCcw className="h-3.5 w-3.5" /> Wiederherstellen
</Button>
</div>
</div>
))}
</div>
)}
<p className="flex items-start gap-1.5 text-[11px] text-faint">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
Wiederherstellen löscht nichts: Der zurückgesetzte Stand wird selbst als neue Version
festgehalten. Hängen Szenarien an diesem, wird vorher gewarnt.
</p>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { Fragment } from "react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { ElementCategory } from "@/lib/elements";
// Nur-Lese-Matrix eines festgehaltenen Standes. Bewusst NICHT die Bearbeitungs-Matrix aus
// PlanView: Die trägt Inspector-Anbindung, Übergangs-Ampeln, Diff-Markierung und Dialoge --
// alles ohne Bedeutung für einen alten Stand, den man nur ansehen kann. Diese Ansicht zeigt
// stattdessen genau das, was eine Version ausmacht: Phasen, Elemente, Werte.
export function VersionMatrix({ computed }: { computed: PlanComputed }) {
const phases = computed.phases;
if (phases.length === 0) {
return <p className="text-sm text-muted">Dieser Stand enthielt noch keine Lebensphasen.</p>;
}
// Elemente in der gewohnten Kategorie-Reihenfolge, über alle Phasen gesammelt.
const elements = (() => {
const seen = new Map<string, { name: string; category: ElementCategory; ownerRole: string | null }>();
for (const p of phases) {
for (const e of p.elements) {
if (!seen.has(e.elementId)) {
seen.set(e.elementId, { name: e.name, category: e.category, ownerRole: e.ownerRole });
}
}
}
return [...seen.entries()]
.map(([id, v]) => ({ id, ...v }))
.sort((a, b) => {
const ca = CATEGORY_ORDER.indexOf(a.category);
const cb = CATEGORY_ORDER.indexOf(b.category);
return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH");
});
})();
// Kategoriewechsel als Zwischenüberschrift -- dieselbe Gliederung wie in der Planansicht.
// Vorab bestimmt statt während des Renderns mitgezählt: Eine Variable, die der Render-Lauf
// fortschreibt, verhält sich bei erneutem Rendern nicht mehr gleich.
const headerAt = new Map<string, ElementCategory>();
elements.forEach((el, i) => {
if (i === 0 || el.category !== elements[i - 1].category) headerAt.set(el.id, el.category);
});
return (
<div className="max-h-[60vh] overflow-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10">
<tr className="bg-surface-2 text-xs text-faint">
<th className="sticky left-0 z-20 bg-surface-2 px-3 py-2 text-left font-semibold">Element</th>
{phases.map((p) => (
<th key={p.id} className="whitespace-nowrap px-3 py-2 text-right font-semibold">
<div className="text-fg">{p.name}</div>
<div className="font-normal text-faint">{p.durationYears} J.</div>
</th>
))}
</tr>
</thead>
<tbody>
{elements.map((el) => {
const header = headerAt.get(el.id) ?? null;
return (
<Fragment key={el.id}>
{header && (
<tr className="border-t border-border bg-surface-2/60">
<td
colSpan={phases.length + 1}
className="sticky left-0 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint"
>
{CATEGORY_LABELS[header]}
</td>
</tr>
)}
<tr className="border-t border-border">
<td className="sticky left-0 z-10 bg-surface px-3 py-2">
<span className="font-medium text-fg">{el.name}</span>
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
<span className="ml-1.5 text-[10px] text-faint">
{el.ownerRole === "PERSON_A" ? "A" : "B"}
</span>
)}
</td>
{phases.map((p) => {
const c = p.elements.find((x) => x.elementId === el.id);
if (!c) return <td key={p.id} className="px-3 py-2 text-right text-faint"></td>;
return (
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right">
<div className={c.status === "ACTIVE" ? "text-fg" : "text-faint line-through"}>
{formatChf(c.startValue)}
</div>
{c.endValue !== c.startValue && (
<div className="text-[11px] text-muted"> {formatChf(c.endValue)}</div>
)}
</td>
);
})}
</tr>
</Fragment>
);
})}
{/* Vermögen je Phase -- die Kennzahl, für die der ganze Stand steht. */}
<tr className="border-t-2 border-border bg-surface-2">
<td className="sticky left-0 z-10 bg-surface-2 px-3 py-2 text-xs font-semibold text-fg">
Vermögen am Phasenende
</td>
{phases.map((p) => (
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right text-xs font-semibold text-fg">
{formatChf(p.endWealthNominal)}
<div className="font-normal text-faint">({formatChf(p.endWealthReal)} real)</div>
</td>
))}
</tr>
</tbody>
</table>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
import { History } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { api } from "@/lib/api-client";
import type { PlanInput } from "@/lib/types";
// Kennzeichnet den aktuellen (ungespeicherten) Arbeitsstand -- im Gegensatz zu einer
// festgehaltenen Version.
export const CURRENT = "current";
export interface VersionOption {
id: string;
label: string; // "1.4" bzw. "1.4 (Hauptversion)"
isMajor: boolean;
comment: string | null;
}
// Lädt die Versionsliste eines Szenarios. Bewusst ohne Snapshots -- die kommen erst beim
// tatsächlichen Auswählen dazu.
export function useVersionOptions(scenarioId: string | null): VersionOption[] {
const [options, setOptions] = useState<VersionOption[]>([]);
useEffect(() => {
if (!scenarioId) return;
let cancelled = false;
(async () => {
try {
const data = await api.get<{
versions: { id: string; major: number; minor: number; isMajor: boolean; comment: string | null }[];
}>(`/api/scenarios/${scenarioId}/versions`);
if (cancelled) return;
setOptions(
data.versions.map((v) => ({
id: v.id,
label: `${v.major}.${v.minor}${v.isMajor ? " (Hauptversion)" : ""}`,
isMajor: v.isMajor,
comment: v.comment,
}))
);
} catch {
if (!cancelled) setOptions([]);
}
})();
return () => {
cancelled = true;
};
}, [scenarioId]);
return options;
}
// Holt den Plan-Stand einer Version. `CURRENT` liefert null -- dann gilt der Arbeitsstand.
export async function loadVersionPlan(scenarioId: string, versionId: string): Promise<PlanInput | null> {
if (versionId === CURRENT) return null;
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${scenarioId}/versions/${versionId}`);
return data.plan;
}
// Ein kompakter Wähler je Szenario. Wird in allen vier Analysewerkzeugen verwendet, damit
// die Bedienung überall dieselbe ist.
export function VersionSelect({
scenarioId,
value,
onChange,
label,
compact,
}: {
scenarioId: string;
value: string;
onChange: (versionId: string) => void;
label?: string;
compact?: boolean;
}) {
const options = useVersionOptions(scenarioId);
return (
<label className={`flex items-center gap-1.5 ${compact ? "text-[11px]" : "text-xs"} text-muted`}>
{label && (
<span className="flex items-center font-medium">
<History className="mr-1 h-3.5 w-3.5 text-faint" />
{label}
</span>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`rounded-lg border border-border bg-surface px-2 py-1 text-fg ${compact ? "text-[11px]" : "text-xs"}`}
>
<option value={CURRENT}>Aktueller Stand</option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
{o.comment ? ` ${o.comment.slice(0, 40)}` : ""}
</option>
))}
</select>
</label>
);
}
// Hält die Auswahl "welcher Stand" für ein einzelnes Szenario. Solange `CURRENT` gewählt
// ist, wird der übergebene Arbeitsstand durchgereicht -- ohne Netzwerkzugriff.
//
// Der Snapshot ist bereits ein PlanInput, deshalb rechnen die Werkzeuge damit unverändert
// weiter; das Gerechnete entsteht bei ihnen lokal (computePlan ist rein und kostet ~0.2 ms).
export function useVersionedPlan(scenarioId: string, currentPlan: PlanInput) {
const [versionId, setVersionId] = useState<string>(CURRENT);
// Nur die geladenen Snapshots liegen im Zustand. Der Arbeitsstand ist bereits da und wird
// abgeleitet -- ihn in den Zustand zu spiegeln, hiesse ihn doppelt zu führen.
const [snapshots, setSnapshots] = useState<Record<string, PlanInput>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (versionId === CURRENT || snapshots[versionId]) return;
let cancelled = false;
(async () => {
try {
const loaded = await loadVersionPlan(scenarioId, versionId);
if (cancelled) return;
if (loaded) setSnapshots((prev) => ({ ...prev, [versionId]: loaded }));
setError(null);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [scenarioId, versionId, snapshots]);
const plan = versionId === CURRENT ? currentPlan : (snapshots[versionId] ?? currentPlan);
// Solange der Snapshot fehlt und kein Fehler vorliegt, laeuft der Abruf noch. Abgeleitet
// statt als eigener Zustand -- ein Ladeflag, das nur den Abruf spiegelt, ist redundant.
const loading = versionId !== CURRENT && !snapshots[versionId] && !error;
return { versionId, setVersionId, plan, loading, error };
}
// Kopfzeile für die Analyse-Dialoge mit einem einzelnen Szenario.
export function VersionBar({
scenarioId,
versionId,
onChange,
loading,
}: {
scenarioId: string;
versionId: string;
onChange: (id: string) => void;
loading?: boolean;
}) {
return (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<VersionSelect scenarioId={scenarioId} value={versionId} onChange={onChange} label="Berechnungsgrundlage" />
<VersionHint />
{loading && <span className="text-[11px] text-muted">wird geladen</span>}
{versionId !== CURRENT && !loading && (
<span className="rounded bg-attention-soft px-1.5 py-0.5 text-[10px] font-semibold text-attention-soft-fg">
Nicht der aktuelle Stand
</span>
)}
</div>
);
}
// Hinweiszeile für die Analyse-Dialoge.
export function VersionHint() {
return (
<InfoBubble text="Standardmässig rechnen die Werkzeuge mit dem aktuellen Arbeitsstand. Du kannst stattdessen jede festgehaltene Version wählen dann wird genau der damalige Stand gerechnet." />
);
}
+27
View File
@@ -59,5 +59,32 @@ describe("Datenbank-Migrationen", () => {
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
}
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
// --- Versionierung ---
expect(tables, "Tabelle ScenarioVersion fehlt").toContain("ScenarioVersion");
expect(scenCols, "Scenario.currentMajor fehlt").toContain("currentMajor");
const verCols = await cols("ScenarioVersion");
for (const c of ["scenarioId", "major", "minor", "comment", "isMajor", "createdById", "snapshot"]) {
expect(verCols, `ScenarioVersion.${c} fehlt`).toContain(c);
}
// Bestehende Szenarien müssen nach der Migration eine gültige Hauptversion tragen --
// sonst stünde ein vor der Migration angelegter Plan ohne Version da.
const major = await db.query<{ column_default: string | null; is_nullable: string }>(
`SELECT column_default, is_nullable FROM information_schema.columns
WHERE table_name='Scenario' AND column_name='currentMajor'`
);
expect(major.rows[0].is_nullable).toBe("NO");
expect(major.rows[0].column_default).toContain("1");
// A.B muss je Szenario eindeutig sein, sonst kollidieren zwei Sitzungen auf derselben
// Nummer und die Historie wird mehrdeutig.
const idx = (
await db.query<{ indexname: string }>(
`SELECT indexname FROM pg_indexes WHERE tablename='ScenarioVersion'`
)
).rows.map((r) => r.indexname);
expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key");
}, 60000);
});
+81
View File
@@ -0,0 +1,81 @@
// Wächter über die Vollständigkeit der Historie.
//
// Die Versionierung hängt daran, dass JEDER inhaltsverändernde Endpunkt `touchScenario`
// aufruft. Ein vergessener Pfad fällt nicht auf -- er erzeugt einfach still keine Version,
// und die Historie hat eine Lücke, die man erst Wochen später bemerkt. Dieser Test liest die
// Route-Dateien und prüft das statisch.
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
const API = path.join(process.cwd(), "src", "app", "api");
function routeFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...routeFiles(full));
else if (entry.name === "route.ts") out.push(full);
}
return out;
}
// Endpunkte, die schreiben, aber bewusst KEINE Version erzeugen -- jeweils mit Begründung.
const EXEMPT: Record<string, string> = {
"auth/login": "kein Szenario betroffen",
"auth/logout": "kein Szenario betroffen",
"auth/register": "kein Szenario betroffen",
"auth/change-password": "kein Szenario betroffen",
"plans/[planId]": "ändert nur den Plan-Namen bzw. löscht den ganzen Plan kein Szenario-Inhalt",
"scenarios/[scenarioId]/versions": "erzeugt Versionen selbst (Hauptversion / Wiederherstellen)",
"scenarios/[scenarioId]/versions/[versionId]": "erzeugt Versionen selbst",
};
describe("Versionierung: Abdeckung der Schreibpfade", () => {
const files = routeFiles(API);
it("findet überhaupt Endpunkte", () => {
expect(files.length).toBeGreaterThan(10);
});
it("ruft in jedem inhaltsverändernden Endpunkt touchScenario auf", () => {
const missing: string[] = [];
for (const file of files) {
const src = readFileSync(file, "utf8");
const rel = path
.relative(API, file)
.replace(/\\/g, "/")
.replace(/\/route\.ts$/, "");
// Schreibt dieser Endpunkt überhaupt?
const mutates = /export async function (POST|PUT|PATCH|DELETE)/.test(src);
if (!mutates) continue;
if (rel in EXEMPT) continue;
// Ein DELETE, das das Szenario selbst entfernt, braucht keine Version mehr.
const onlyDeletesScenario =
rel === "scenarios/[scenarioId]" && !/export async function (POST|PUT|PATCH)/.test(src);
if (onlyDeletesScenario) continue;
if (!src.includes("touchScenario(")) missing.push(rel);
}
expect(
missing,
`Diese Endpunkte verändern Inhalte, erzeugen aber keine Version:\n ${missing.join(
"\n "
)}\nEntweder touchScenario ergänzen oder in EXEMPT mit Begründung eintragen.`
).toEqual([]);
});
it("hält die Ausnahmeliste frei von Karteileichen", () => {
// Eine Ausnahme für einen Endpunkt, den es nicht mehr gibt, verschleiert später eine
// echte Lücke.
const known = new Set(
files.map((f) => path.relative(API, f).replace(/\\/g, "/").replace(/\/route\.ts$/, ""))
);
const stale = Object.keys(EXEMPT).filter((k) => !known.has(k));
expect(stale, `Ausnahmen ohne zugehörigen Endpunkt: ${stale.join(", ")}`).toEqual([]);
});
});
+283
View File
@@ -0,0 +1,283 @@
// Datenbank-Anbindung der Versionierung. Die Entscheidungslogik (wann eine Version entsteht,
// wie sie nummeriert wird) liegt in `versioning.ts` und ist dort ohne Datenbank getestet.
import { prisma } from "@/lib/db";
import { planInclude, toPlanInput } from "@/lib/queries";
import {
assessRestore,
decideVersion,
nextMajor,
planRestore,
restoreComment,
type LatestVersion,
type RestoreImpact,
type VersionRef,
} from "@/lib/versioning";
import type { PlanInput } from "@/lib/types";
import type { Prisma } from "@/generated/prisma/client";
const asJson = (plan: PlanInput) => plan as unknown as Prisma.InputJsonValue;
async function loadPlanInput(scenarioId: string): Promise<PlanInput | null> {
const scenario = await prisma.scenario.findUnique({ where: { id: scenarioId }, include: planInclude });
return scenario ? toPlanInput(scenario) : null;
}
async function loadLatest(scenarioId: string): Promise<LatestVersion | null> {
const v = await prisma.scenarioVersion.findFirst({
where: { scenarioId },
orderBy: [{ major: "desc" }, { minor: "desc" }],
});
if (!v) return null;
return {
id: v.id,
major: v.major,
minor: v.minor,
isMajor: v.isMajor,
createdById: v.createdById,
updatedAt: v.updatedAt,
snapshot: v.snapshot as unknown as PlanInput,
};
}
// Wird nach JEDEM inhaltsveraendernden Schreibvorgang aufgerufen. Legt je nach Entscheidung
// eine neue Nebenversion an, aktualisiert die laufende oder tut nichts.
//
// Bewusst fehlertolerant: Schlaegt das Festhalten der Version fehl, darf das die eigentliche
// Aenderung des Nutzers nicht scheitern lassen -- die Historie ist Begleitinformation, nicht
// der Zweck der Anfrage.
export async function touchScenario(scenarioId: string, userId: string): Promise<void> {
try {
const next = await loadPlanInput(scenarioId);
if (!next) return;
const latest = await loadLatest(scenarioId);
const decision = decideVersion(latest, next, userId, new Date());
if (decision.action === "skip") return;
if (decision.action === "update") {
await prisma.scenarioVersion.update({
where: { id: decision.versionId },
data: { snapshot: asJson(next) },
});
return;
}
await prisma.scenarioVersion.create({
data: {
scenarioId,
major: decision.major,
minor: decision.minor,
createdById: userId,
snapshot: asJson(next),
},
});
} catch (err) {
console.error("Versionierung fehlgeschlagen", { scenarioId, err });
}
}
// Legt den aktuellen Stand als HAUPTVERSION fest. Anders als eine Nebenversion wird sie nie
// zusammengefasst und nie nachtraeglich veraendert.
export async function createMajorVersion(
scenarioId: string,
userId: string,
comment: string
): Promise<VersionRef | null> {
const snapshot = await loadPlanInput(scenarioId);
if (!snapshot) return null;
const latest = await loadLatest(scenarioId);
const ref = nextMajor(latest);
await prisma.$transaction([
prisma.scenarioVersion.create({
data: {
scenarioId,
major: ref.major,
minor: ref.minor,
comment: comment.trim(),
isMajor: true,
createdById: userId,
snapshot: asJson(snapshot),
},
}),
prisma.scenario.update({ where: { id: scenarioId }, data: { currentMajor: ref.major } }),
]);
return ref;
}
// Welche Bezuege wuerde ein Wiederherstellen in den Kind-Szenarien brechen? Wird vor dem
// eigentlichen Wiederherstellen abgefragt, damit der Dialog warnen kann.
export async function restoreImpact(scenarioId: string, snapshot: PlanInput): Promise<RestoreImpact> {
const children = await prisma.scenario.findMany({
where: { parentScenarioId: scenarioId },
select: {
name: true,
elements: { select: { sourceElementId: true } },
phases: { select: { sourcePhaseId: true } },
},
});
return assessRestore(
snapshot,
children.map((c) => ({
name: c.name,
elementSourceIds: c.elements.map((e) => e.sourceElementId).filter((x): x is string => !!x),
phaseSourceIds: c.phases.map((p) => p.sourcePhaseId).filter((x): x is string => !!x),
}))
);
}
// Setzt das Szenario auf den Stand einer Version zurueck.
//
// IDs von Phasen und Elementen werden ERHALTEN (der Snapshot traegt sie mit). Wuerden hier
// neue IDs entstehen, verloeren alle Kind-Szenarien ihre Diff-Basis: `sourceElementId` zeigt
// auf die IDs dieses Szenarios, und jedes Element wuerde schlagartig als "neu" statt als
// "geaendert" gelten.
//
// Das Wiederherstellen loescht KEINE Historie -- es legt anschliessend eine neue Version an.
export async function restoreVersion(
scenarioId: string,
versionId: string,
userId: string
): Promise<VersionRef | null> {
const version = await prisma.scenarioVersion.findFirst({ where: { id: versionId, scenarioId } });
if (!version) return null;
const snap = version.snapshot as unknown as PlanInput;
// Was zu tun ist, entscheidet die reine Funktion `planRestore` (dort getestet); hier wird
// der Plan nur noch ausgefuehrt.
const [curPhases, curElements, curPersons] = await Promise.all([
prisma.phase.findMany({ where: { scenarioId }, select: { id: true } }),
prisma.financialElement.findMany({ where: { scenarioId }, select: { id: true } }),
prisma.person.findMany({ where: { scenarioId }, select: { role: true } }),
]);
const plan = planRestore(snap, {
phaseIds: curPhases.map((p) => p.id),
elementIds: curElements.map((e) => e.id),
personRoles: curPersons.map((p) => p.role),
});
const phaseById = new Map(snap.phases.map((p) => [p.id, p]));
const elementById = new Map(snap.elements.map((e) => [e.id, e]));
await prisma.$transaction(async (tx) => {
// Profil.
await tx.scenario.update({
where: { id: scenarioId },
data: {
householdType: snap.householdType,
inflationRateDefault: snap.inflationRateDefault,
initialCash: snap.initialCash,
startYear: snap.startYear ?? null,
},
});
// Personen: an der Rolle festgemacht (je Szenario eindeutig).
for (const p of snap.persons) {
await tx.person.upsert({
where: { scenarioId_role: { scenarioId, role: p.role } },
create: { id: p.id, scenarioId, role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge },
update: { name: p.name, age: p.age, retirementAge: p.retirementAge },
});
}
if (plan.deletePersonRoles.length > 0) {
await tx.person.deleteMany({
where: { scenarioId, role: { in: plan.deletePersonRoles as never } },
});
}
// Was der alte Stand nicht kannte, verschwindet -- samt seiner Werte (Cascade).
if (plan.deletePhaseIds.length > 0) {
await tx.phase.deleteMany({ where: { id: { in: plan.deletePhaseIds } } });
}
if (plan.deleteElementIds.length > 0) {
await tx.financialElement.deleteMany({ where: { id: { in: plan.deleteElementIds } } });
}
// Sequenznummern zuerst auf negative Werte parken: Sie sind je Szenario eindeutig, und
// beim Zurueckgehen koennen sich alte und neue Nummern ueberschneiden.
for (const [i, id] of plan.parkPhaseIds.entries()) {
await tx.phase.update({ where: { id }, data: { sequenceNumber: -1 - i } });
}
const phaseData = (id: string) => {
const ph = phaseById.get(id)!;
return {
sequenceNumber: ph.sequenceNumber,
name: ph.name,
durationYears: ph.durationYears,
cashTransition: (ph.cashTransition ?? {}) as Prisma.InputJsonValue,
sourcePhaseId: ph.sourcePhaseId ?? null,
};
};
for (const id of plan.updatePhaseIds) await tx.phase.update({ where: { id }, data: phaseData(id) });
for (const id of plan.createPhaseIds) {
await tx.phase.create({ data: { id, scenarioId, ...phaseData(id) } });
}
const elementData = (id: string) => {
const el = elementById.get(id)!;
return {
category: el.category,
name: el.name,
ownerRole: el.ownerRole ?? null,
orderIndex: el.orderIndex,
sourceElementId: el.sourceElementId ?? null,
};
};
for (const id of plan.updateElementIds) {
await tx.financialElement.update({ where: { id }, data: elementData(id) });
}
for (const id of plan.createElementIds) {
await tx.financialElement.create({ data: { id, scenarioId, ...elementData(id) } });
}
// Werte vollstaendig ersetzen statt abgleichen -- der Snapshot ist die Wahrheit.
const allElementIds = [...plan.updateElementIds, ...plan.createElementIds];
if (allElementIds.length > 0) {
await tx.elementPhaseValue.deleteMany({ where: { elementId: { in: allElementIds } } });
await tx.elementTransitionValue.deleteMany({ where: { elementId: { in: allElementIds } } });
}
for (const { elementId, phaseId } of plan.phaseValueKeys) {
await tx.elementPhaseValue.create({
data: {
elementId,
phaseId,
data: elementById.get(elementId)!.phaseValues[phaseId] as Prisma.InputJsonValue,
},
});
}
for (const { elementId, fromPhaseId } of plan.transitionValueKeys) {
await tx.elementTransitionValue.create({
data: {
elementId,
fromPhaseId,
data: elementById.get(elementId)!.transitionValues[fromPhaseId] as Prisma.InputJsonValue,
},
});
}
});
// Der wiederhergestellte Stand ist selbst eine neue Version -- mit Herkunftsvermerk, und
// ohne dass irgendetwas aus der Historie verloren geht.
const latest = await loadLatest(scenarioId);
const current = await loadPlanInput(scenarioId);
if (!current) return null;
const created = await prisma.scenarioVersion.create({
data: {
scenarioId,
major: latest?.major ?? 1,
minor: (latest?.minor ?? 0) + 1,
comment: restoreComment({ major: version.major, minor: version.minor }),
createdById: userId,
snapshot: asJson(current),
},
});
return { major: created.major, minor: created.minor };
}
+267
View File
@@ -0,0 +1,267 @@
import { describe, it, expect } from "vitest";
import {
assessRestore,
canonicalJson,
COALESCE_WINDOW_MS,
decideVersion,
formatVersion,
isValidMajorComment,
nextMajor,
planRestore,
restoreComment,
sameSnapshot,
type LatestVersion,
} from "@/lib/versioning";
import type { PlanInput } from "@/lib/types";
function snap(overrides: Partial<PlanInput> = {}): PlanInput {
return {
id: "s",
name: "Szenario",
householdType: "SINGLE",
inflationRateDefault: 1.5,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} }],
elements: [
{
id: "e1",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 100000 } },
transitionValues: {},
},
],
...overrides,
} as unknown as PlanInput;
}
function latest(over: Partial<LatestVersion> = {}): LatestVersion {
return {
id: "v1",
major: 1,
minor: 3,
isMajor: false,
createdById: "u1",
updatedAt: new Date("2026-07-19T10:00:00Z"),
snapshot: snap(),
...over,
} as LatestVersion;
}
const T0 = new Date("2026-07-19T10:00:00Z");
const after = (ms: number) => new Date(T0.getTime() + ms);
describe("canonicalJson", () => {
it("ist unabhängig von der Schlüsselreihenfolge", () => {
// Genau der Fall aus der Praxis: `toPlanInput` baut phaseValues aus Datenbankzeilen auf,
// deren Reihenfolge nicht garantiert ist.
const a = { phaseValues: { p1: { amount: 1 }, p2: { amount: 2 } } };
const b = { phaseValues: { p2: { amount: 2 }, p1: { amount: 1 } } };
expect(canonicalJson(a)).toBe(canonicalJson(b));
// Ein echter Unterschied bleibt aber einer.
expect(canonicalJson(a)).not.toBe(canonicalJson({ phaseValues: { p1: { amount: 9 } } }));
});
it("lässt die Reihenfolge von Listen in Ruhe", () => {
// Bei Phasen ist die Reihenfolge inhaltlich bedeutsam.
expect(canonicalJson([1, 2])).not.toBe(canonicalJson([2, 1]));
});
});
describe("decideVersion", () => {
it("legt für den allerersten Stand 1.0 an", () => {
expect(decideVersion(null, snap(), "u1", T0)).toEqual({ action: "create", major: 1, minor: 0 });
});
it("tut nichts, wenn sich inhaltlich nichts geändert hat", () => {
// Dialog geöffnet, unverändert gespeichert -- darf keine Version erzeugen.
const d = decideVersion(latest(), snap(), "u1", after(1000));
expect(d.action).toBe("skip");
});
it("fasst Schreibvorgänge derselben Sitzung zu EINER Nebenversion zusammen", () => {
// Der Kern der Entscheidung: Der Verteil-Dialog schreibt einmal je Zielelement, der
// Assistent ~14 Mal. Ohne Zusammenfassung wäre die Historie ein Tastenprotokoll.
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u1", after(60_000));
expect(d).toEqual({ action: "update", versionId: "v1" });
});
it("beginnt nach Ablauf des Fensters eine neue Nebenversion", () => {
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u1", after(COALESCE_WINDOW_MS + 1));
expect(d).toEqual({ action: "create", major: 1, minor: 4 });
});
it("fasst Änderungen verschiedener Benutzer nie zusammen", () => {
// Vorbereitung auf den Finanzberater: fremde Änderungen dürfen nicht unter einem Namen
// zusammenlaufen, auch nicht innerhalb des Zeitfensters.
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u2", after(1000));
expect(d).toEqual({ action: "create", major: 1, minor: 4 });
});
it("verändert eine Hauptversion nie nachträglich", () => {
// Eine Hauptversion ist ein bewusst gesetzter Schnitt. Die nächste Änderung beginnt A.1,
// auch wenn sie eine Sekunde später kommt.
const d = decideVersion(
latest({ isMajor: true, major: 2, minor: 0 }),
snap({ initialCash: 5000 }),
"u1",
after(1000)
);
expect(d).toEqual({ action: "create", major: 2, minor: 1 });
});
});
describe("Hauptversionen", () => {
it("zählt die Hauptversion hoch und setzt die Nebenversion zurück", () => {
expect(nextMajor({ major: 1, minor: 7 })).toEqual({ major: 2, minor: 0 });
expect(nextMajor(null)).toEqual({ major: 1, minor: 0 });
});
it("verlangt einen echten Kommentar", () => {
expect(isValidMajorComment("Vor dem Hauskauf")).toBe(true);
expect(isValidMajorComment(" ")).toBe(false);
expect(isValidMajorComment("ok")).toBe(false);
});
it("formatiert A.B", () => {
expect(formatVersion({ major: 2, minor: 0 })).toBe("2.0");
});
});
describe("assessRestore", () => {
const s = snap(); // kennt Phase p1 und Element e1
it("meldet nichts, wenn alle Bezüge der Kinder im alten Stand existieren", () => {
const impact = assessRestore(s, [
{ name: "Frühpension", elementSourceIds: ["e1"], phaseSourceIds: ["p1"] },
]);
expect(impact.affectedChildren).toEqual([]);
expect(impact.lostElementIds).toEqual([]);
});
it("meldet Kind-Szenarien, deren Diff-Basis wegfällt", () => {
// Das Kind zeigt auf ein Element, das es im wiederhergestellten Stand noch nicht gab.
// Dann erschiene es dort schlagartig als "neu" statt als "geändert".
const impact = assessRestore(s, [
{ name: "Frühpension", elementSourceIds: ["e1", "e99"], phaseSourceIds: ["p1"] },
{ name: "Umzug", elementSourceIds: ["e1"], phaseSourceIds: ["p1"] },
]);
expect(impact.affectedChildren).toEqual(["Frühpension"]);
expect(impact.lostElementIds).toEqual(["e99"]);
expect(impact.lostPhaseIds).toEqual([]);
});
it("erkennt auch verlorene Phasenbezüge und zählt jede ID nur einmal", () => {
const impact = assessRestore(s, [
{ name: "A", elementSourceIds: [], phaseSourceIds: ["p9"] },
{ name: "B", elementSourceIds: [], phaseSourceIds: ["p9"] },
]);
expect(impact.lostPhaseIds).toEqual(["p9"]);
expect(impact.affectedChildren).toEqual(["A", "B"]);
});
});
describe("planRestore", () => {
// Alter Stand: zwei Phasen, ein Element mit Werten in beiden.
const old = snap({
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{
id: "e1",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 100000 }, p2: { amount: 0 } },
transitionValues: { p1: {} },
},
],
} as unknown as Partial<PlanInput>);
it("erhält bestehende IDs, statt neu anzulegen", () => {
// Der Kern: Kind-Szenarien zeigen über sourceElementId auf genau diese IDs.
const plan = planRestore(old, { phaseIds: ["p1", "p2"], elementIds: ["e1"], personRoles: ["PERSON_A"] });
expect(plan.updatePhaseIds).toEqual(["p1", "p2"]);
expect(plan.updateElementIds).toEqual(["e1"]);
expect(plan.createPhaseIds).toEqual([]);
expect(plan.createElementIds).toEqual([]);
});
it("entfernt, was der alte Stand nicht kannte", () => {
const plan = planRestore(old, {
phaseIds: ["p1", "p2", "p3"],
elementIds: ["e1", "e2"],
personRoles: ["PERSON_A", "PERSON_B"],
});
expect(plan.deletePhaseIds).toEqual(["p3"]);
expect(plan.deleteElementIds).toEqual(["e2"]);
// Der Haushalt war damals eine Einzelperson -- Person B muss weg.
expect(plan.deletePersonRoles).toEqual(["PERSON_B"]);
});
it("legt neu an, was seither gelöscht wurde", () => {
const plan = planRestore(old, { phaseIds: ["p1"], elementIds: [], personRoles: ["PERSON_A"] });
expect(plan.createPhaseIds).toEqual(["p2"]);
expect(plan.createElementIds).toEqual(["e1"]);
expect(plan.updatePhaseIds).toEqual(["p1"]);
});
it("parkt nur die überlebenden Phasen zum Umnummerieren", () => {
// Die zu löschenden sind vorher schon weg -- sie zu parken wäre ein Fehler.
const plan = planRestore(old, { phaseIds: ["p2", "p3"], elementIds: [], personRoles: [] });
expect(plan.parkPhaseIds).toEqual(["p2"]);
expect(plan.deletePhaseIds).toEqual(["p3"]);
});
it("verwirft Werte, die auf inzwischen entfernte Phasen zeigen", () => {
// Ein Element trägt einen Wert für eine Phase, die dieser Stand gar nicht kannte.
const withOrphan = snap({
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} }],
elements: [
{
id: "e1",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 1 }, pX: { amount: 2 } },
transitionValues: { pX: {} },
},
],
} as unknown as Partial<PlanInput>);
const plan = planRestore(withOrphan, { phaseIds: ["p1"], elementIds: ["e1"], personRoles: [] });
expect(plan.phaseValueKeys).toEqual([{ elementId: "e1", phaseId: "p1" }]);
// Ein Übergangswert nach einer nicht existierenden Phase würde beim Schreiben auf einen
// Fremdschlüsselfehler laufen.
expect(plan.transitionValueKeys).toEqual([]);
});
it("führt alle Werte des alten Standes auf", () => {
const plan = planRestore(old, { phaseIds: ["p1", "p2"], elementIds: ["e1"], personRoles: [] });
expect(plan.phaseValueKeys).toHaveLength(2);
expect(plan.transitionValueKeys).toEqual([{ elementId: "e1", fromPhaseId: "p1" }]);
});
});
describe("restoreComment", () => {
it("hält die Herkunft fest", () => {
expect(restoreComment({ major: 1, minor: 3 })).toBe("Wiederhergestellt aus 1.3");
});
});
describe("sameSnapshot", () => {
it("erkennt inhaltliche Gleichheit trotz anderer Schlüsselreihenfolge", () => {
const a = snap();
const b = snap({ elements: [{ ...snap().elements[0] }] });
expect(sameSnapshot(a, b)).toBe(true);
expect(sameSnapshot(a, snap({ inflationRateDefault: 2 }))).toBe(false);
});
});
+221
View File
@@ -0,0 +1,221 @@
// Versionierung je Szenario (SPEZIFIKATION 3.8, 4.16).
//
// Version A.B:
// B (Nebenversion) -- automatisch, EINE je Bearbeitungssitzung.
// A (Hauptversion) -- manuell, mit Pflichtkommentar; setzt B auf 0 zurueck.
//
// Warum nicht je Schreibvorgang: FPT hat keinen Speichern-Knopf, jede Aenderung schreibt
// sofort. Ein Durchlauf des Plan-Assistenten macht ~14 Schreibvorgaenge, ein Klick im
// Verteil-Dialog einen pro Zielelement. Eine Version je Schreibvorgang waere ein
// Tastenprotokoll, keine Historie. Stattdessen fassen wir alle Schreibvorgaenge innerhalb
// eines Zeitfensters zu EINER Nebenversion zusammen (siehe 9.28).
//
// Dieses Modul enthaelt die reine Entscheidungslogik (ohne I/O), damit sie ohne Datenbank
// testbar ist. Die Datenbank-Anbindung liegt in `versioning-db.ts`.
import type { PlanInput } from "@/lib/types";
// Innerhalb dieses Fensters aktualisiert ein weiterer Schreibvorgang die bestehende
// Nebenversion, statt eine neue anzulegen.
export const COALESCE_WINDOW_MS = 10 * 60 * 1000; // 10 Minuten
export interface VersionRef {
major: number;
minor: number;
}
export function formatVersion(v: VersionRef): string {
return `${v.major}.${v.minor}`;
}
// Der jeweils letzte Stand, gegen den entschieden wird.
export interface LatestVersion extends VersionRef {
id: string;
isMajor: boolean;
createdById: string;
updatedAt: Date;
snapshot: PlanInput;
}
export type VersionDecision =
| { action: "skip"; reason: string }
| { action: "update"; versionId: string }
| { action: "create"; major: number; minor: number };
// Serialisiert mit SORTIERTEN Schluesseln. Noetig, weil `toPlanInput` die Werte je Phase als
// Objekt aufbaut (`phaseValues[phaseId] = ...`) und die Reihenfolge dieser Schluessel aus der
// Datenbank-Zeilenfolge stammt -- die ist nicht garantiert. Ohne Sortierung wuerden zwei
// inhaltlich identische Staende als verschieden gelten und die Historie mit Scheinversionen
// fluten.
export function canonicalJson(value: unknown): string {
return JSON.stringify(value, (_key, val) => {
if (val === null || typeof val !== "object" || Array.isArray(val)) return val;
return Object.fromEntries(Object.entries(val as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
});
}
// Vergleicht zwei Staende inhaltlich.
export function sameSnapshot(a: PlanInput, b: PlanInput): boolean {
return canonicalJson(a) === canonicalJson(b);
}
// Entscheidet, was ein Schreibvorgang mit der Historie macht.
//
// `latest` ist die zuletzt angelegte Version des Szenarios (oder null, wenn es noch keine
// gibt). `now` und `userId` beschreiben den aktuellen Schreibvorgang.
export function decideVersion(
latest: LatestVersion | null,
next: PlanInput,
userId: string,
now: Date
): VersionDecision {
// Allererster Stand: 1.0.
if (!latest) return { action: "create", major: 1, minor: 0 };
// Nichts veraendert -- z. B. Dialog geoeffnet und unveraendert gespeichert. Ohne diese
// Pruefung sammelt die Historie Versionen, die nichts unterscheiden.
if (sameSnapshot(latest.snapshot, next)) {
return { action: "skip", reason: "unveraendert" };
}
const withinWindow = now.getTime() - latest.updatedAt.getTime() < COALESCE_WINDOW_MS;
// Eine Hauptversion ist ein bewusst gesetzter Schnitt -- sie wird nie nachtraeglich
// veraendert, auch nicht innerhalb des Fensters. Die naechste Aenderung beginnt A.1.
if (latest.isMajor) {
return { action: "create", major: latest.major, minor: latest.minor + 1 };
}
// Fortsetzung derselben Sitzung: bestehende Nebenversion aktualisieren. An den Benutzer
// gebunden, damit spaeter (Finanzberater) nicht fremde Aenderungen unter einem Namen
// zusammenlaufen.
if (withinWindow && latest.createdById === userId) {
return { action: "update", versionId: latest.id };
}
return { action: "create", major: latest.major, minor: latest.minor + 1 };
}
// Naechste Hauptversion. Der Kommentar ist Pflicht -- eine Hauptversion ohne Begruendung
// waere nur eine Zahl.
export function nextMajor(latest: VersionRef | null): VersionRef {
return { major: (latest?.major ?? 0) + 1, minor: 0 };
}
export function isValidMajorComment(comment: string): boolean {
return comment.trim().length >= 3;
}
// --- Wiederherstellen ------------------------------------------------------------------
// Kind-Szenarien zeigen ueber `sourceElementId` / `sourcePhaseId` auf die IDs DIESES
// Szenarios -- darauf beruht die Abweichungs-Markierung. Beim Wiederherstellen bleiben die
// IDs erhalten; trotzdem kann ein Bezug brechen, wenn der alte Stand ein Element oder eine
// Phase noch gar nicht enthielt. Das laesst sich nicht verhindern, aber ankuendigen.
export interface RestoreImpact {
lostElementIds: string[];
lostPhaseIds: string[];
affectedChildren: string[]; // Namen der betroffenen Kind-Szenarien
}
export function assessRestore(
snapshot: PlanInput,
children: { name: string; elementSourceIds: string[]; phaseSourceIds: string[] }[]
): RestoreImpact {
const haveElements = new Set(snapshot.elements.map((e) => e.id));
const havePhases = new Set(snapshot.phases.map((p) => p.id));
const lostElementIds = new Set<string>();
const lostPhaseIds = new Set<string>();
const affectedChildren: string[] = [];
for (const child of children) {
const lostE = child.elementSourceIds.filter((id) => !haveElements.has(id));
const lostP = child.phaseSourceIds.filter((id) => !havePhases.has(id));
if (lostE.length === 0 && lostP.length === 0) continue;
lostE.forEach((id) => lostElementIds.add(id));
lostP.forEach((id) => lostPhaseIds.add(id));
affectedChildren.push(child.name);
}
return {
lostElementIds: [...lostElementIds],
lostPhaseIds: [...lostPhaseIds],
affectedChildren,
};
}
// Kommentar der Version, die durch ein Wiederherstellen entsteht. Das Wiederherstellen
// LOESCHT keine Historie -- es legt einen neuen Stand oben drauf.
export function restoreComment(from: VersionRef): string {
return `Wiederhergestellt aus ${formatVersion(from)}`;
}
// --- Bauplan des Wiederherstellens -----------------------------------------------------
//
// Das Wiederherstellen ist der einzige destruktive Pfad der Anwendung. Damit er pruefbar
// ist, entscheidet diese reine Funktion, WAS geschehen soll; `versioning-db.ts` fuehrt den
// Plan nur noch aus. Drei Feinheiten stecken darin:
//
// 1. IDs bleiben erhalten -- sonst verlieren Kind-Szenarien ihre Diff-Basis.
// 2. `Phase.sequenceNumber` ist je Szenario eindeutig. Beim Zurueckgehen koennen sich alte
// und neue Nummern ueberschneiden (z. B. Phase X hatte 2, soll wieder 1 werden, waehrend
// eine andere noch auf 1 sitzt). Deshalb werden bestehende Phasen zuerst auf negative
// Nummern geparkt und danach auf ihre Zielnummer gesetzt.
// 3. Werte zu inzwischen entfernten Phasen werden verworfen, nicht wiederhergestellt.
export interface RestorePlan {
deletePhaseIds: string[];
deleteElementIds: string[];
parkPhaseIds: string[]; // vor dem Umnummerieren auf negative Werte schieben
createPhaseIds: string[];
updatePhaseIds: string[];
createElementIds: string[];
updateElementIds: string[];
deletePersonRoles: string[];
// Werte je Element, bereits auf die ueberlebenden Phasen gefiltert.
phaseValueKeys: { elementId: string; phaseId: string }[];
transitionValueKeys: { elementId: string; fromPhaseId: string }[];
}
export interface CurrentState {
phaseIds: string[];
elementIds: string[];
personRoles: string[];
}
export function planRestore(snapshot: PlanInput, current: CurrentState): RestorePlan {
const keepPhaseIds = snapshot.phases.map((p) => p.id);
const keepElementIds = snapshot.elements.map((e) => e.id);
const keepRoles = snapshot.persons.map((p) => p.role);
const keepPhaseSet = new Set(keepPhaseIds);
const havePhase = new Set(current.phaseIds);
const haveElement = new Set(current.elementIds);
const phaseValueKeys: { elementId: string; phaseId: string }[] = [];
const transitionValueKeys: { elementId: string; fromPhaseId: string }[] = [];
for (const el of snapshot.elements) {
for (const phaseId of Object.keys(el.phaseValues)) {
// Werte zu einer Phase, die dieser Stand nicht kannte, gehoeren nirgendwohin.
if (keepPhaseSet.has(phaseId)) phaseValueKeys.push({ elementId: el.id, phaseId });
}
for (const fromPhaseId of Object.keys(el.transitionValues)) {
if (keepPhaseSet.has(fromPhaseId)) transitionValueKeys.push({ elementId: el.id, fromPhaseId });
}
}
return {
deletePhaseIds: current.phaseIds.filter((id) => !keepPhaseSet.has(id)),
deleteElementIds: current.elementIds.filter((id) => !keepElementIds.includes(id)),
// Nur die Phasen parken, die bleiben -- die anderen sind vorher schon weg.
parkPhaseIds: current.phaseIds.filter((id) => keepPhaseSet.has(id)),
createPhaseIds: keepPhaseIds.filter((id) => !havePhase.has(id)),
updatePhaseIds: keepPhaseIds.filter((id) => havePhase.has(id)),
createElementIds: keepElementIds.filter((id) => !haveElement.has(id)),
updateElementIds: keepElementIds.filter((id) => haveElement.has(id)),
deletePersonRoles: current.personRoles.filter((r) => !keepRoles.includes(r as never)),
phaseValueKeys,
transitionValueKeys,
};
}