Raten ueber Lebensphasen uebernehmen + Rate in der Verlaufsgrafik
Deploy App / deploy (push) Successful in 1m6s

(1) Beim Aendern eines Ratenfelds fragt das Panel nach der Reichweite:
nur diese Phase (Vorgabe), diese + folgende, alle Phasen. Gilt fuer
expectedReturn, valueGrowth, interestRate und teuerungsausgleich.

Inline statt Modal -- das Zahlenfeld loest je Tastendruck aus. Die
Zielphasen behalten ihre uebrigen Werte (der Endpunkt ersetzt den ganzen
Satz; ein Kopieren des Entwurfs haette dort Betraege geloescht).

(2) ElementYearPoint fuehrt neu `rate` mit -- additiv, nur durchgereicht.
Verlaufsgrafik zeigt sie auf zweiter Y-Achse als Stufenlinie.

Golden Tests unveraendert. Spezifikation 0.20, 17 Tests (164 -> 181).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:36:05 +02:00
parent d023534a03
commit 4343d50aa4
7 changed files with 482 additions and 11 deletions
+49 -5
View File
@@ -396,6 +396,17 @@ export function ElementDetailDialog({
? "Eigenkapital"
: "Wert";
// Zweite Achse nur, wenn dieses Element überhaupt eine Rate trägt (AHV und Schulden nicht).
const rateLabel =
category === "REAL_ESTATE"
? "Wertsteigerung"
: isFlow
? category === "INCOME"
? "Lohnentwicklung"
: "Reale Mehrausgaben"
: "Erwartete Rendite";
const hasRate = points.some((p) => typeof p.rate === "number");
const verlauf = (
<>
{points.length === 0 ? (
@@ -406,16 +417,49 @@ export function ElementDetailDialog({
<LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v, n) => [typeof v === "number" ? formatChf(v) : v, n]} labelFormatter={(v) => `Alter ${v}`} />
<YAxis yAxisId="chf" tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
{hasRate && (
<YAxis
yAxisId="rate"
orientation="right"
tick={{ fontSize: 11 }}
tickFormatter={(v) => `${v} %`}
// Etwas Luft nach oben und unten, damit eine konstante Rate nicht als
// Linie direkt auf der Achse klebt.
domain={([min, max]: readonly [number, number]) => [Math.min(0, min - 1), max + 1]}
/>
)}
<Tooltip
formatter={(v, n) =>
typeof v !== "number" ? [v, n] : n === rateLabel ? [`${v} %`, n] : [formatChf(v), n]
}
labelFormatter={(v) => `Alter ${v}`}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line yAxisId="chf" dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
{MULTI_SERIES.includes(category) && (
<>
<Line dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
<Line dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
<Line yAxisId="chf" dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
<Line yAxisId="chf" dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
</>
)}
{hasRate && (
// Stufenlinie, nicht interpoliert: Die Rate ist innerhalb einer Phase
// konstant und springt an der Phasengrenze. Eine weiche Kurve würde einen
// gleitenden Übergang suggerieren, den die Berechnung nicht macht.
<Line
yAxisId="rate"
type="stepAfter"
dataKey="rate"
name={rateLabel}
stroke="#d97706"
strokeWidth={1.5}
strokeDasharray="4 2"
dot={false}
connectNulls
isAnimationActive={false}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
+75 -1
View File
@@ -6,6 +6,14 @@ import { AlertTriangle } from "lucide-react";
import { FieldLabel, MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
import { formatChf } from "@/lib/format";
import { api } from "@/lib/api-client";
import {
buildRateWrites,
changedRateFields,
scopeQuestion,
SCOPE_OPTIONS,
type PhaseRef,
type RateScope,
} from "@/lib/ratefields";
import { ahvAnnualPension, ahvMdje, type AhvCareer } from "@/lib/calculations";
import { CATEGORY_LABELS, num } from "@/lib/elements";
import {
@@ -49,6 +57,10 @@ interface Props {
context: CellContext;
phaseData: PhaseData;
transitionData: TransitionData;
// Alle Phasen des Szenarios und die dort bereits erfassten Werte -- Grundlage für die
// Übernahme einer geänderten Rate auf weitere Phasen (siehe SPEZIFIKATION 3.6.11).
allPhases?: PhaseRef[];
phaseDataByPhase?: Record<string, PhaseData>;
onSaved: () => void;
onDeleteElement: () => void;
}
@@ -886,14 +898,29 @@ export function ElementTransitionFields({
}
}
export function ElementDetail({ element, context, phaseData, transitionData, onSaved, onDeleteElement }: Props) {
export function ElementDetail({
element,
context,
phaseData,
transitionData,
allPhases = [],
phaseDataByPhase = {},
onSaved,
onDeleteElement,
}: Props) {
const [pd, setPd] = useState<PhaseData>({ ...phaseData });
const [td, setTd] = useState<TransitionData>({ ...transitionData });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reichweite einer geänderten Rate. Vorgabe ist das bisherige Verhalten: nur diese Phase.
const [scope, setScope] = useState<RateScope>("THIS");
const isTransition = context.kind === "transition";
// Welche Ratenfelder hat der Nutzer verändert? Nur dann wird überhaupt gefragt.
const changedRates = isTransition ? [] : changedRateFields(element.category, phaseData, pd);
const showScope = changedRates.length > 0 && allPhases.length > 1;
async function save() {
setSaving(true);
setError(null);
@@ -914,6 +941,21 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
delete payload.amount;
}
await api.put(`/api/elements/${element.id}/phase/${context.phaseId}`, payload);
// Geänderte Raten auf die gewählten weiteren Phasen übertragen. Die bestehenden
// Werte der Zielphasen bleiben erhalten -- übernommen wird ausschliesslich die Rate.
// Alle Schreibvorgänge fallen in dieselbe Bearbeitungssitzung und ergeben daher
// EINE Nebenversion, nicht eine je Phase (siehe 3.8.1).
for (const w of buildRateWrites(
allPhases,
context.phaseId,
scope,
changedRates,
pd,
phaseDataByPhase
)) {
await api.put(`/api/elements/${element.id}/phase/${w.phaseId}`, w.data);
}
}
onSaved();
} catch (e) {
@@ -959,6 +1001,38 @@ export function ElementDetail({ element, context, phaseData, transitionData, onS
)}
</div>
{/* Rückfrage zur Reichweite. Erscheint erst, wenn eine Rate tatsächlich verändert
wurde -- und bewusst hier statt als Modal beim Tippen: Das Zahlenfeld löst bei
jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. */}
{showScope && (
<div className="rounded-xl border border-attention bg-attention-soft p-3">
<p className="mb-2 text-xs text-attention-soft-fg">
{scopeQuestion(changedRates, allPhases.length)}
</p>
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-surface p-0.5 text-xs">
{SCOPE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => setScope(o.value)}
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
scope === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{o.label}
</button>
))}
</div>
<p className="mt-1.5 text-[11px] text-attention-soft-fg/80">
{scope === "THIS"
? "Andere Phasen bleiben unverändert."
: `Wird beim Speichern in ${
buildRateWrites(allPhases, context.phaseId, scope, changedRates, pd, phaseDataByPhase).length
} weitere Phase(n) übertragen. Beträge und Raten dort bleiben erhalten nur der geänderte Wert wird gesetzt.`}
</p>
</div>
)}
{error && <p className="text-sm text-danger">{error}</p>}
<div>
<button
+4
View File
@@ -863,6 +863,10 @@ export function PlanView({
element={element}
context={buildPhaseContext(phase, element)}
phaseData={element.phaseValues[phase.id] ?? {}}
// Für die Übernahme von Raten über Phasen hinweg: alle Phasen und die dort
// bereits erfassten Werte (sie müssen beim Übertragen erhalten bleiben).
allPhases={plan.phases.map((p) => ({ id: p.id, sequenceNumber: p.sequenceNumber }))}
phaseDataByPhase={element.phaseValues}
transitionData={{}}
onSaved={closeAndRefresh}
onDeleteElement={() => {
+14 -2
View File
@@ -62,6 +62,11 @@ export interface ElementYearPoint {
value: number; // Haupt-Kennzahl (Saldo, Eigenkapital, Flow, Rente)
propertyValue?: number; // nur REAL_ESTATE: Verkehrswert der Liegenschaft
mortgage?: number; // nur REAL_ESTATE: Restschuld
// Die in DIESEM Jahr wirksame Rate in Prozent -- erwartete Rendite (PK/3a/Vermögen),
// Wertsteigerung (Immobilie) bzw. jährliche Anpassung (Einkommen/Ausgaben). Rein für die
// Darstellung; die Rechnung benutzt denselben Wert, der hier nur mitgeführt wird.
// Innerhalb einer Phase konstant, an der Phasengrenze springt sie.
rate?: number;
}
export interface ElementPhaseComputed {
@@ -794,16 +799,22 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
const age = personA.age + yearsBefore + t;
const yr = yearsBefore + t;
for (const inc of incomes) {
inc.ec.yearly.push({ year: yr, age, value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)) });
inc.ec.yearly.push({
year: yr,
age,
value: Math.round(inc.basis * Math.pow(1 + inc.idx / 100, t - 1)),
rate: inc.idx,
});
}
for (const exp of expenses) {
exp.ec.yearly.push({
year: yr,
age,
value: Math.round(exp.basis * Math.pow(1 + exp.idx / 100, t - 1) * inflFactor),
rate: exp.idx,
});
}
for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value) });
for (const a of assets) a.ec.yearly.push({ year: yr, age, value: Math.round(a.value), rate: a.r });
for (const re of realEstates) {
re.ec.yearly.push({
year: yr,
@@ -811,6 +822,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
value: Math.round(re.value - re.mortgage), // Eigenkapital
propertyValue: Math.round(re.value),
mortgage: Math.round(re.mortgage),
rate: re.growth,
});
}
for (const d of debts) d.ec.yearly.push({ year: yr, age, value: -Math.round(d.owed) });
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect } from "vitest";
import {
buildRateWrites,
changedRateFields,
rateFieldsOf,
targetPhases,
type PhaseRef,
} from "@/lib/ratefields";
import type { PhaseData } from "@/lib/elements";
const PHASES: PhaseRef[] = [
{ id: "p1", sequenceNumber: 1 },
{ id: "p2", sequenceNumber: 2 },
{ id: "p3", sequenceNumber: 3 },
];
describe("rateFieldsOf", () => {
it("kennt die Ratenfelder je Kategorie", () => {
expect(rateFieldsOf("OTHER_ASSET")).toEqual(["expectedReturn"]);
expect(rateFieldsOf("REAL_ESTATE")).toEqual(["valueGrowth", "interestRate"]);
expect(rateFieldsOf("INCOME")).toEqual(["teuerungsausgleich"]);
});
it("hat für AHV und Schulden keine dort gibt es keine frei wählbare Rate", () => {
expect(rateFieldsOf("AHV")).toEqual([]);
expect(rateFieldsOf("OTHER_DEBT")).toEqual([]);
});
});
describe("targetPhases", () => {
it("«nur diese» trifft genau die bearbeitete Phase", () => {
expect(targetPhases(PHASES, "p2", "THIS").map((p) => p.id)).toEqual(["p2"]);
});
it("«diese + folgende» lässt frühere Phasen unangetastet", () => {
// Der Punkt der Option: Vergangenes soll nicht rückwirkend umgeschrieben werden.
expect(targetPhases(PHASES, "p2", "FOLLOWING").map((p) => p.id)).toEqual(["p2", "p3"]);
});
it("«alle» trifft alle, aufsteigend sortiert", () => {
const unsorted: PhaseRef[] = [
{ id: "p3", sequenceNumber: 3 },
{ id: "p1", sequenceNumber: 1 },
{ id: "p2", sequenceNumber: 2 },
];
expect(targetPhases(unsorted, "p2", "ALL").map((p) => p.id)).toEqual(["p1", "p2", "p3"]);
});
it("liefert nichts, wenn die bearbeitete Phase unbekannt ist", () => {
expect(targetPhases(PHASES, "weg", "ALL")).toEqual([]);
});
});
describe("changedRateFields", () => {
it("meldet nur tatsächlich veränderte Raten", () => {
const changed = changedRateFields("OTHER_ASSET", { expectedReturn: 4, startValue: 1000 }, { expectedReturn: 5, startValue: 1000 });
expect(changed).toEqual(["expectedReturn"]);
});
it("meldet nichts, wenn nur ein Betrag geändert wurde", () => {
// Sonst käme die Rückfrage bei jeder Betragsänderung reiner Lärm.
const changed = changedRateFields("OTHER_ASSET", { expectedReturn: 4, startValue: 1000 }, { expectedReturn: 4, startValue: 2000 });
expect(changed).toEqual([]);
});
it("behandelt «nicht gesetzt» und 0 als gleich", () => {
// Ein leeres Feld wird im Formular auf 0 vorbelegt. Ohne diese Gleichsetzung würde das
// blosse Öffnen eines Dialogs eine Änderung melden.
expect(changedRateFields("OTHER_ASSET", {}, { expectedReturn: 0 })).toEqual([]);
expect(changedRateFields("OTHER_ASSET", {}, { expectedReturn: 3 })).toEqual(["expectedReturn"]);
});
it("erfasst bei Immobilien beide Raten unabhängig", () => {
const changed = changedRateFields(
"REAL_ESTATE",
{ valueGrowth: 1, interestRate: 2 },
{ valueGrowth: 1, interestRate: 2.5 }
);
expect(changed).toEqual(["interestRate"]);
});
});
describe("buildRateWrites", () => {
const existing: Record<string, PhaseData> = {
p1: { expectedReturn: 4, startValue: 200000, annualContribution: 6000 },
p2: { expectedReturn: 4, annualContribution: 6000 },
p3: { expectedReturn: 4, annualWithdrawal: 30000 },
};
it("schreibt bei «nur diese Phase» gar nichts zusätzlich", () => {
expect(buildRateWrites(PHASES, "p2", "THIS", ["expectedReturn"], { expectedReturn: 5 }, existing)).toEqual([]);
});
it("lässt die bearbeitete Phase aus die speichert der Dialog selbst", () => {
const writes = buildRateWrites(PHASES, "p2", "ALL", ["expectedReturn"], { expectedReturn: 5 }, existing);
expect(writes.map((w) => w.phaseId)).toEqual(["p1", "p3"]);
});
it("ERHÄLT die übrigen Werte der Zielphase", () => {
// Der wichtigste Test: Der Endpunkt ersetzt den ganzen Werte-Satz einer Phase. Würde man
// den Entwurf kopieren statt zu mischen, verlöre p3 seine Bezugsrate und p1 seinen
// Startwert -- ein stiller Datenverlust.
const writes = buildRateWrites(PHASES, "p2", "ALL", ["expectedReturn"], { expectedReturn: 5, annualContribution: 9999 }, existing);
const p1 = writes.find((w) => w.phaseId === "p1")!;
expect(p1.data).toEqual({ expectedReturn: 5, startValue: 200000, annualContribution: 6000 });
const p3 = writes.find((w) => w.phaseId === "p3")!;
expect(p3.data).toEqual({ expectedReturn: 5, annualWithdrawal: 30000 });
// Der Beitrag aus dem Entwurf darf NICHT mitwandern -- nur die Rate war gemeint.
expect(p3.data.annualContribution).toBeUndefined();
});
it("überspringt Phasen, in denen der Wert schon stimmt", () => {
// Spart Schreibvorgänge und verhindert eine Version ohne inhaltliche Änderung.
const alreadyRight: Record<string, PhaseData> = {
p1: { expectedReturn: 5 },
p2: { expectedReturn: 4 },
p3: { expectedReturn: 4 },
};
const writes = buildRateWrites(PHASES, "p2", "ALL", ["expectedReturn"], { expectedReturn: 5 }, alreadyRight);
expect(writes.map((w) => w.phaseId)).toEqual(["p3"]);
});
it("schreibt bei «folgende» nicht in frühere Phasen", () => {
const writes = buildRateWrites(PHASES, "p2", "FOLLOWING", ["expectedReturn"], { expectedReturn: 5 }, existing);
expect(writes.map((w) => w.phaseId)).toEqual(["p3"]);
});
it("überträgt mehrere Raten gleichzeitig", () => {
const re: Record<string, PhaseData> = {
p1: { valueGrowth: 1, interestRate: 2, purchasePrice: 800000 },
p2: { valueGrowth: 1, interestRate: 2 },
p3: { valueGrowth: 1, interestRate: 2 },
};
const writes = buildRateWrites(
PHASES,
"p1",
"ALL",
["valueGrowth", "interestRate"],
{ valueGrowth: 2, interestRate: 3 },
re
);
expect(writes).toHaveLength(2);
expect(writes[0].data.valueGrowth).toBe(2);
expect(writes[0].data.interestRate).toBe(3);
});
it("legt Werte auch in Phasen an, die bisher keinen Satz hatten", () => {
const writes = buildRateWrites(PHASES, "p1", "ALL", ["expectedReturn"], { expectedReturn: 5 }, { p1: {} });
expect(writes.map((w) => w.phaseId)).toEqual(["p2", "p3"]);
expect(writes[0].data).toEqual({ expectedReturn: 5 });
});
});
+127
View File
@@ -0,0 +1,127 @@
// Ratenfelder und ihre Übernahme über Lebensphasen hinweg.
//
// Hintergrund: Werte liegen je Lebensphase vor. Bei Beträgen ist das richtig (das Einkommen
// ändert sich), bei RATEN meist nicht: Wer die erwartete Rendite seines ETF auf 5 % setzt,
// meint fast nie "nur in Phase 2". Ohne Übernahme muss man denselben Wert vier- oder fünfmal
// eintippen -- und übersieht dabei zuverlässig eine Phase.
//
// Reines Modul ohne I/O: Es entscheidet nur, WAS wohin geschrieben wird. Die Schreibvorgänge
// selbst löst der Dialog aus (ein PUT je Zielphase, über die bestehenden Endpunkte).
import type { ElementCategory, PhaseData } from "@/lib/elements";
// Ein Feld, das eine Rate beschreibt -- im Gegensatz zu einem Betrag.
export type RateField = "expectedReturn" | "valueGrowth" | "interestRate" | "teuerungsausgleich";
// Welche Ratenfelder hat eine Kategorie? Nur diese lösen die Rückfrage aus.
export const RATE_FIELDS_BY_CATEGORY: Record<ElementCategory, RateField[]> = {
PENSION_FUND: ["expectedReturn"],
PILLAR_3A: ["expectedReturn"],
OTHER_ASSET: ["expectedReturn"],
REAL_ESTATE: ["valueGrowth", "interestRate"],
INCOME: ["teuerungsausgleich"],
EXPENSE: ["teuerungsausgleich"],
AHV: [], // die Rente folgt der amtlichen Formel, es gibt keine frei wählbare Rate
OTHER_DEBT: [], // Schulden tragen ihren Zins nicht als eigenes Feld
};
export const RATE_FIELD_LABELS: Record<RateField, string> = {
expectedReturn: "Erwartete Rendite",
valueGrowth: "Wertsteigerung",
interestRate: "Hypothekarzins",
teuerungsausgleich: "Jährliche Anpassung",
};
export function rateFieldsOf(category: ElementCategory): RateField[] {
return RATE_FIELDS_BY_CATEGORY[category] ?? [];
}
// Reichweite der Übernahme. Vorgabe ist bewusst THIS -- das ist das bisherige Verhalten,
// und eine Rückfrage darf nie stillschweigend mehr verändern als bisher.
export type RateScope = "THIS" | "FOLLOWING" | "ALL";
export const SCOPE_OPTIONS: { value: RateScope; label: string }[] = [
{ value: "THIS", label: "Nur diese Phase" },
{ value: "FOLLOWING", label: "Diese + folgende" },
{ value: "ALL", label: "Alle Phasen" },
];
export interface PhaseRef {
id: string;
sequenceNumber: number;
}
// Welche Phasen sind Ziel der Übernahme? Die bearbeitete Phase ist immer dabei -- sie wird
// ohnehin gespeichert.
export function targetPhases(phases: PhaseRef[], currentPhaseId: string, scope: RateScope): PhaseRef[] {
const current = phases.find((p) => p.id === currentPhaseId);
if (!current) return [];
const sorted = [...phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
switch (scope) {
case "THIS":
return [current];
case "FOLLOWING":
return sorted.filter((p) => p.sequenceNumber >= current.sequenceNumber);
case "ALL":
return sorted;
}
}
// Welche Ratenfelder hat der Nutzer tatsächlich verändert? Nur dafür wird gefragt -- eine
// Rückfrage zu einem unveränderten Feld wäre Lärm.
export function changedRateFields(
category: ElementCategory,
original: PhaseData,
draft: PhaseData
): RateField[] {
return rateFieldsOf(category).filter((f) => {
const a = original[f];
const b = draft[f];
// Nicht gesetzt und 0 sind für eine Rate dasselbe -- sonst meldet das Öffnen eines
// Dialogs, in dem ein leeres Feld auf 0 vorbelegt wird, fälschlich eine Änderung.
return (a ?? 0) !== (b ?? 0);
});
}
export interface RateWrite {
phaseId: string;
data: PhaseData;
}
// Baut die Schreibvorgänge für die ÜBRIGEN Phasen (die bearbeitete speichert der Dialog
// selbst). Die bestehenden Werte der Zielphase bleiben erhalten -- übernommen werden
// ausschliesslich die veränderten Ratenfelder.
//
// Das ist wesentlich: Der Endpunkt ersetzt den ganzen Werte-Satz einer Phase. Würde man hier
// den Entwurf der bearbeiteten Phase einfach kopieren, überschriebe man in allen anderen
// Phasen auch Beträge, Sparraten und Bezüge.
export function buildRateWrites(
phases: PhaseRef[],
currentPhaseId: string,
scope: RateScope,
fields: RateField[],
draft: PhaseData,
existingByPhase: Record<string, PhaseData>
): RateWrite[] {
if (scope === "THIS" || fields.length === 0) return [];
const patch: Partial<PhaseData> = {};
for (const f of fields) patch[f] = draft[f] ?? 0;
return targetPhases(phases, currentPhaseId, scope)
.filter((p) => p.id !== currentPhaseId)
.map((p) => ({ phaseId: p.id, data: { ...(existingByPhase[p.id] ?? {}), ...patch } }))
// Phasen, in denen sich nichts ändert, gar nicht erst anfassen: spart Schreibvorgänge
// und verhindert, dass eine Version entsteht, obwohl inhaltlich nichts passiert ist.
.filter((w) => fields.some((f) => (existingByPhase[w.phaseId]?.[f] ?? 0) !== (draft[f] ?? 0)));
}
// Text für die Rückfrage im Dialog.
export function scopeQuestion(fields: RateField[], phaseCount: number): string {
const names = fields.map((f) => RATE_FIELD_LABELS[f].toLowerCase());
const list =
names.length === 1
? names[0]
: `${names.slice(0, -1).join(", ")} und ${names[names.length - 1]}`;
return `Du hast ${names.length === 1 ? "die" : "die"} ${list} geändert. Für welche der ${phaseCount} Lebensphasen soll der Wert gelten?`;
}