Effektive Werte: Plan-Ist-Vergleich (Roadmap 5)
Deploy App / deploy (push) Successful in 1m57s

Neuer Knopf auf Plan-Ebene: Liste plus Wizard in zwei Schritten. Ein
Ist-Satz haengt am PLAN, nicht am Szenario -- die Zuordnung laeuft ueber
die Herkunfts-Kette sourceElementId.

computePlan nimmt neu { actuals }: Die Werte schnappen in jedem erfassten
Jahr auf die Realitaet und laufen von dort planmaessig weiter. Luecken
fallen auf die Plandaten zurueck. Ohne die Option unveraendert -- die 43
Golden Tests laufen durch.

Der Sprung ist keine Rendite: eigene Brueckenposition actualsCorrection
in Vermoegens- und Cash-Bruecke, sonst ginge die Zerlegung nicht auf.

Matrix: Umschalter Plan/Effektiv, im Ist-Modus mit farbiger Abweichung
statt acht Zahlen je Zelle. Zeitachse: Marker je Jahr, juengster farbig.

Vier Analysewerkzeuge mit einheitlicher Leiste (nominal/real als
Einfachauswahl, Plan/Effektiv). MC: Zielbetrag dreht mit, Startjahr
abgeleitet statt eingebbar.

Neue Tabelle ActualsSet (gegen echtes Postgres verifiziert), Module
actuals.ts und dataview.ts. Spezifikation 0.21, 27 Tests (181 -> 208).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:35:07 +02:00
parent 4343d50aa4
commit e117e5a4e5
25 changed files with 1991 additions and 74 deletions
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
// Löscht einen Ist-Satz. Ein Ist-Satz ist eine Beobachtung, keine Planänderung -- deshalb
// gibt es hier weder Versionierung noch Wiederherstellung.
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; setId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, setId } = await params;
const set = await prisma.actualsSet.findFirst({
where: { id: setId, planId, plan: { userId } },
});
if (!set) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 });
await prisma.actualsSet.delete({ where: { id: setId } });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
// Ein Ist-Wert je Wurzel-Element. Beide Felder optional: Wer eine Zahl nicht kennt, lässt sie
// weg -- die Lücke fällt in der Berechnung auf die Plandaten zurück.
const valueSchema = z.object({
value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(),
mortgage: z.number().min(0).max(1_000_000_000).optional(),
});
const createSchema = z.object({
recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"),
comment: z.string().max(500).optional(),
cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(),
values: z.record(z.string(), valueSchema),
});
async function ownedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({ where: { id: planId, userId } });
}
// Alle Ist-Sätze eines Plans, neueste zuerst.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const rows = await prisma.actualsSet.findMany({
where: { planId },
orderBy: [{ year: "desc" }, { recordedOn: "desc" }],
include: { createdBy: { select: { username: true } } },
});
return NextResponse.json({
sets: rows.map((r) => ({
id: r.id,
recordedOn: r.recordedOn.toISOString().slice(0, 10),
year: r.year,
comment: r.comment,
cash: r.cash,
values: r.values,
author: r.createdBy.username,
createdAt: r.createdAt,
})),
});
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const { recordedOn, comment, cash, values } = parsed.data;
// Für die Berechnung zählt nur die Jahreszahl -- der Rechenkern arbeitet in ganzen Jahren
// ab Planbeginn. Das exakte Datum bleibt für Liste und Zeitachse erhalten.
const year = Number(recordedOn.slice(0, 4));
const created = await prisma.actualsSet.create({
data: {
planId,
recordedOn: new Date(`${recordedOn}T00:00:00.000Z`),
year,
comment: comment?.trim() || null,
cash: typeof cash === "number" ? cash : null,
values,
createdById: userId,
},
});
return NextResponse.json({ set: { id: created.id, year: created.year } }, { status: 201 });
}
@@ -26,10 +26,33 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
if (parent) base = toPlanInput(parent);
}
// Ist-Werte des Plans plus die Element-Herkunft ALLER Szenarien: Nur damit lässt sich die
// auf Wurzel-IDs erfasste Realität auf dieses Szenario abbilden (siehe lib/actuals.ts).
// Die Ist-Rechnung selbst passiert im Browser -- computePlan ist rein.
const [actualsRows, siblings] = await Promise.all([
prisma.actualsSet.findMany({
where: { planId: scenario.planId },
orderBy: [{ year: "asc" }, { recordedOn: "asc" }],
}),
prisma.scenario.findMany({
where: { planId: scenario.planId },
select: { elements: { select: { id: true, sourceElementId: true } } },
}),
]);
return NextResponse.json({
plan: planInput,
computed,
base,
actuals: actualsRows.map((r) => ({
id: r.id,
recordedOn: r.recordedOn.toISOString().slice(0, 10),
year: r.year,
comment: r.comment,
cash: r.cash,
values: r.values,
})),
elementOrigins: siblings.flatMap((s) => s.elements),
meta: {
id: scenario.id,
planId: scenario.planId,
+508
View File
@@ -0,0 +1,508 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, CalendarClock, Plus, Trash2, X } from "lucide-react";
import { InfoBubble } from "@/components/InfoBubble";
import { Button, useConfirm, useToast } from "@/components/ui";
import { api } from "@/lib/api-client";
import { computePlan } from "@/lib/calculations";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
import { formatChf } from "@/lib/format";
import { actualKindOf, resolveActuals, toPlanYear, type ActualsSetInput } from "@/lib/actuals";
import { resolveRootElementId } from "@/lib/montecarlo";
import type { ElementCategory } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
interface StoredSet extends ActualsSetInput {
author: string;
}
// Ein Eingabefeld im Wizard -- je Wurzel-Element eines oder (bei Immobilien) zwei.
interface Row {
rootId: string;
name: string;
category: ElementCategory;
scenarioNames: string[];
planValue: number; // Vorbelegung aus dem Basisszenario für das gewählte Jahr
planMortgage?: number;
kind: ReturnType<typeof actualKindOf>;
}
const dt = (iso: string) =>
new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-CH", { day: "2-digit", month: "long", year: "numeric" });
interface LoadedScenario {
id: string;
name: string;
isBase: boolean;
plan: PlanInput;
}
export function ActualsDialog({
planId,
planName,
scenarioMetas,
initial,
onClose,
onChanged,
}: {
planId: string;
planName: string;
// Alle Szenarien dieses Plans (nur Kopfdaten) -- die Pläne werden hier nachgeladen.
scenarioMetas: { id: string; name: string; isBase: boolean }[];
// Das bereits geöffnete Szenario, damit der Dialog sofort etwas anzeigen kann.
initial: LoadedScenario;
onClose: () => void;
onChanged: () => void;
}) {
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({ [initial.id]: initial }));
// Die übrigen Szenarien nachladen: Ein Ist-Satz gilt für ALLE, also müssen auch Elemente
// erscheinen, die es nur in einem Nebenszenario gibt.
useEffect(() => {
const missing = scenarioMetas.filter((m) => m.id !== initial.id);
if (missing.length === 0) return;
let cancelled = false;
(async () => {
try {
const entries = await Promise.all(
missing.map(async (m) => {
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${m.id}`);
return [m.id, { id: m.id, name: m.name, isBase: m.isBase, plan: data.plan }] as const;
})
);
if (!cancelled) setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
} catch {
// Fehlende Nebenszenarien sind verschmerzbar -- der Wizard zeigt dann weniger Zeilen.
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scenarioMetas.map((m) => m.id).join(","), initial.id]);
const scenarios = useMemo(
() => scenarioMetas.map((m) => loaded[m.id]).filter((s): s is LoadedScenario => !!s),
[scenarioMetas, loaded]
);
const [sets, setSets] = useState<StoredSet[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<"list" | "wizard">("list");
const [step, setStep] = useState<1 | 2>(1);
const [busy, setBusy] = useState(false);
const confirm = useConfirm();
const toast = useToast();
// Schritt 1
const [recordedOn, setRecordedOn] = useState(() => new Date().toISOString().slice(0, 10));
const [comment, setComment] = useState("");
// Schritt 2
const [values, setValues] = useState<Record<string, { value?: number; mortgage?: number }>>({});
const [cash, setCash] = useState<number>(0);
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
const year = Number(recordedOn.slice(0, 4));
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
if (!cancelled) setSets(data.sets);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId]);
// Herkunft aller Elemente über alle Szenarien -- Grundlage der Wurzel-Auflösung.
const origins = useMemo(
() => scenarios.flatMap((s) => s.plan.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }))),
[scenarios]
);
// Alle Elemente ALLER Szenarien, zusammengefasst auf ihre Wurzel. Vorbelegt mit dem
// berechneten Stand des Basisszenarios im gewählten Jahr; fehlt das Element dort, wird es
// aus dem erstbesten Szenario geholt, das es kennt.
const rows: Row[] = useMemo(() => {
const sourceById = new Map(origins.map((o) => [o.id, o.sourceElementId]));
const byRoot = new Map<string, Row>();
const ordered = [base, ...scenarios.filter((s) => s.id !== base?.id)].filter(Boolean);
for (const sc of ordered) {
const planYear = toPlanYear(year, sc.plan.startYear);
const computed = computePlan(sc.plan);
for (const el of sc.plan.elements) {
const kind = actualKindOf(el.category);
const root = resolveRootElementId(el.id, sourceById);
// Jahresstand aus dem Verlauf: genau der Wert, den der Plan für dieses Jahr vorsieht.
const yearly = computed.phases
.flatMap((p) => p.elements.filter((e) => e.elementId === el.id).flatMap((e) => e.yearly))
.find((y) => y.year === planYear);
// Die AHV ist nur erfassbar, wenn die Rente zum Stichtag bereits läuft -- vorher gibt
// es keinen Stand, den man ablesen könnte.
if (el.category === "AHV" && !(yearly && yearly.value > 0)) continue;
const existing = byRoot.get(root);
if (existing) {
if (!existing.scenarioNames.includes(sc.name)) existing.scenarioNames.push(sc.name);
continue;
}
byRoot.set(root, {
rootId: root,
name: el.name,
category: el.category,
scenarioNames: [sc.name],
planValue: Math.round(Math.abs(yearly?.value ?? 0)),
planMortgage: kind === "PROPERTY" ? Math.round(yearly?.mortgage ?? 0) : undefined,
kind,
});
}
}
return [...byRoot.values()].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");
});
}, [scenarios, base, origins, year]);
// Der geplante Cash-Bestand im gewählten Jahr -- Vorbelegung für das Cash-Feld.
const planCash = useMemo(() => {
if (!base) return 0;
const planYear = toPlanYear(year, base.plan.startYear);
if (planYear === null) return 0;
const computed = computePlan(base.plan);
const ph = computed.phases.find(
(p) => planYear <= computed.phases.slice(0, p.sequenceNumber).reduce((s, x) => s + x.durationYears, 0)
);
return Math.round(ph?.cashBridge.cashEnd ?? 0);
}, [base, year]);
function startWizard() {
setValues({});
setCash(planCash);
setComment("");
setStep(1);
setMode("wizard");
}
// Beim Wechsel auf Schritt 2 mit den Planwerten vorbelegen -- der Nutzer überschreibt nur,
// was tatsächlich abweicht.
function goToStep2() {
const prefill: Record<string, { value?: number; mortgage?: number }> = {};
for (const r of rows) {
prefill[r.rootId] =
r.kind === "PROPERTY" ? { value: r.planValue, mortgage: r.planMortgage ?? 0 } : { value: r.planValue };
}
setValues(prefill);
setCash(planCash);
setStep(2);
}
async function reload() {
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
setSets(data.sets);
}
async function save() {
setBusy(true);
try {
await api.post(`/api/plans/${planId}/actuals`, { recordedOn, comment: comment.trim() || undefined, cash, values });
toast("success", `Effektive Werte für ${dt(recordedOn)} erfasst.`);
await reload();
setMode("list");
onChanged();
} catch (e) {
toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
} finally {
setBusy(false);
}
}
async function remove(set: StoredSet) {
const ok = await confirm({
title: "Datensatz löschen?",
message: `Die effektiven Werte vom ${dt(set.recordedOn)} werden entfernt. Der Plan selbst bleibt unverändert.`,
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/plans/${planId}/actuals/${set.id}`);
await reload();
onChanged();
toast("success", "Datensatz gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
// Wie viele Elemente weichen ab? Kleine Orientierungshilfe in der Liste.
const deviationCount = (set: ActualsSetInput) => {
const resolved = resolveActuals([set], base?.plan ?? scenarios[0].plan, origins);
return resolved.length === 0 ? 0 : Object.keys(resolved[0].byElementId).length;
};
const setRow = (rootId: string, patch: { value?: number; mortgage?: number }) =>
setValues((prev) => ({ ...prev, [rootId]: { ...prev[rootId], ...patch } }));
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-4xl 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">
<CalendarClock className="h-5 w-5 text-accent" /> Effektive Werte
</h2>
<p className="mt-0.5 text-xs text-muted">
Plan «{planName}». Was tatsächlich eingetreten ist der Plan selbst bleibt unverändert.
</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>
{mode === "list" && (
<>
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Ein Datensatz hält fest, wie es an einem Stichtag <strong className="text-fg">wirklich</strong> aussah.
Die Berechnung läuft dann ein zweites Mal: gleiche Mechanik, aber ab jedem erfassten Jahr mit den
echten Zahlen. Werte, die du weglässt, laufen unverändert auf ihrer Planlinie weiter.
Ein Datensatz gilt für <strong className="text-fg">alle Szenarien</strong> dieses Plans die
Wirklichkeit ist dieselbe, egal wogegen man sie hält.
</div>
{error && <p className="text-xs text-danger">{error}</p>}
{!sets && !error && <p className="text-xs text-muted">Wird geladen</p>}
{sets && sets.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Noch keine effektiven Werte erfasst.
</p>
)}
{sets && sets.length > 0 && (
<div className="flex flex-col gap-2">
{sets.map((s, i) => (
<div
key={s.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
i === 0 ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-fg">
{dt(s.recordedOn)}
{i === 0 && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">
aktuellster
</span>
)}
</div>
<div className="text-[11px] text-muted">
rechnet ab {s.year} · {deviationCount(s)} Werte · {s.author}
{typeof s.cash === "number" && ` · Cash ${formatChf(s.cash)}`}
</div>
{s.comment && <div className="mt-0.5 truncate text-xs text-fg">«{s.comment}»</div>}
</div>
<button
type="button"
onClick={() => remove(s)}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" /> Löschen
</button>
</div>
))}
</div>
)}
<div>
<Button onClick={startWizard}>
<Plus className="h-4 w-4" /> Effektive Werte erfassen
</Button>
</div>
</>
)}
{mode === "wizard" && step === 1 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">Schritt 1 von 2 · Stichtag</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Datum der Erfassung
<InfoBubble text="Das exakte Datum erscheint in der Liste und auf der Zeitachse. Für die Berechnung zählt nur die Jahreszahl der Rechenkern arbeitet in ganzen Jahren ab Planbeginn." />
</label>
<input
type="date"
value={recordedOn}
onChange={(e) => setRecordedOn(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Notiz (optional)</label>
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="z. B. «nach Jahresabschluss»"
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
</div>
<p className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
Gerechnet wird ab dem Jahr <strong className="text-fg">{year}</strong>. Die Vorbelegung im nächsten
Schritt zeigt, was dein Plan für dieses Jahr vorsieht du überschreibst nur, was tatsächlich anders ist.
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setMode("list")}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button onClick={goToStep2}>
Weiter <ArrowRight className="h-4 w-4" />
</Button>
</div>
</>
)}
{mode === "wizard" && step === 2 && (
<>
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
Schritt 2 von 2 · Werte per {dt(recordedOn)}
</div>
<div className="max-h-[52vh] overflow-y-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-surface-2">
<tr className="text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Element</th>
<th className="px-3 py-2 text-right font-semibold">Laut Plan {year}</th>
<th className="px-3 py-2 text-right font-semibold">Effektiv</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-border bg-surface-2/60">
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
Cash
</td>
</tr>
<tr className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">Cash-Konto</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(planCash)}</td>
<td className="px-3 py-2">
<NumInput value={cash} onChange={setCash} />
</td>
</tr>
{rows.map((r, i) => {
const header = i === 0 || rows[i - 1].category !== r.category ? r.category : null;
const v = values[r.rootId] ?? {};
return (
<FragmentRow
key={r.rootId}
header={header}
row={r}
value={v}
onChange={(patch) => setRow(r.rootId, patch)}
/>
);
})}
</tbody>
</table>
</div>
<p className="text-[11px] text-faint">
Einkommen und Ausgaben bitte als <strong>Jahresbetrag</strong> erfassen (nominal, wie tatsächlich
geflossen). Bei Immobilien zählt der Verkehrswert und die Restschuld getrennt. Was du auf dem
Planwert stehen lässt, wird als «keine Abweichung» gewertet.
</p>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setStep(1)}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button disabled={busy} onClick={save}>
{busy ? "Speichern…" : "Speichern"}
</Button>
</div>
</>
)}
</div>
</div>
);
}
function FragmentRow({
header,
row,
value,
onChange,
}: {
header: ElementCategory | null;
row: Row;
value: { value?: number; mortgage?: number };
onChange: (patch: { value?: number; mortgage?: number }) => void;
}) {
return (
<>
{header && (
<tr className="border-t border-border bg-surface-2/60">
<td colSpan={3} className="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="px-3 py-2">
<div className="font-medium text-fg">{row.name}</div>
{row.scenarioNames.length > 1 && (
<div className="text-[10px] text-faint">gilt für {row.scenarioNames.join(", ")}</div>
)}
{row.kind === "FLOW" && <div className="text-[10px] text-faint">Jahresbetrag</div>}
</td>
<td className="px-3 py-2 text-right text-muted">
{formatChf(row.planValue)}
{row.kind === "PROPERTY" && (
<div className="text-[11px] text-faint">Hypothek {formatChf(row.planMortgage ?? 0)}</div>
)}
</td>
<td className="px-3 py-2">
<NumInput value={value.value ?? 0} onChange={(v) => onChange({ value: v })} />
{row.kind === "PROPERTY" && (
<div className="mt-1">
<NumInput
value={value.mortgage ?? 0}
onChange={(v) => onChange({ mortgage: v })}
label="Hypothek"
/>
</div>
)}
</td>
</tr>
</>
);
}
function NumInput({ value, onChange, label }: { value: number; onChange: (v: number) => void; label?: string }) {
return (
<div className="flex items-center justify-end gap-1">
{label && <span className="text-[10px] text-faint">{label}</span>}
<input
type="number"
value={Number.isFinite(value) ? value : 0}
onChange={(e) => onChange(Math.round(Number(e.target.value) || 0))}
className="w-32 rounded border border-border bg-surface px-2 py-1 text-right text-sm text-fg"
/>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useMemo, useState } from "react";
import { InfoBubble } from "@/components/InfoBubble";
import { computePlan } from "@/lib/calculations";
import {
resolveActuals,
latestPlanYear,
type ActualsSetInput,
type ElementOrigin,
type ResolvedActuals,
} from "@/lib/actuals";
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// In den Analysewerkzeugen ist nominal/real eine EINFACHauswahl, kein "beide".
//
// Grund: Der Vermögensverlauf zeichnet je Serie ohnehin schon eine Linie; mit "beide" wären
// es zwei, mit Plan/Ist vier und bei zwei Szenarien acht. Das Stilbudget wird stattdessen
// für Plan (gestrichelt) gegen Ist (durchgezogen) ausgegeben -- das ist der Vergleich, um
// den es geht (siehe SPEZIFIKATION 9.29).
export type Metric = "nominal" | "real";
export const METRIC_OPTIONS: { value: Metric; label: string }[] = [
{ value: "nominal", label: "Nominal" },
{ value: "real", label: "Real" },
];
export interface AnalysisBasis {
metric: Metric;
setMetric: (m: Metric) => void;
source: DataSource;
setSource: (s: DataSource) => void;
// Die für die gewählte Quelle massgebende Rechnung.
computed: PlanComputed;
// Immer die reine Plan-Rechnung -- Referenzlinie in den Grafiken.
planComputed: PlanComputed;
// Ist-Rechnung, null solange nichts erfasst ist.
actualComputed: PlanComputed | null;
hasActuals: boolean;
// Auf dieses Szenario aufgeloeste Ist-Saetze -- fuer Werkzeuge, die selbst rechnen.
resolvedActuals: ResolvedActuals[];
// Erstes Jahr, das nicht mehr durch Ist-Werte belegt ist (Monte-Carlo-Startpunkt).
simStartPlanYear: number;
simStartCalendarYear: number | null;
}
export function useAnalysisBasis(
plan: PlanInput,
actuals: ActualsSetInput[],
origins: ElementOrigin[],
initialMetric: Metric = "nominal"
): AnalysisBasis {
const [metric, setMetric] = useState<Metric>(initialMetric);
const [source, setSource] = useState<DataSource>("PLAN");
const resolved = useMemo(() => resolveActuals(actuals, plan, origins), [actuals, plan, origins]);
const planComputed = useMemo(() => computePlan(plan), [plan]);
const actualComputed = useMemo(
() => (resolved.length === 0 ? null : computePlan(plan, undefined, { actuals: resolved })),
[plan, resolved]
);
const hasActuals = actualComputed !== null;
const effectiveSource: DataSource = hasActuals ? source : "PLAN";
const latest = latestPlanYear(resolved);
return {
metric,
setMetric,
resolvedActuals: effectiveSource === "ACTUAL" ? resolved : [],
source: effectiveSource,
setSource,
computed: effectiveSource === "ACTUAL" && actualComputed ? actualComputed : planComputed,
planComputed,
actualComputed,
hasActuals,
// Im Ist-Modus beginnt die Simulation NACH dem jüngsten erfassten Jahr: Was erfasst ist,
// ist bekannt und darf nicht gewürfelt werden.
simStartPlanYear: effectiveSource === "ACTUAL" && latest !== null ? latest + 1 : 1,
simStartCalendarYear:
plan.startYear == null
? null
: plan.startYear + (effectiveSource === "ACTUAL" && latest !== null ? latest : 0),
};
}
// Einheitliche Leiste für alle vier Werkzeuge, damit die Bedienung überall dieselbe ist.
export function AnalysisBar({
basis,
onChange,
}: {
basis: AnalysisBasis;
// Wird nach jeder Umstellung gerufen -- die Werkzeuge verwerfen damit alte Ergebnisse.
onChange?: () => void;
}) {
const pick = (fn: () => void) => () => {
fn();
onChange?.();
};
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Werte</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{METRIC_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={pick(() => basis.setMetric(o.value))}
className={`rounded-md px-2 py-0.5 font-medium ${
basis.metric === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{o.label}
</button>
))}
</div>
<InfoBubble text="Nominal = Frankenbeträge im jeweiligen Jahr. Real = kaufkraftbereinigt auf den Planbeginn. In den Grafiken ist das bewusst eine Entweder-oder-Wahl: Beides gleichzeitig verdoppelt die Linien." />
</label>
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Grundlage</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
disabled={o.value === "ACTUAL" && !basis.hasActuals}
onClick={pick(() => basis.setSource(o.value))}
title={
o.value === "ACTUAL" && !basis.hasActuals
? "Für diesen Plan sind noch keine effektiven Werte erfasst."
: o.label
}
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
basis.source === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{o.short}
</button>
))}
</div>
<InfoBubble text="Planzahlen rechnen rein mit deinen Annahmen. Effektiv rechnet dieselbe Mechanik, springt aber in jedem Jahr, für das du Ist-Werte erfasst hast, auf die Realität." />
</label>
</div>
);
}
+50 -2
View File
@@ -8,6 +8,7 @@ import {
Dices,
FileText,
FolderKanban,
CalendarClock,
GitBranch,
History,
LayoutDashboard,
@@ -26,6 +27,9 @@ import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { LiveSimDialog } from "@/components/LiveSimDialog";
import { ActualsDialog } from "@/components/ActualsDialog";
import { buildViews } from "@/lib/dataview";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView";
@@ -56,6 +60,10 @@ interface ScenarioDetail {
computed: PlanComputed;
base: PlanInput | null; // Eltern-Szenario als Vergleichsbasis
meta: ScenarioMeta & { planName: string };
// Effektive Werte des PLANS plus die Element-Herkunft aller Szenarien -- daraus entsteht
// im Browser der zweite Rechenlauf (siehe lib/dataview.ts).
actuals: ActualsSetInput[];
elementOrigins: ElementOrigin[];
}
// Die Provider (Toast, Bestätigung) müssen UM die Shell liegen, damit deren Hooks
@@ -90,6 +98,7 @@ function AppShellInner({ username }: { username: string }) {
const [showSensitivity, setShowSensitivity] = useState(false);
const [showLiveSim, setShowLiveSim] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [showActuals, setShowActuals] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false);
const [showPalette, setShowPalette] = useState(false);
@@ -210,6 +219,14 @@ function AppShellInner({ username }: { username: string }) {
const activePlan = plans.find((p) => p.scenarios.some((s) => s.id === selectedScenarioId)) ?? null;
// Plan-Sicht und Ist-Sicht in einem Zug. Ohne erfasste Ist-Werte bleibt `actual` null und
// die Oberflaeche verhaelt sich exakt wie bisher.
const views = useMemo(
() =>
detail ? buildViews(detail.plan, detail.actuals ?? [], detail.elementOrigins ?? []) : null,
[detail]
);
// Aktionen der Befehls-Palette -- kontextabhängig (Analysen nur bei offenem Szenario).
const paletteActions = useMemo<PaletteAction[]>(() => {
const base: PaletteAction[] = [
@@ -426,6 +443,10 @@ function AppShellInner({ username }: { username: string }) {
<BarChart3 className="h-4 w-4" />
Grafiken
</Button>
<Button variant="secondary" onClick={() => setShowActuals(true)}>
<CalendarClock className="h-4 w-4" />
Effektive Werte
</Button>
<Button variant="secondary" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4" />
Änderungshistorie
@@ -465,6 +486,8 @@ function AppShellInner({ username }: { username: string }) {
<PlanView
plan={detail.plan}
computed={detail.computed}
actualComputed={views?.actual ?? null}
actualYears={views?.actualYears ?? []}
diff={diff}
onChanged={refreshCurrent}
onOpenSpec={openSpecAt}
@@ -525,6 +548,8 @@ function AppShellInner({ username }: { username: string }) {
plan={detail.plan}
computed={detail.computed}
siblings={(activePlan?.scenarios ?? []).filter((s) => s.id !== detail.meta.id)}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
/>
</ChartsDialog>
)}
@@ -535,16 +560,39 @@ function AppShellInner({ username }: { username: string }) {
computed={detail.computed}
meta={detail.meta}
scenarios={activePlan?.scenarios ?? [detail.meta]}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
onClose={() => setShowMonteCarlo(false)}
/>
)}
{showSensitivity && detail && (
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
<SensitivityDialog
plan={detail.plan}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
onClose={() => setShowSensitivity(false)}
/>
)}
{showLiveSim && detail && (
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
<LiveSimDialog
plan={detail.plan}
actuals={detail.actuals ?? []}
origins={detail.elementOrigins ?? []}
onClose={() => setShowLiveSim(false)}
/>
)}
{showActuals && detail && activePlan && (
<ActualsDialog
planId={detail.meta.planId}
planName={detail.meta.planName}
scenarioMetas={activePlan.scenarios}
initial={{ id: detail.meta.id, name: detail.meta.name, isBase: detail.meta.isBase, plan: detail.plan }}
onClose={() => setShowActuals(false)}
onChanged={refreshCurrent}
/>
)}
{showHistory && detail && (
+27 -7
View File
@@ -4,7 +4,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 { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client";
@@ -21,20 +22,26 @@ export function Dashboard({
plan: currentPlan,
computed: currentComputed,
siblings,
actuals = [],
origins = [],
}: {
plan: PlanInput;
computed: PlanComputed;
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
siblings: PlanListItem[];
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
}) {
// 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 basis = useAnalysisBasis(plan, actuals, origins);
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
const computed = useMemo(
() => (versionId === "current" ? currentComputed : computePlan(plan)),
[versionId, currentComputed, plan]
() => (versionId === "current" && basis.source === "PLAN" ? currentComputed : basis.computed),
[versionId, currentComputed, basis.source, basis.computed]
);
const [compareIds, setCompareIds] = useState<string[]>([]);
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
@@ -52,13 +59,24 @@ export function Dashboard({
const series: TimelineSeries[] = useMemo(() => {
const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
// Im Ist-Modus die reine Planlinie als gestrichelte Referenz daneben -- ohne Anker sagt
// eine Ist-Linie allein nichts aus.
if (basis.source === "ACTUAL") {
result.push({
label: `${plan.name} (Plan)`,
color: CHART_PALETTE[0],
computed: basis.planComputed,
dashed: true,
});
}
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
});
return result;
}, [plan.name, computed, compareIds, compareData, siblings]);
// Vier Serien sind die Grenze der Lesbarkeit -- darüber hilft keine Farbpalette mehr.
return result.slice(0, 4);
}, [plan.name, computed, compareIds, compareData, siblings, basis.source, basis.planComputed]);
const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1];
@@ -72,6 +90,8 @@ export function Dashboard({
loading={versionLoading}
/>
<AnalysisBar basis={basis} />
<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} />
@@ -125,7 +145,7 @@ export function Dashboard({
))}
</div>
)}
<WealthChart series={series} />
<WealthChart series={series} metric={basis.metric} />
</section>
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
+33 -7
View File
@@ -7,7 +7,9 @@ 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 { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { formatChf } from "@/lib/format";
import {
buildSliders,
@@ -44,9 +46,20 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
export function LiveSimDialog({
plan: currentPlan,
actuals = [],
origins = [],
onClose,
}: {
plan: PlanInput;
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
onClose: () => void;
}) {
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
const basis = useAnalysisBasis(plan, actuals, origins);
const [expandReturns, setExpandReturns] = useState(false);
const [values, setValues] = useState<SliderValues>({});
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
@@ -64,12 +77,15 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
const base = useMemo(() => {
const computed = computePlan(plan);
const computed = basis.computed;
return { computed, kpis: kpisOf(computed) };
}, [plan]);
}, [basis.computed]);
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
const live = useMemo(() => runLive(plan, sliders, values), [plan, sliders, values]);
const live = useMemo(
() => runLive(plan, sliders, values, basis.resolvedActuals),
[plan, sliders, values, basis.resolvedActuals]
);
const neutral = isNeutral(sliders, values);
const settings = describeSettings(sliders, values);
@@ -121,6 +137,8 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
loading={versionLoading}
/>
<AnalysisBar basis={basis} onChange={() => setValues({})} />
<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
@@ -129,8 +147,16 @@ export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput;
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DeltaCard label="Endvermögen (nominal)" base={base.kpis.endNominal} live={live.kpis.endNominal} />
<DeltaCard label="Endvermögen (real)" base={base.kpis.endReal} live={live.kpis.endReal} />
<DeltaCard
label={basis.metric === "real" ? "Endvermögen (real)" : "Endvermögen (nominal)"}
base={basis.metric === "real" ? base.kpis.endReal : base.kpis.endNominal}
live={basis.metric === "real" ? live.kpis.endReal : live.kpis.endNominal}
/>
<DeltaCard
label={basis.metric === "real" ? "Endvermögen (nominal)" : "Endvermögen (real)"}
base={basis.metric === "real" ? base.kpis.endNominal : base.kpis.endReal}
live={basis.metric === "real" ? live.kpis.endNominal : live.kpis.endReal}
/>
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
</div>
+31 -2
View File
@@ -6,6 +6,8 @@ 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 { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { computePlan } from "@/lib/calculations";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
@@ -111,11 +113,15 @@ export function MonteCarloDialog({
computed,
meta,
scenarios,
actuals = [],
origins = [],
onClose,
}: {
plan: PlanInput;
computed: PlanComputed;
meta: ScenarioMeta;
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
scenarios: ScenarioMeta[];
@@ -124,6 +130,7 @@ export function MonteCarloDialog({
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
[meta.id]: { id: meta.id, name: meta.name, plan, computed },
}));
const basis = useAnalysisBasis(plan, actuals, origins);
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
@@ -349,6 +356,18 @@ export function MonteCarloDialog({
</button>
</div>
<AnalysisBar basis={basis} onChange={clearResults} />
{/* Startpunkt: bewusst abgeleitet statt eingebbar. Ein frei gesetztes Jahr würde
Jahre als sicher behandeln, die nie erfasst wurden. */}
<p className="text-[11px] text-muted">
{basis.source === "ACTUAL" && basis.simStartCalendarYear
? `Simuliert ab ${basis.simStartCalendarYear}. Die Jahre davor sind durch deine effektiven Werte belegt und werden nicht gewürfelt.`
: plan.startYear
? `Simuliert ab ${plan.startYear} (Planbeginn).`
: "Simuliert ab Planbeginn."}
</p>
{/* Erklärung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
@@ -419,8 +438,18 @@ export function MonteCarloDialog({
{/* Zielbetrag */}
<div className="sm:max-w-xs">
<MoneyField
label="Zielbetrag (Endvermögen)"
help="Dein frei gewähltes Ziel, nominal. Geprüft wird, wie oft es in beiden Welten mindestens erreicht wird. Gilt für alle ausgewählten Szenarien."
// Das Feld dreht mit der gewählten Grösse mit -- sonst prüft man einen nominalen
// Zielbetrag gegen ein reales Endvermögen und vergleicht Äpfel mit Birnen.
label={
basis.metric === "real"
? "Zielbetrag (Endvermögen, REAL)"
: "Zielbetrag (Endvermögen, NOMINAL)"
}
help={
basis.metric === "real"
? "Dein frei gewähltes Ziel in HEUTIGER Kaufkraft. Geprüft wird gegen das reale Endvermögen."
: "Dein frei gewähltes Ziel in Franken des Zieljahres (nominal). Geprüft wird gegen das nominale Endvermögen."
}
value={manualTarget}
onChange={(v) => {
setManualTarget(v);
+78 -4
View File
@@ -46,6 +46,7 @@ import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFi
import { MoneyField } from "@/components/FormField";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
import {
CATEGORY_LABELS,
CATEGORY_ORDER,
@@ -107,7 +108,9 @@ type Panel =
export function PlanView({
plan,
computed,
computed: planComputed,
actualComputed = null,
actualYears = [],
diff,
onChanged,
onOpenSpec,
@@ -115,12 +118,32 @@ export function PlanView({
}: {
plan: PlanInput;
computed: PlanComputed;
// Zweiter Rechenlauf mit den effektiven Werten; null, wenn keine erfasst sind.
actualComputed?: PlanComputed | null;
// Kalenderjahre mit Ist-Satz (Marker auf der Zeitachse).
actualYears?: number[];
// Abweichungen gegenüber dem Eltern-Szenario; null im Basisszenario (nichts zu markieren).
diff: ScenarioDiff | null;
onChanged: () => void;
onOpenSpec?: (anchor: string) => void;
onOpenSensitivity?: () => void;
}) {
// Planzahlen oder effektive Zahlen (inkl. Abweichung). Bewusst zwei Möglichkeiten statt
// dreier: Plan UND Ist als Rohwerte nebeneinander wären mit nominal/real acht Zahlen je
// Zelle (siehe SPEZIFIKATION 9.29).
const [dataSource, setDataSource] = useState<DataSource>("PLAN");
const hasActuals = actualComputed !== null;
const computed = dataSource === "ACTUAL" && actualComputed ? actualComputed : planComputed;
// Planwert derselben Zelle -- Grundlage der Abweichung. In der Plan-Sicht null, dann zeigt
// die Zelle gar keine Abweichung an (statt eine von 0 zu behaupten).
const showDeviation = dataSource === "ACTUAL" && actualComputed !== null;
const planEndOf = (elementId: string | undefined, phaseId: string): number | null => {
if (!showDeviation || !elementId) return null;
const ph = planComputed.phases.find((p) => p.id === phaseId);
const el = ph?.elements.find((e) => e.elementId === elementId);
return el ? el.endValue : null;
};
const confirmDialog = useConfirm();
const toast = useToast();
// Markierungs-Klassen: geändert = gelb, neu = grün, entfernt = grau.
@@ -366,6 +389,32 @@ export function PlanView({
))}
</div>
<span className="text-[11px] text-faint">real = kaufkraftbereinigt (Planbeginn)</span>
{/* Zweite Achse: Datenquelle. Erscheint nur, wenn es überhaupt Ist-Werte gibt --
sonst wäre es ein Umschalter ohne Gegenstück. */}
{hasActuals && (
<>
<span className="ml-2 text-xs text-muted">Zahlen</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5 text-xs">
{DATA_SOURCE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setDataSource(o.value)}
title={o.label}
className={`rounded-md px-2.5 py-1 font-medium ${
dataSource === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
}`}
>
{o.short}
</button>
))}
</div>
{dataSource === "ACTUAL" && (
<span className="text-[11px] text-faint">Abweichung gegenüber Plan farbig</span>
)}
</>
)}
</div>
<div data-tour="timeline">
@@ -373,6 +422,7 @@ export function PlanView({
phases={computed.phases}
persons={personAxes}
ruinAge={computed.ruinAge}
actualYears={actualYears}
startYear={plan.startYear}
/>
</div>
@@ -629,7 +679,7 @@ export function PlanView({
ce?.locked ? "text-faint" : "text-fg"
} ${cellDiff(el.id, col.phase.id)}`}
>
{phaseCellContent(ce, col.phase, valueMode)}
{phaseCellContent(ce, col.phase, valueMode, planEndOf(ce?.elementId, col.phase.id))}
</td>
);
}
@@ -992,12 +1042,16 @@ function ValuePair({
deflatorStart,
deflatorEnd,
mode,
devEnd,
}: {
start: number;
end: number;
deflatorStart: number;
deflatorEnd: number;
mode: ValueMode;
// Abweichung des ENDwerts gegenüber dem Plan (nur in der Ist-Sicht gesetzt). Bewusst nur
// ein Wert statt Start und Ende: Die Zelle soll nicht zur zweiten Tabelle werden.
devEnd?: number | null;
}) {
const arrow = <span className="text-faint"></span>;
return (
@@ -1005,16 +1059,31 @@ function ValuePair({
<span className="whitespace-nowrap tabular-nums">
{mode === "real" ? formatChf(realOf(start, deflatorStart)) : formatChf(start)} {arrow}{" "}
{mode === "real" ? formatChf(realOf(end, deflatorEnd)) : formatChf(end)}
<Deviation value={devEnd} deflator={mode === "real" ? deflatorEnd : 1} />
</span>
{mode === "both" && (
<span className="whitespace-nowrap tabular-nums text-faint">
({formatChf(realOf(start, deflatorStart))}) {arrow} ({formatChf(realOf(end, deflatorEnd))})
<Deviation value={devEnd} deflator={deflatorEnd} />
</span>
)}
</span>
);
}
// Die Abweichung trägt als einziges Element Farbe -- würde man die Beträge selbst einfärben,
// entstünde ein Ampelteppich, in dem nichts mehr heraussticht.
function Deviation({ value, deflator = 1 }: { value?: number | null; deflator?: number }) {
if (value == null || Math.round(value / (deflator || 1)) === 0) return null;
const v = Math.round(value / (deflator || 1));
return (
<span className={`ml-1.5 font-semibold ${v > 0 ? "text-success" : "text-danger"}`}>
{v > 0 ? "▲ +" : "▼ "}
{formatChf(Math.abs(v))}
</span>
);
}
// Einzelwert, gleiche Konvention.
function ValueSingle({ value, deflator, mode }: { value: number; deflator: number; mode: ValueMode }) {
return (
@@ -1032,15 +1101,20 @@ function ValueSingle({ value, deflator, mode }: { value: number; deflator: numbe
function phaseCellContent(
ce: ReturnType<PhaseComputed["elements"]["find"]> | undefined,
phase: PhaseComputed,
mode: ValueMode
mode: ValueMode,
// Endwert desselben Elements in derselben Phase laut PLAN -- null in der Plan-Sicht.
planEnd?: number | null
): React.ReactNode {
if (!ce) return "";
if (ce.note) return ce.note;
const isFlow = ce.category === "INCOME" || ce.category === "EXPENSE";
const dS = phase.cumulativeInflationStart;
const dE = isFlow ? phase.flowDeflatorEnd : phase.cumulativeInflationEnd;
const devEnd = planEnd == null ? null : ce.endValue - planEnd;
if (START_END_CATEGORIES.includes(ce.category) && (ce.startValue !== 0 || ce.endValue !== 0)) {
return <ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} />;
return (
<ValuePair start={ce.startValue} end={ce.endValue} deflatorStart={dS} deflatorEnd={dE} mode={mode} devEnd={devEnd} />
);
}
if ((ce.category === "AHV" || ce.category === "PENSION_FUND") && ce.startValue !== 0) {
return (
+22 -20
View File
@@ -3,9 +3,11 @@
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Tornado, X } from "lucide-react";
import { RequiredNumberField, SelectField } from "@/components/FormField";
import { RequiredNumberField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { formatChf } from "@/lib/format";
import {
computeTornado,
@@ -35,12 +37,23 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
return `${sign(low)} ${suffix}${sign(high)} ${suffix}`;
}
export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
export function SensitivityDialog({
plan: currentPlan,
actuals = [],
origins = [],
onClose,
}: {
plan: PlanInput;
actuals?: ActualsSetInput[];
origins?: ElementOrigin[];
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");
const basis = useAnalysisBasis(plan, actuals, origins, "real");
const metric: TornadoMetric = basis.metric;
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
const [result, setResult] = useState<TornadoResult | null>(null);
@@ -75,7 +88,10 @@ export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanIn
checked.map((d) => {
const dr = draftFor(d.id);
return { id: d.id, low: Number(dr.low), high: Number(dr.high) };
})
}),
// Mit Ist-Werten wirken die Treiber nur noch auf die NICHT belegten Jahre -- was
// erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus.
basis.resolvedActuals
)
);
}
@@ -143,22 +159,8 @@ export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanIn
</p>
</div>
{/* Zielgrösse */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SelectField
label="Zielgrösse"
help="Woran die Wirkung gemessen wird: das Endvermögen der letzten Lebensphase. «Real» ist kaufkraftbereinigt auf den Planbeginn und damit die ehrlichere Grösse."
value={metric}
onChange={(v: TornadoMetric) => {
setMetric(v);
setResult(null);
}}
options={[
{ value: "real", label: "Endvermögen real (kaufkraftbereinigt)" },
{ value: "nominal", label: "Endvermögen nominal" },
]}
/>
</div>
{/* Zielgrösse und Datengrundlage */}
<AnalysisBar basis={basis} onChange={() => setResult(null)} />
{/* Parameter */}
<div>
+41 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { Flag } from "lucide-react";
import { CalendarCheck, Flag } from "lucide-react";
import type { PhaseComputed } from "@/lib/calculations";
interface PersonAxis {
@@ -19,11 +19,15 @@ export function Timeline({
persons,
ruinAge,
startYear,
actualYears = [],
}: {
phases: PhaseComputed[];
persons: PersonAxis[];
ruinAge?: number | null;
startYear?: number | null;
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
actualYears?: number[];
}) {
if (phases.length === 0 || persons.length === 0) return null;
@@ -91,6 +95,42 @@ export function Timeline({
</div>
)}
{/* Marker für erfasste effektive Werte. Nur mit bekanntem Planstartjahr platzierbar --
ohne Kalenderbezug gäbe es keine Position auf der Achse. */}
{startYear &&
actualYears.map((y) => {
const age = minAge + (y - startYear);
if (age < minAge || age > maxAge) return null;
const isLatest = y === actualYears[actualYears.length - 1];
return (
<div
key={y}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
style={{ left: pct(age), opacity: isLatest ? 1 : 0.35 }}
title={
isLatest
? `Effektive Werte erfasst für ${y} (aktuellster Stand)`
: `Effektive Werte erfasst für ${y} (überholt)`
}
>
<CalendarCheck
className="h-3.5 w-3.5"
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
/>
<span
className="whitespace-nowrap text-[10px] font-medium"
style={{ color: isLatest ? "var(--attention)" : "var(--muted)" }}
>
{y}
</span>
<div
className="mt-0.5 h-3 w-px"
style={{ backgroundColor: isLatest ? "var(--attention)" : "var(--muted)" }}
/>
</div>
);
})}
{/* Phasen-Segmente: Breite proportional zur Dauer, Einfärbung nach Phasentyp. */}
<div className="flex w-full overflow-hidden rounded-lg border border-border">
{segments.map((s, i) => {
+15 -17
View File
@@ -17,6 +17,9 @@ export interface TimelineSeries {
label: string;
color: string;
computed: PlanComputed;
// Plandaten werden gestrichelt gezeichnet, effektive Daten durchgezogen. Das Stilbudget
// geht bewusst an Plan/Ist statt an nominal/real (siehe SPEZIFIKATION 9.29).
dashed?: boolean;
}
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
@@ -35,7 +38,13 @@ function pointsFor(computed: PlanComputed) {
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
// überlagerte Pläne für den Szenario-Vergleich.
export function WealthChart({ series }: { series: TimelineSeries[] }) {
export function WealthChart({
series,
metric = "nominal",
}: {
series: TimelineSeries[];
metric?: "nominal" | "real";
}) {
if (series.length === 0 || series[0].computed.phases.length === 0) {
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
}
@@ -47,8 +56,7 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
const row: Record<string, number | null> = { age };
for (const s of withPoints) {
const pt = s.points.find((p) => p.age === age);
row[`${s.label} (nominal)`] = pt ? pt.nominal : null;
row[`${s.label} (real)`] = pt ? pt.real : null;
row[s.label] = pt ? (metric === "real" ? pt.real : pt.nominal) : null;
}
return row;
});
@@ -76,25 +84,15 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
<Legend wrapperStyle={{ fontSize: 12 }} />
{withPoints.map((s) => (
<Line
key={`${s.label}-nominal`}
key={s.label}
type="monotone"
dataKey={`${s.label} (nominal)`}
dataKey={s.label}
stroke={s.color}
strokeWidth={2}
strokeDasharray={s.dashed ? "5 3" : undefined}
dot={false}
connectNulls
/>
))}
{withPoints.map((s) => (
<Line
key={`${s.label}-real`}
type="monotone"
dataKey={`${s.label} (real)`}
stroke={s.color}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
+239
View File
@@ -0,0 +1,239 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import {
actualKindOf,
actualsForYear,
latestPlanYear,
rebaseFlow,
resolveActuals,
toPlanYear,
type ActualsSetInput,
} from "@/lib/actuals";
import type { PlanInput } from "@/lib/types";
// Plan ab 2020: ein Fonds mit 100'000 und 5 % Rendite, keine Zu-/Abflüsse. Das ist bewusst
// das Beispiel aus der Anforderung, nur in ganzen Franken.
function fundPlan(): PlanInput {
return {
id: "s",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2020,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
elements: [
{
id: "fonds",
category: "OTHER_ASSET",
name: "Fonds",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } },
transitionValues: {},
sourceElementId: null,
},
],
} as unknown as PlanInput;
}
const valueAt = (computed: ReturnType<typeof computePlan>, elementId: string, year: number) =>
computed.phases
.flatMap((p) => p.elements.filter((e) => e.elementId === elementId).flatMap((e) => e.yearly))
.find((y) => y.year === year)?.value ?? null;
const setAt = (year: number, values: Record<string, { value?: number; mortgage?: number }>, cash?: number): ActualsSetInput => ({
id: `set-${year}`,
recordedOn: `${year}-08-18`,
year,
cash: cash ?? null,
values,
});
describe("toPlanYear", () => {
it("rechnet das Kalenderjahr auf das Planjahr um", () => {
expect(toPlanYear(2020, 2020)).toBe(1);
expect(toPlanYear(2026, 2020)).toBe(7);
});
it("liefert nichts ohne Planstartjahr", () => {
expect(toPlanYear(2026, null)).toBeNull();
});
});
describe("actualKindOf", () => {
it("trennt Bestände von Flüssen", () => {
expect(actualKindOf("OTHER_ASSET")).toBe("STOCK");
expect(actualKindOf("PENSION_FUND")).toBe("STOCK");
expect(actualKindOf("REAL_ESTATE")).toBe("PROPERTY");
expect(actualKindOf("INCOME")).toBe("FLOW");
expect(actualKindOf("EXPENSE")).toBe("FLOW");
});
});
describe("resolveActuals", () => {
const plan = fundPlan();
it("bildet Wurzel-IDs auf die Elemente des Szenarios ab", () => {
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
expect(res).toHaveLength(1);
expect(res[0].planYear).toBe(3);
expect(res[0].byElementId.fonds.value).toBe(120000);
});
it("findet das Element auch über eine Kopie-Kette hinweg", () => {
// Kind-Szenario: eigene Element-ID, zeigt über sourceElementId auf das Original.
const child: PlanInput = {
...plan,
id: "child",
elements: [{ ...plan.elements[0], id: "fonds-kopie", sourceElementId: "fonds" }],
} as unknown as PlanInput;
// Der Ist-Satz ist auf die WURZEL-ID erfasst -- er muss trotzdem greifen.
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], child, [...plan.elements, ...child.elements]);
expect(res[0].byElementId["fonds-kopie"].value).toBe(120000);
});
it("verwirft Sätze ausserhalb des Planzeitraums", () => {
const res = resolveActuals(
[setAt(2019, { fonds: { value: 1 } }), setAt(2099, { fonds: { value: 1 } })],
plan,
plan.elements
);
expect(res).toEqual([]);
});
it("lässt Elemente ohne Ist-Wert weg -- sie laufen auf der Planlinie weiter", () => {
const res = resolveActuals([setAt(2022, {})], plan, plan.elements);
expect(res[0].byElementId).toEqual({});
});
it("sortiert nach Planjahr", () => {
const res = resolveActuals(
[setAt(2024, { fonds: { value: 140000 } }), setAt(2022, { fonds: { value: 120000 } })],
plan,
plan.elements
);
expect(res.map((r) => r.year)).toEqual([2022, 2024]);
});
});
describe("actualsForYear / latestPlanYear", () => {
const plan = fundPlan();
const res = resolveActuals(
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
plan,
plan.elements
);
it("findet den Satz des Jahres", () => {
expect(actualsForYear(res, 3)!.year).toBe(2022);
expect(actualsForYear(res, 4)).toBeNull();
});
it("kennt das jüngste erfasste Planjahr", () => {
expect(latestPlanYear(res)).toBe(5);
expect(latestPlanYear([])).toBeNull();
});
});
describe("Berechnung mit Ist-Werten", () => {
const plan = fundPlan();
it("lässt die Plan-Sicht unangetastet", () => {
// Der wichtigste Test überhaupt: Ohne actuals-Option muss auf die Zahl dasselbe
// herauskommen wie vorher.
const a = computePlan(plan);
const b = computePlan(plan, undefined, {});
expect(a.phases[0].endWealthNominal).toBe(b.phases[0].endWealthNominal);
});
it("springt im Ist-Jahr auf den erfassten Wert", () => {
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
const computed = computePlan(plan, undefined, { actuals });
// Planwert 2022 wäre 100'000 x 1.05^3 = 115'762. Erfasst sind 120'000.
expect(valueAt(computePlan(plan), "fonds", 3)).toBe(115763);
expect(valueAt(computed, "fonds", 3)).toBe(120000);
});
it("rechnet ab dem Ist-Wert planmässig weiter", () => {
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
const computed = computePlan(plan, undefined, { actuals });
// Zwei Jahre nach dem Sprung: 120'000 x 1.05^2 = 132'300 -- genau die Erwartung aus der
// Anforderung ("in 2024 wäre man dann bei ca. 132").
expect(valueAt(computed, "fonds", 5)).toBe(132300);
});
it("springt bei mehreren Sätzen an jeder erfassten Stelle", () => {
const actuals = resolveActuals(
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
plan,
plan.elements
);
const computed = computePlan(plan, undefined, { actuals });
expect(valueAt(computed, "fonds", 3)).toBe(120000);
expect(valueAt(computed, "fonds", 5)).toBe(140000); // statt 132'300
expect(valueAt(computed, "fonds", 6)).toBe(147000); // 140'000 x 1.05
});
it("führt den Sprung als eigene Position, nicht als Rendite", () => {
// Sonst erschiene eine Planabweichung als Anlageerfolg -- und die Brücke ginge nicht auf.
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
const withActuals = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
const planOnly = computePlan(plan).phases[0].wealthBridge;
// Planwert 2022 exakt: 100'000 x 1.05^3 = 115'762.50 -> Korrektur 4'237.50, gerundet 4'238.
expect(withActuals.actualsCorrection).toBe(4238);
// Die Rendite selbst bleibt unberührt: Der Sprung ist KEIN Anlageerfolg. Sie ist nur
// grösser, weil ab 2022 auf einem höheren Kapital verzinst wird.
expect(withActuals.investmentReturn).toBeGreaterThan(planOnly.investmentReturn);
expect(planOnly.actualsCorrection).toBe(0);
});
it("hält die Vermögensbrücke auch bei mehreren Sprüngen geschlossen", () => {
const actuals = resolveActuals(
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 90000 } })],
plan,
plan.elements
);
const bridge = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
// Der zweite Sprung geht nach UNTEN -- die Korrektur muss negativ sein können.
expect(bridge.actualsCorrection).toBeLessThan(0);
// Der Restposten bleibt reine Rundung. Die Toleranz entspricht der, ab der die
// Detailansicht eine Fehlermeldung zeigt (ResidualNote: > 2 Franken).
expect(Math.abs(bridge.residual)).toBeLessThanOrEqual(2);
});
it("übernimmt den effektiven Cash-Bestand und hält die Cash-Brücke geschlossen", () => {
const actuals = resolveActuals([setAt(2022, {}, 55000)], plan, plan.elements);
const computed = computePlan(plan, undefined, { actuals });
const cb = computed.phases[0].cashBridge;
expect(cb.actualsCorrection).not.toBe(0);
expect(Math.abs(cb.residual)).toBeLessThanOrEqual(2);
// Der Cash-Bestand am Phasenende trägt den Sprung wirklich mit.
expect(computed.phases[0].cashBridge.cashEnd).toBe(55000);
});
});
describe("rebaseFlow", () => {
it("trifft im Ist-Jahr genau den erfassten Betrag", () => {
// Basis so, dass basis * (1+idx)^(t-1) === Ist-Wert.
const basis = rebaseFlow(120000, 2, 5);
expect(basis * Math.pow(1.02, 4)).toBeCloseTo(120000, 6);
});
it("berücksichtigt bei Ausgaben zusätzlich die Teuerung", () => {
const basis = rebaseFlow(50000, 1, 3, 1.1);
expect(basis * Math.pow(1.01, 2) * 1.1).toBeCloseTo(50000, 6);
});
it("weicht nicht auf NaN aus, wenn kein Wachstum vorliegt", () => {
expect(rebaseFlow(1000, 0, 1)).toBe(1000);
expect(rebaseFlow(1000, -100, 3)).toBe(1000);
});
});
+151
View File
@@ -0,0 +1,151 @@
// Effektive (Ist-)Werte -- Roadmap Nr. 5, Plan-/Ist-Vergleich.
//
// Grundgedanke: Der Plan bleibt unangetastet. Parallel dazu läuft eine ZWEITE Berechnung mit
// derselben Mechanik, aber korrigierter Ausgangsbasis: In jedem Jahr, für das ein Ist-Satz
// erfasst wurde, schnappen die Werte auf die Realität und laufen von dort planmässig weiter.
//
// Beispiel: Fonds startet 2020 mit 100 bei 5 % Rendite. Ohne Ist-Daten steht 2022 rechnerisch
// 110 da. Wird für 2022 ein Ist-Wert von 120 erfasst, rechnet die Ist-Sicht ab 2022 mit 120
// weiter und steht 2024 bei ~132. Kommt für 2024 ein Ist-Wert von 140 dazu, springt sie dort
// erneut.
//
// Ein Ist-Satz gehört zum PLAN, nicht zum Szenario: Die Realität ist dieselbe, egal gegen
// welches Szenario man sie hält. Die Zuordnung auf die szenario-eigenen Element-IDs läuft
// über dieselbe Herkunfts-Kette (`sourceElementId`), die auch der Diff und die
// Monte-Carlo-Gruppierung benutzen.
import { resolveRootElementId } from "@/lib/montecarlo";
import type { ElementCategory } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
// Was für ein Ist-Wert je Element erfasst werden kann. Bestände und Flüsse verhalten sich
// verschieden: Ein Bestand ersetzt den laufenden Stand, ein Fluss die Basis für alle
// Folgejahre.
export type ActualKind = "STOCK" | "FLOW" | "PROPERTY" | "NONE";
export function actualKindOf(category: ElementCategory): ActualKind {
switch (category) {
case "PENSION_FUND":
case "PILLAR_3A":
case "OTHER_ASSET":
return "STOCK";
case "OTHER_DEBT":
return "STOCK"; // Restschuld
case "REAL_ESTATE":
return "PROPERTY"; // Verkehrswert UND Resthypothek
case "INCOME":
case "EXPENSE":
return "FLOW";
case "AHV":
// Die Rente folgt der amtlichen Formel aus der Beitragskarriere. Erfassbar ist sie nur,
// wenn sie zum Stichtag bereits LÄUFT -- vorher gibt es keinen Stand. Das entscheidet
// sich am Plan, nicht an der Kategorie (siehe `actualFieldsFor`).
return "FLOW";
}
}
// Ein erfasster Wert je Element.
export interface ActualElementValue {
// Bestand bzw. Verkehrswert bei Immobilien; bei Flüssen der NOMINALE Jahresbetrag
// (Einkommen: was aufs Konto kam; Ausgaben: was tatsächlich ausgegeben wurde).
value?: number;
// Nur Immobilie: tatsächliche Restschuld.
mortgage?: number;
}
// Ein Ist-Satz, wie er gespeichert wird. Schlüssel sind WURZEL-Element-IDs.
export interface ActualsSetInput {
id: string;
recordedOn: string; // exaktes Datum (ISO) -- nur für Liste und Zeitachse
year: number; // Kalenderjahr; nur dieses geht in die Rechnung ein
comment?: string | null;
cash?: number | null;
values: Record<string, ActualElementValue>;
}
// Auf ein konkretes Szenario aufgelöster Ist-Satz: Schlüssel sind die Element-IDs DIESES
// Szenarios, und das Kalenderjahr ist in ein Planjahr (1-basiert) umgerechnet.
export interface ResolvedActuals {
planYear: number; // 1 = erstes Planjahr
year: number; // Kalenderjahr (für Anzeige)
cash?: number;
byElementId: Record<string, ActualElementValue>;
}
// Kalenderjahr -> Planjahr. `startYear` ist das Kalenderjahr des ersten Planjahres.
export function toPlanYear(year: number, startYear: number | null | undefined): number | null {
if (!startYear) return null;
return year - startYear + 1;
}
// Herkunft eines Elements: die lose Referenz auf sein Gegenstück im Eltern-Szenario.
export interface ElementOrigin {
id: string;
sourceElementId?: string | null;
}
// Bildet die gespeicherten Ist-Sätze auf ein Szenario ab.
//
// `origins` enthält die Elemente ALLER Szenarien des Plans -- nur so löst sich die
// Herkunfts-Kette auch über ein übersprungenes Zwischen-Szenario hinweg auf.
export function resolveActuals(
sets: ActualsSetInput[],
scenario: PlanInput,
origins: ElementOrigin[]
): ResolvedActuals[] {
const sourceById = new Map<string, string | null>();
for (const o of origins) sourceById.set(o.id, o.sourceElementId ?? null);
// Sicherheitsnetz: Elemente des betrachteten Szenarios sind immer dabei.
for (const e of scenario.elements) if (!sourceById.has(e.id)) sourceById.set(e.id, e.sourceElementId ?? null);
const totalYears = scenario.phases.reduce((s, p) => s + p.durationYears, 0);
return sets
.flatMap<ResolvedActuals>((set) => {
const planYear = toPlanYear(set.year, scenario.startYear);
if (planYear === null) return [];
const byElementId: Record<string, ActualElementValue> = {};
for (const e of scenario.elements) {
const root = resolveRootElementId(e.id, sourceById);
const v = set.values[root];
// Lücken fallen bewusst auf die Plandaten zurück: Ein Element ohne Ist-Wert läuft
// unverändert auf seiner Planlinie weiter.
if (v && (typeof v.value === "number" || typeof v.mortgage === "number")) {
byElementId[e.id] = v;
}
}
const out: ResolvedActuals = { planYear, year: set.year, byElementId };
if (typeof set.cash === "number") out.cash = set.cash;
return [out];
})
// Ausserhalb des Plans liegende Sätze werden ignoriert -- sie hätten keinen Angriffspunkt.
.filter((r) => r.planYear >= 1 && r.planYear <= totalYears)
// Bei zwei Sätzen im selben Planjahr gewinnt der zuletzt erfasste.
.sort((a, b) => a.planYear - b.planYear);
}
// Nachschlagen im Rechenkern: Gibt es für dieses Planjahr einen Ist-Satz?
export function actualsForYear(list: ResolvedActuals[], planYear: number): ResolvedActuals | null {
// Rückwärts, damit bei mehreren Sätzen im selben Jahr der letzte gewinnt.
for (let i = list.length - 1; i >= 0; i--) if (list[i].planYear === planYear) return list[i];
return null;
}
// Das jüngste erfasste Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und
// Grundlage der Hervorhebung auf der Zeitachse.
export function latestPlanYear(list: ResolvedActuals[]): number | null {
return list.length === 0 ? null : Math.max(...list.map((r) => r.planYear));
}
// Rechnet einen Ist-Fluss auf die Basis zurück, mit der der Rechenkern arbeitet.
//
// Einkommen laufen als `basis * (1 + idx/100)^(t-1)`. Ist für Jahr t ein Ist-Wert erfasst,
// muss die Basis so gesetzt werden, dass die Formel in genau diesem Jahr den Ist-Wert trifft
// -- danach wächst sie planmässig weiter. Das ist gleichbedeutend damit, den Bezugspunkt der
// Reihe auf das Ist-Jahr zu legen, kommt aber ohne Eingriff in die Struktur aus.
export function rebaseFlow(actualValue: number, idxPercent: number, tInPhase: number, deflator = 1): number {
const growth = Math.pow(1 + idxPercent / 100, tInPhase - 1);
const divisor = growth * (deflator || 1);
if (!Number.isFinite(divisor) || divisor === 0) return actualValue;
return actualValue / divisor;
}
+73 -2
View File
@@ -11,6 +11,7 @@ import {
DEFAULT_PROPERTY_GAINS_TAX_RATE,
} from "@/lib/constants";
import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import type { ElementCategory } from "@/lib/elements";
import type { PersonRole, PlanInput } from "@/lib/types";
@@ -50,6 +51,9 @@ export interface ComputeOptions {
// Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
// und darf von der Protokollierung nichts merken.
explain?: boolean;
// Effektive (Ist-)Werte, auf DIESES Szenario aufgelöst (siehe `actuals.ts`). Ohne sie
// rechnet die Funktion exakt wie bisher -- die Plan-Sicht bleibt unangetastet.
actuals?: ResolvedActuals[];
}
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
@@ -111,6 +115,10 @@ export interface WealthBridge {
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten
// Sprung auf die erfassten Ist-Werte (nur in der Ist-Sicht, sonst 0). Bewusst als eigene
// Position: Die Differenz zwischen Plan und Wirklichkeit ist KEINE Rendite und darf nicht
// als solche erscheinen -- ohne diese Zeile ginge die Brücke im Ist-Jahr nicht auf.
actualsCorrection: number;
endWealth: number; // = endWealthNominal
residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein)
}
@@ -127,6 +135,7 @@ export interface CashBridge {
savingRates: number; // 3a + Sparbeiträge (Abgang)
debtRates: number; // Amortisationen + Tilgungen (Abgang)
withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang)
actualsCorrection: number; // Sprung auf den erfassten Ist-Cashbestand (sonst 0)
cashEnd: number;
residual: number;
}
@@ -332,6 +341,8 @@ function pct(v: number): string {
export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed {
const explain = options?.explain === true;
// Ohne Ist-Werte verhält sich die Funktion exakt wie bisher (die Golden Tests belegen es).
const actuals = options?.actuals;
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = plan.persons;
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
@@ -703,6 +714,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let savingRatesTotal = 0;
let debtRatesTotal = 0;
let withdrawalsTotal = 0;
// Sprung auf die Ist-Werte. Ohne Ist-Daten bleiben beide 0 und die Brücken rechnen
// exakt wie bisher.
let actualsCorrectionTotal = 0;
let actualsCashCorrectionTotal = 0;
for (let t = 1; t <= duration; t++) {
// Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix).
@@ -787,6 +802,58 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates;
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
// --- Effektive Werte einspielen (Roadmap Nr. 5) --------------------------------------
// Bewusst NACH Verzinsung, Tilgung und Cash-Fortschreibung: Der erfasste Wert ist der
// Stand AM ENDE des Ist-Jahres. Rechnet man 2022 mit 120 und wieder 2024 mit 140, so
// liegen dazwischen genau zwei Wachstumsjahre -- das entspricht der Erwartung.
//
// Die Differenz wird als eigene Grösse geführt und NICHT den Renditen zugeschlagen:
// Ein Rückstand gegenüber dem Plan ist keine negative Rendite, sondern eine Korrektur.
const act = actuals ? actualsForYear(actuals, yearsBefore + t) : null;
if (act) {
for (const a of assets) {
const v = act.byElementId[a.ec.elementId]?.value;
if (typeof v !== "number") continue; // Lücke -> Planlinie läuft weiter
actualsCorrectionTotal += v - a.value;
a.value = v;
}
for (const re of realEstates) {
const av = act.byElementId[re.ec.elementId];
if (typeof av?.value === "number") {
actualsCorrectionTotal += av.value - re.value;
re.value = av.value;
}
if (typeof av?.mortgage === "number") {
// Eine höhere Restschuld mindert das Vermögen -- Vorzeichen umgekehrt.
actualsCorrectionTotal -= av.mortgage - re.mortgage;
re.mortgage = av.mortgage;
}
}
for (const d of debts) {
const v = act.byElementId[d.ec.elementId]?.value;
if (typeof v !== "number") continue;
actualsCorrectionTotal -= v - d.owed;
d.owed = v;
}
// Flüsse: Der erfasste Betrag gilt für DIESES Jahr; die Basis wird so zurückgerechnet,
// dass die Reihe hier den Ist-Wert trifft und danach planmässig weiterwächst.
for (const inc of incomes) {
const v = act.byElementId[inc.ec.elementId]?.value;
if (typeof v === "number") inc.basis = rebaseFlow(v, inc.idx, t);
}
for (const exp of expenses) {
const v = act.byElementId[exp.ec.elementId]?.value;
// Ausgaben werden real geführt, erfasst wird der nominale Ist-Betrag.
if (typeof v === "number") exp.basis = rebaseFlow(v, exp.idx, t, inflFactor);
}
if (typeof act.cash === "number") {
actualsCashCorrectionTotal += act.cash - cash;
actualsCorrectionTotal += act.cash - cash;
cash = act.cash;
}
}
if (cash < 0) cashNegative = true;
quotaTotal += quote;
@@ -1203,6 +1270,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
investmentReturn: Math.round(investmentReturnTotal),
propertyAppreciation: Math.round(propertyAppreciationTotal),
pensionFundContribution: Math.round(pensionFundContributionTotal),
actualsCorrection: Math.round(actualsCorrectionTotal),
endWealth: endWealthNominal,
residual:
endWealthNominal -
@@ -1215,7 +1283,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
Math.round(quotaTotal) +
Math.round(investmentReturnTotal) +
Math.round(propertyAppreciationTotal) +
Math.round(pensionFundContributionTotal)),
Math.round(pensionFundContributionTotal) +
Math.round(actualsCorrectionTotal)),
},
cashBridge: {
openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd,
@@ -1229,6 +1298,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
savingRates: Math.round(savingRatesTotal),
debtRates: Math.round(debtRatesTotal),
withdrawals: Math.round(withdrawalsTotal),
actualsCorrection: Math.round(actualsCashCorrectionTotal),
cashEnd,
residual:
cashEnd -
@@ -1236,7 +1306,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
Math.round(quotaTotal) -
Math.round(savingRatesTotal) -
Math.round(debtRatesTotal) +
Math.round(withdrawalsTotal)),
Math.round(withdrawalsTotal) +
Math.round(actualsCashCorrectionTotal)),
},
traces: explain ? phaseTraces : undefined,
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import { buildViews, deviation, viewFor } from "@/lib/dataview";
import type { ActualsSetInput } from "@/lib/actuals";
import type { PlanInput } from "@/lib/types";
function plan(): PlanInput {
return {
id: "s",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2020,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
elements: [
{
id: "fonds",
category: "OTHER_ASSET",
name: "Fonds",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } },
transitionValues: {},
sourceElementId: null,
},
],
} as unknown as PlanInput;
}
const set: ActualsSetInput = {
id: "a1",
recordedOn: "2022-08-18",
year: 2022,
cash: null,
values: { fonds: { value: 120000 } },
};
const endOf = (c: { phases: { endWealthNominal: number }[] }) => c.phases[c.phases.length - 1].endWealthNominal;
describe("buildViews", () => {
it("liefert ohne Ist-Werte gar keine Ist-Sicht", () => {
// Wichtig für die Oberfläche: Der Umschalter erscheint dann gar nicht erst.
const v = buildViews(plan(), [], plan().elements);
expect(v.actual).toBeNull();
expect(v.actualYears).toEqual([]);
expect(v.latestActualPlanYear).toBeNull();
});
it("rechnet beide Sichten und lässt die Plan-Sicht unberührt", () => {
const p = plan();
const v = buildViews(p, [set], p.elements);
const planOnly = buildViews(p, [], p.elements);
expect(v.actual).not.toBeNull();
expect(endOf(v.plan)).toBe(endOf(planOnly.plan));
expect(endOf(v.actual!)).toBeGreaterThan(endOf(v.plan));
});
it("merkt sich die erfassten Jahre für die Zeitachse", () => {
const p = plan();
const v = buildViews(p, [set, { ...set, id: "a2", recordedOn: "2024-01-05", year: 2024 }], p.elements);
expect(v.actualYears).toEqual([2022, 2024]);
expect(v.latestActualPlanYear).toBe(5);
});
});
describe("viewFor", () => {
it("fällt ohne Ist-Sicht auf den Plan zurück, statt leer zu bleiben", () => {
const v = buildViews(plan(), [], plan().elements);
expect(viewFor(v, "ACTUAL")).toBe(v.plan);
});
it("liefert mit Ist-Werten die Ist-Sicht", () => {
const p = plan();
const v = buildViews(p, [set], p.elements);
expect(viewFor(v, "ACTUAL")).toBe(v.actual);
expect(viewFor(v, "PLAN")).toBe(v.plan);
});
});
describe("deviation", () => {
it("behauptet ohne Ist-Sicht keine Abweichung von 0", () => {
const v = buildViews(plan(), [], plan().elements);
expect(deviation(v, endOf)).toBeNull();
});
it("misst die Abweichung Ist gegenüber Plan", () => {
const p = plan();
const v = buildViews(p, [set], p.elements);
const d = deviation(v, endOf)!;
expect(d).toBeGreaterThan(0);
expect(d).toBe(endOf(v.actual!) - endOf(v.plan));
});
});
+67
View File
@@ -0,0 +1,67 @@
// Datensicht: Plandaten oder effektive (Ist-)Daten.
//
// Der Plan-Lauf bleibt unangetastet -- die Ist-Sicht ist ein ZWEITER Lauf derselben Mechanik
// mit korrigierter Ausgangsbasis (siehe `actuals.ts`). Dieses Modul bündelt, was beide
// Sichten gemeinsam brauchen, damit sich Matrix, Grafiken und die drei Analysewerkzeuge
// identisch verhalten.
import { computePlan } from "@/lib/calculations";
import { resolveActuals, latestPlanYear, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// In der Matrix stehen genau zwei Möglichkeiten zur Wahl: die reinen Planzahlen oder die
// Ist-Zahlen MIT Abweichung. Ein Nebeneinander beider Rohwerte wäre bei zusätzlich
// nominal/real eine Zelle mit acht Zahlen (siehe SPEZIFIKATION 9.29).
export type DataSource = "PLAN" | "ACTUAL";
export const DATA_SOURCE_OPTIONS: { value: DataSource; label: string; short: string }[] = [
{ value: "PLAN", label: "Planzahlen", short: "Plan" },
{ value: "ACTUAL", label: "Effektive Zahlen inkl. Abweichung", short: "Effektiv" },
];
export interface DataViews {
plan: PlanComputed;
// Null, solange für diesen Plan keine verwertbaren Ist-Werte erfasst sind.
actual: PlanComputed | null;
// Jüngstes erfasstes Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und
// Grundlage der Hervorhebung auf der Zeitachse.
latestActualPlanYear: number | null;
// Kalenderjahre mit Ist-Satz, aufsteigend (für die Marker auf der Zeitachse).
actualYears: number[];
}
// Beide Sichten in einem Zug. `computePlan` ist rein und kostet rund 0.2 ms -- zwei Läufe
// sind billiger als jede Zwischenspeicherung.
export function buildViews(
plan: PlanInput,
sets: ActualsSetInput[],
origins: ElementOrigin[]
): DataViews {
const planComputed = computePlan(plan);
const resolved = resolveActuals(sets, plan, origins);
if (resolved.length === 0) {
return { plan: planComputed, actual: null, latestActualPlanYear: null, actualYears: [] };
}
return {
plan: planComputed,
actual: computePlan(plan, undefined, { actuals: resolved }),
latestActualPlanYear: latestPlanYear(resolved),
actualYears: [...new Set(resolved.map((r) => r.year))].sort((a, b) => a - b),
};
}
// Die für die gewählte Quelle massgebende Berechnung. Fehlen Ist-Werte, bleibt es beim Plan --
// eine leere Ansicht wäre die schlechtere Antwort als eine ehrliche Rückfallebene.
export function viewFor(views: DataViews, source: DataSource): PlanComputed {
return source === "ACTUAL" ? views.actual ?? views.plan : views.plan;
}
// Abweichung Ist gegenüber Plan. `null`, wenn es keine Ist-Sicht gibt -- dann zeigt die
// Oberfläche gar keine Abweichung an, statt eine von 0 zu behaupten.
export function deviation(views: DataViews, pick: (c: PlanComputed) => number): number | null {
if (!views.actual) return null;
return pick(views.actual) - pick(views.plan);
}
+8 -2
View File
@@ -17,6 +17,7 @@ import { applyDriver, applyElementDriver, driverById, DRIVERS, tunableElements }
import type { DriverId, DriverUnit } from "@/lib/sensitivity";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
import type { ResolvedActuals } from "@/lib/actuals";
// Ein Regler: entweder ein plan-weiter Treiber oder die Rendite eines einzelnen Elements.
export type SliderRef = { kind: "driver"; id: DriverId } | { kind: "element"; id: string };
@@ -147,9 +148,14 @@ export interface LiveResult {
kpis: LiveKpis;
}
export function runLive(plan: PlanInput, sliders: SliderDef[], values: SliderValues): LiveResult {
export function runLive(
plan: PlanInput,
sliders: SliderDef[],
values: SliderValues,
actuals?: ResolvedActuals[]
): LiveResult {
const tuned = applySliders(plan, sliders, values);
const computed = computePlan(tuned);
const computed = computePlan(tuned, undefined, actuals ? { actuals } : undefined);
return { plan: tuned, computed, kpis: kpisOf(computed) };
}
+19
View File
@@ -86,5 +86,24 @@ describe("Datenbank-Migrationen", () => {
)
).rows.map((r) => r.indexname);
expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key");
// --- Effektive Werte ---
expect(tables, "Tabelle ActualsSet fehlt").toContain("ActualsSet");
const actCols = await cols("ActualsSet");
for (const c of ["planId", "recordedOn", "year", "comment", "cash", "values", "createdById"]) {
expect(actCols, `ActualsSet.${c} fehlt`).toContain(c);
}
// Der Ist-Satz hängt am PLAN, nicht am Szenario -- sonst wäre die Realität pro Szenario
// verschieden erfasst.
expect(actCols).not.toContain("scenarioId");
// `cash` muss leer bleiben dürfen: Wer den Kontostand nicht kennt, soll den Satz trotzdem
// erfassen können (Lücken fallen auf die Plandaten zurück).
const cashCol = await db.query<{ is_nullable: string }>(
`SELECT is_nullable FROM information_schema.columns
WHERE table_name='ActualsSet' AND column_name='cash'`
);
expect(cashCol.rows[0].is_nullable).toBe("YES");
}, 60000);
});
+10 -6
View File
@@ -18,6 +18,7 @@ import { computePlan } from "@/lib/calculations";
import { num } from "@/lib/elements";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
import type { ResolvedActuals } from "@/lib/actuals";
export type DriverId =
| "inflation"
@@ -292,8 +293,8 @@ export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
}
// Zielgrösse: Endvermögen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
const computed = computePlan(plan);
export function planMetric(plan: PlanInput, metric: TornadoMetric, actuals?: ResolvedActuals[]): number {
const computed = computePlan(plan, undefined, actuals ? { actuals } : undefined);
const last = computed.phases[computed.phases.length - 1];
if (!last) return 0;
return Math.round(metric === "real" ? last.endWealthReal : last.endWealthNominal);
@@ -302,14 +303,17 @@ export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
export function computeTornado(
plan: PlanInput,
metric: TornadoMetric,
inputs: TornadoInput[]
inputs: TornadoInput[],
// Effektive Werte: Die Treiber wirken dann nur noch auf die NICHT belegten Jahre -- was
// erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus.
actuals?: ResolvedActuals[]
): TornadoResult {
const base = planMetric(plan, metric);
const base = planMetric(plan, metric, actuals);
const bars: TornadoBar[] = inputs.map((input) => {
const def = driverById(input.id);
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric, actuals);
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric, actuals);
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermögen). Der Balken spannt
// deshalb über min..max; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
const swing = Math.abs(highResult - lowResult);
+3
View File
@@ -29,6 +29,9 @@ const EXEMPT: Record<string, string> = {
"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",
"plans/[planId]/actuals":
"Ist-Werte sind eine Beobachtung, keine Planänderung sie verändern kein Szenario",
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
};
describe("Versionierung: Abdeckung der Schreibpfade", () => {