diff --git a/src/lib/money-tracker/MoneyChart.svelte b/src/lib/money-tracker/MoneyChart.svelte
index 8cec5bd..a1d89c0 100644
--- a/src/lib/money-tracker/MoneyChart.svelte
+++ b/src/lib/money-tracker/MoneyChart.svelte
@@ -105,14 +105,17 @@
)
.join(' ');
}
- /** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
+ /** Actual part through `projectedFrom` and projected part from the last actual point onward. */
function segments(item: ChartSeries) {
- if (!item.dashedFrom) return { solid: item.points, dashed: [] };
- const split = item.points.findLastIndex((point) => point.date <= item.dashedFrom!);
+ if (!item.projectedFrom) return { actual: item.points, projected: [] };
+ const split = item.points.findLastIndex((point) => point.date <= item.projectedFrom!);
return split < 0
- ? { solid: [], dashed: item.points }
- : { solid: item.points.slice(0, split + 1), dashed: item.points.slice(split) };
+ ? { actual: [], projected: item.points }
+ : { actual: item.points.slice(0, split + 1), projected: item.points.slice(split) };
}
+ /** Actual lines are thick and opaque; projected ones a thin, slightly lighter solid line. */
+ const lineWidth = (item: ChartSeries, projected: boolean) =>
+ projected ? (item.emphasis ? 1.5 : 1.1) : item.emphasis ? 3 : 1.75;
function pointAt(points: ChartPoint[], date: string) {
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
return points.findLast((point) => point.date <= date) ?? null;
@@ -236,16 +239,17 @@
{/if}
{#each series as item (item.label)}
{@const parts = segments(item)}
- {#each [parts.solid, parts.dashed] as points, index (index)}
+ {#each [parts.actual, parts.projected] as points, index (index)}
{#if points.length > 1}
{active.label} portfolio:
{active.portfolio}. Average {active.returnPercent}%/yr nominal, volatility about {active.volatilityPercent}%.
+ {#if markets.calmUntil !== null}No crash or down year through {markets.calmUntil}: those years
+ earn the {active.returnPercent}% average.
+ {/if}
{#if active.crashPercent !== 0}A {active.crashPercent}% crash every {active.crashEveryYears} years
from {active.firstCrashYear}{active.correctionPercent !== 0
? `, a ${active.correctionPercent}% correction halfway between`
- : ''}, and {percent(normalReturn)} in the other years so it averages out.
+ : ''}, and {percent(normalReturn)} in the other years so it averages out{markets.calmUntil !==
+ null
+ ? ` (from ${markets.calmUntil + 1})`
+ : ''}. A crash drops over March–October and takes about {Math.ceil(
+ Math.log(1 / (1 + active.crashPercent / 100)) / Math.log(1 + normalReturn)
+ )} years of normal returns to recover.
+ {#if shocks.length}Next ten years: {shocks
+ .map((row) => `${row.year} ${row.kind} ${Math.round(row.rate * 100)}%`)
+ .join(', ')}.{:else}No crash or correction in the next ten years.{/if}
{:else}No crashes modelled.{/if}
+ {#if active.salaryCapGrowthPercent > 0}The salary plateau keeps rising {active.salaryCapGrowthPercent}%/yr
+ above inflation, so pay never stalls.{/if}
Retirement in {scenarioFacts.birthYear + active.retireAge} at {active.retireAge}: salary and
side income stop, {cents(active.pensionNetEuros * 100)}/mo net pension (today’s €) starts, and
the rest of the spending is sold from investments.
@@ -180,9 +202,10 @@
Joint items from the recurring list in full until the rent starts, then your share, while your
partner’s income pays hers, DUO {cents(scenarioFacts.duoCents)}/mo from {scenarioFacts.duoFrom}
to {scenarioFacts.duoUntil}. Prices, benefits, pension, the buffer and the salary plateau
- grow with inflation; spending grows at the spending rate for ten years, then with inflation.
- Box 3 keeps taxing gains (losses carried forward). Salary bands (base per year, excl. 8%
- holiday pay): medior €52–68k, senior €65–91k, lead €79–110k (Chase IT 2026,
diff --git a/src/lib/money-tracker/charts.test.ts b/src/lib/money-tracker/charts.test.ts
index 2395b07..418b4df 100644
--- a/src/lib/money-tracker/charts.test.ts
+++ b/src/lib/money-tracker/charts.test.ts
@@ -207,7 +207,7 @@ describe('money tracker calculations', () => {
}
];
const [total] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'total');
- expect(total.dashedFrom).toBe('2026-09-23');
+ expect(total.projectedFrom).toBe('2026-09-23');
expect(total.points).toEqual([
{ date: '2026-09-01', value: 50000 },
{ date: '2026-09-10', value: 70000 },
diff --git a/src/lib/money-tracker/charts.ts b/src/lib/money-tracker/charts.ts
index 6ba2a60..8477dc4 100644
--- a/src/lib/money-tracker/charts.ts
+++ b/src/lib/money-tracker/charts.ts
@@ -15,8 +15,8 @@ export type ChartSeries = {
color: string;
points: ChartPoint[];
emphasis?: boolean;
- /** Points after this date are drawn dashed (projected). */
- dashedFrom?: string;
+ /** Points after this date are projected: drawn as a thinner, lighter solid line. */
+ projectedFrom?: string;
};
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
@@ -283,7 +283,7 @@ export function wealthProjection(
/**
* Thins long series for drawing: per time bucket only the lowest and highest point stay (in
* date order), so a daily projection over decades keeps its spikes without plotting every day.
- * The first and last points and the solid/dashed join are always kept.
+ * The first and last points and the actual/projected join are always kept.
*/
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
const limit = Math.max(2, Math.floor(buckets)) * 2;
@@ -301,8 +301,8 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
const keep = new Uint8Array(line.points.length);
keep[0] = 1;
keep[line.points.length - 1] = 1;
- if (line.dashedFrom) {
- const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!);
+ if (line.projectedFrom) {
+ const join = line.points.findLastIndex((point) => point.date <= line.projectedFrom!);
if (join >= 0) keep[join] = 1;
}
let bucket = -1;
@@ -334,7 +334,7 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
export type TimelineView = 'total' | 'split' | 'accounts';
/**
- * Joins actual history (solid, through today) with the wealth projection (dashed, after
+ * Joins actual history (solid, through today) with the wealth projection (thin solid line, after
* today) so both read as one continuous line per measure.
*/
export function wealthTimeline(
@@ -379,7 +379,7 @@ export function wealthTimeline(
label: 'Total',
color: 'var(--site-text)',
emphasis: true,
- dashedFrom: today,
+ projectedFrom: today,
points: join(history.at(-1)!.points, totalProjection?.points ?? [])
};
if (view === 'accounts')
@@ -395,13 +395,13 @@ export function wealthTimeline(
{
label: 'Spendable',
color: '#46a758',
- dashedFrom: today,
+ projectedFrom: today,
points: join(sumKind(false), spendableProjection?.points ?? [])
},
{
label: 'Invested',
color: '#8e4ec6',
- dashedFrom: today,
+ projectedFrom: today,
points: join(sumKind(true), investedProjection?.points ?? [])
}
];
diff --git a/src/lib/money-tracker/scenarios.test.ts b/src/lib/money-tracker/scenarios.test.ts
index 5c63cac..b09746a 100644
--- a/src/lib/money-tracker/scenarios.test.ts
+++ b/src/lib/money-tracker/scenarios.test.ts
@@ -189,6 +189,49 @@ describe('runScenario', () => {
expect(markets.yearReturn(2034).kind).toBe('correction');
expect(markets.yearReturn(2028).kind).toBe('normal');
});
+ it('keeps the optimistic start calm and its cycle after that', () => {
+ const markets = marketReturns(defaultScenarios.optimistic, 2026);
+ for (let year = 2026; year <= 2031; year++)
+ expect(markets.yearReturn(year)).toEqual({ kind: 'calm', rate: 0.095 });
+ expect(markets.yearReturn(2032).kind).toBe('normal');
+ expect(markets.yearReturn(2035)).toMatchObject({ kind: 'correction', rate: -0.15 });
+ expect(markets.yearReturn(2040)).toMatchObject({ kind: 'crash', rate: -0.38 });
+ // No down year at all in the first five years of the run.
+ const run = runScenario(inputs, defaultScenarios.optimistic);
+ const growth = run.years.filter((row) => row.year <= 2031).map((row) => row.invested);
+ growth.slice(1).forEach((value, index) => expect(value).toBeGreaterThan(growth[index]));
+ });
+ it('gives realistic one crash in its first ten years and keeps pessimistic as it was', () => {
+ const shocks = (id: 'realistic' | 'pessimistic') => {
+ const markets = marketReturns(defaultScenarios[id], 2026);
+ return Array.from({ length: 10 }, (_, offset) => 2026 + offset)
+ .map((year) => ({ year, ...markets.yearReturn(year) }))
+ .filter((row) => row.kind === 'crash');
+ };
+ expect(shocks('realistic')).toEqual([{ year: 2029, kind: 'crash', rate: -0.3 }]);
+ expect(shocks('pessimistic').map((row) => row.year)).toEqual([2027, 2035]);
+ expect(defaultScenarios.realistic.calmYears).toBe(0);
+ expect(defaultScenarios.pessimistic.calmYears).toBe(0);
+ // Without a start year the calm start never applies.
+ expect(marketReturns(defaultScenarios.optimistic).yearReturn(2030).kind).toBe('crash');
+ });
+ it('keeps the optimistic salary rising every working year', () => {
+ const long = { ...inputs, endDate: '2076-10-24' };
+ const years = runScenario(long, defaultScenarios.optimistic).years.filter(
+ (row) => row.year >= 2027 && row.role !== 'Retired'
+ );
+ expect(years.length).toBeGreaterThan(40);
+ years
+ .slice(1)
+ .forEach((row, index) => expect(row.baseReal).toBeGreaterThan(years[index].baseReal));
+ // A flat plateau stalls once it is reached.
+ const flatCap = runScenario(long, {
+ ...defaultScenarios.optimistic,
+ salaryCapGrowthPercent: 0
+ });
+ const late = flatCap.years.filter((row) => row.year >= 2050 && row.role !== 'Retired');
+ expect(late.at(-1)!.baseReal).toBeCloseTo(late[0].baseReal, -2);
+ });
it('hops jobs, caps the salary and retires at the chosen age', () => {
const long = { ...inputs, endDate: '2126-10-24' };
const result = runScenario(long, {
diff --git a/src/lib/money-tracker/scenarios.ts b/src/lib/money-tracker/scenarios.ts
index 6984965..0954e32 100644
--- a/src/lib/money-tracker/scenarios.ts
+++ b/src/lib/money-tracker/scenarios.ts
@@ -39,6 +39,11 @@ export type ScenarioParams = {
firstCrashYear: number;
/** Smaller correction halfway between crashes, percent (0 = none). */
correctionPercent: number;
+ /**
+ * Calm start: no crash or correction from this year through this many years ahead; those
+ * years earn the plain average return (0 = the crash cycle applies from the start).
+ */
+ calmYears: number;
/** Interest on the cash buffer, percent per year. */
cashRatePercent: number;
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
@@ -62,6 +67,8 @@ export type ScenarioParams = {
hopGapMonths: number;
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
salaryCapEuros: number;
+ /** Real growth of that plateau per year, percent (0 = flat once reached). */
+ salaryCapGrowthPercent: number;
/** Share of the contract's bonus estimate actually paid each August, 0–100. */
bonusPercentOfTarget: number;
/** Side income, gross today's euros per month, ramping over five years to the end value. */
@@ -97,6 +104,7 @@ export const defaultScenarios: Record