Skip to content

Commit e85de61

Browse files
Merge pull request #172 from briandadams85/feature/coast-fi
feat: add Coast FI calculation and display
2 parents 17ea70a + dd17c47 commit e85de61

8 files changed

Lines changed: 229 additions & 6 deletions

File tree

src/app/forecasting/input/ynab/ynab.component.html

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,47 @@ <h2 ngbAccordionHeader>
365365
</div>
366366
</div>
367367
</div>
368+
<div class="row">
369+
<label
370+
for="targetRetirementAge"
371+
class="col-sm-12 col-md-5 col-form-label"
372+
>Target Retirement Age</label
373+
>
374+
<div class="col">
375+
<div class="input-group">
376+
<input
377+
type="number"
378+
class="form-control"
379+
id="targetRetirementAge"
380+
formControlName="targetRetirementAge"
381+
min="18"
382+
max="120"
383+
step="1"
384+
/>
385+
</div>
386+
</div>
387+
</div>
388+
<div class="row mb-3">
389+
<div class="col">
390+
<small
391+
>Used for Coast FI calculation. Coast FI is the amount
392+
needed today where investment growth alone reaches your FI
393+
number by your target retirement age.</small
394+
>
395+
<br />
396+
<small>
397+
<a
398+
target="_blank"
399+
href="https://www.financialsamurai.com/what-is-coast-fire-financial-independence-retire-early/"
400+
>What Is Coast FIRE And Is It The Right Retirement Path For You? - Financial Samurai
401+
<fa-icon
402+
[icon]="['fas', 'external-link-square-alt']"
403+
size="sm"
404+
></fa-icon>
405+
</a>
406+
</small>
407+
</div>
408+
</div>
368409
</div>
369410
</ng-template>
370411
</div>

src/app/forecasting/input/ynab/ynab.component.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export class YnabComponent implements OnInit {
4646
public expectedAnnualGrowthRate = 7.0;
4747
public expectedExternalAnnualContributions = 0;
4848
public additionalLumpSumNeeded = 0;
49+
public targetRetirementAge: number | null = 65;
4950

5051
public budgets: ynab.BudgetSummary[];
5152
public budget: ynab.BudgetDetail;
@@ -153,6 +154,17 @@ export class YnabComponent implements OnInit {
153154
this.birthdate = null;
154155
}
155156

157+
const targetRetirementAgeRaw = window.localStorage.getItem('br4-target-retirement-age');
158+
if (targetRetirementAgeRaw === '') {
159+
// User explicitly cleared the value
160+
this.targetRetirementAge = null;
161+
} else if (targetRetirementAgeRaw !== null) {
162+
const parsed = parseFloat(targetRetirementAgeRaw);
163+
if (!isNaN(parsed)) {
164+
this.targetRetirementAge = parsed;
165+
}
166+
}
167+
156168
// Load scheduled changes state from localStorage
157169
try {
158170
const storedState = JSON.parse(
@@ -219,6 +231,10 @@ export class YnabComponent implements OnInit {
219231
this.expectedExternalAnnualContributions,
220232
],
221233
additionalLumpSumNeeded: [this.additionalLumpSumNeeded],
234+
targetRetirementAge: [
235+
this.targetRetirementAge,
236+
[Validators.required, Validators.min(18), Validators.max(120)],
237+
],
222238
});
223239
}
224240

@@ -338,6 +354,9 @@ export class YnabComponent implements OnInit {
338354
this.expectedExternalAnnualContributions,
339355
);
340356
result.additionalLumpSumNeeded = Math.max(0, this.additionalLumpSumNeeded);
357+
result.targetRetirementAge = this.targetRetirementAge !== null
358+
? Math.max(18, Math.min(120, this.targetRetirementAge))
359+
: null;
341360

342361
result.roundAll();
343362
this.calculateInputChange.emit(result);
@@ -496,6 +515,21 @@ export class YnabComponent implements OnInit {
496515
parsedAdditionalLumpSum.toString(),
497516
);
498517
}
518+
519+
const parsedTargetRetirementAge = Number.parseFloat(
520+
this.budgetForm.value.targetRetirementAge,
521+
);
522+
if (!Number.isNaN(parsedTargetRetirementAge)) {
523+
this.targetRetirementAge = parsedTargetRetirementAge;
524+
window.localStorage.setItem(
525+
'br4-target-retirement-age',
526+
parsedTargetRetirementAge.toString(),
527+
);
528+
} else {
529+
this.targetRetirementAge = null;
530+
// Store empty string to remember user explicitly cleared the value
531+
window.localStorage.setItem('br4-target-retirement-age', '');
532+
}
499533
}
500534

501535
handleFormChanges() {
@@ -1149,6 +1183,7 @@ export class YnabComponent implements OnInit {
11491183
expectedExternalAnnualContributions:
11501184
this.expectedExternalAnnualContributions,
11511185
additionalLumpSumNeeded: this.additionalLumpSumNeeded,
1186+
targetRetirementAge: this.targetRetirementAge,
11521187
});
11531188

11541189
const categoryGroupFormGroups = categoriesDisplay.map((cg) =>

src/app/forecasting/models/calculate-input.model.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export class CalculateInput {
1717
birthdate: Birthdate = null;
1818
expectedExternalAnnualContributions = 0;
1919
additionalLumpSumNeeded = 0;
20+
targetRetirementAge: number | null = null;
2021

2122
// Time series for dynamic values
2223
monthlyContributionSeries: TimeSeries | null = null;
@@ -70,6 +71,54 @@ export class CalculateInput {
7071
return baseLeanFi;
7172
}
7273

74+
/**
75+
* Get the Coast FI number at a specific date.
76+
* Coast FI is the amount needed today where, with zero future contributions,
77+
* investment growth alone reaches your FI number by a target retirement age.
78+
*
79+
* Formula: coastFiNumber = fiNumber / (1 + annualGrowthRate)^yearsToTarget
80+
*
81+
* @param atDate The date to calculate Coast FI at
82+
* @returns The Coast FI number, or null if birthdate is not set
83+
*/
84+
getCoastFiNumberAt(atDate: Date): number | null {
85+
if (!this.birthdate || this.targetRetirementAge === null) {
86+
return null;
87+
}
88+
89+
const birthdate = new Date(
90+
this.birthdate.year,
91+
this.birthdate.month - 1,
92+
this.birthdate.day
93+
);
94+
95+
// Calculate current age in years
96+
const ageInMs = atDate.getTime() - birthdate.getTime();
97+
const ageInYears = ageInMs / (1000 * 60 * 60 * 24 * 365.25);
98+
99+
// Calculate years to target retirement age
100+
const yearsToTarget = this.targetRetirementAge - ageInYears;
101+
102+
// If already at or past target age, Coast FI equals FI number
103+
if (yearsToTarget <= 0) {
104+
return this.fiNumber;
105+
}
106+
107+
// Coast FI = FI Number / (1 + growth rate)^years
108+
const coastFi =
109+
this.fiNumber / Math.pow(1 + this.expectedAnnualGrowthRate, yearsToTarget);
110+
111+
return round(coastFi);
112+
}
113+
114+
/**
115+
* Get the Coast FI number based on current date.
116+
* Returns null if birthdate is not set.
117+
*/
118+
get coastFiNumber(): number | null {
119+
return this.getCoastFiNumberAt(new Date());
120+
}
121+
73122
/**
74123
* Get the monthly contribution at a specific month.
75124
* Falls back to the static value if no time series is available.

src/app/forecasting/output/fi-text/fi-text.component.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ <h4>{{ leanFiDate }} • {{ leanFiDateDistance }}
1717
</h4>
1818
</li>
1919
</ul>
20+
<hr *ngIf="coastFiNumber !== null" />
21+
<ul class="list-inline mb-0" *ngIf="coastFiNumber !== null">
22+
<li class="list-inline-item">
23+
<h5 class="fw-light">Coast FI</h5>
24+
</li>
25+
<li class="list-inline-item"></li>
26+
<li class="list-inline-item">
27+
<h4>{{ coastFiDate }} • {{ coastFiDateDistance }}
28+
<span *ngIf="coastFiAge !== null" class="fst-italic">• {{ coastFiAge }} old</span>
29+
</h4>
30+
</li>
31+
</ul>
2032
</div>
2133
<div class="pb-1">
2234
<p>
@@ -71,5 +83,23 @@ <h4>{{ leanFiDate }} • {{ leanFiDateDistance }}
7183
is earned returns.
7284
</span>
7385
</p>
86+
<p *ngIf="coastFiNumber !== null">
87+
<strong>Coast FI</strong> is the amount needed today where, with zero
88+
future contributions, investment growth alone reaches your FI number by
89+
age <strong>{{ targetRetirementAge }}</strong
90+
>. Your Coast FI number is
91+
<strong>{{
92+
coastFiNumber | currency: calculateInput.currencyIsoCode
93+
}}</strong
94+
>. Once you reach Coast FI, you could theoretically stop saving and still
95+
reach FI by your target retirement age.
96+
<span *ngIf="coastFiDate !== 'Never'">
97+
You are forecasted to reach Coast FI on <strong>{{ coastFiDate }}</strong
98+
>; {{ coastFiDateDistance }} from now.
99+
<span *ngIf="coastFiAge !== null"
100+
>You'll be <strong>{{ coastFiAge }}</strong> old at that time.</span
101+
>
102+
</span>
103+
</p>
74104
</div>
75105
</div>

src/app/forecasting/output/fi-text/fi-text.component.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export class FiTextComponent implements OnInit, OnChanges {
3434
leanFiDateDistance: string;
3535
leanFiAge: string;
3636

37+
coastFiNumber: number;
38+
coastFiDate: string;
39+
coastFiDateDistance: string;
40+
coastFiAge: string;
41+
targetRetirementAge: number;
42+
3743
externalContributions: number;
3844
externalContributionReduction: number;
3945
additionalLumpSum: number;
@@ -110,5 +116,44 @@ export class FiTextComponent implements OnInit, OnChanges {
110116
)
111117
: null;
112118
}
119+
120+
// Coast FI calculation - only show if birthdate is set
121+
this.targetRetirementAge = this.calculateInput.targetRetirementAge;
122+
const coastFiNumber = this.calculateInput.coastFiNumber;
123+
124+
if (coastFiNumber === null) {
125+
// No birthdate set - hide Coast FI
126+
this.coastFiNumber = null;
127+
this.coastFiDate = null;
128+
this.coastFiDateDistance = null;
129+
this.coastFiAge = null;
130+
} else {
131+
const roundedCoastFi = Math.max(0, round(coastFiNumber));
132+
this.coastFiNumber = roundedCoastFi;
133+
134+
// Find first month where net worth >= Coast FI (using today's Coast FI value)
135+
const foundCoastFiForecast = this.forecast.monthlyForecasts.find(
136+
(f) => f.netWorth >= roundedCoastFi
137+
);
138+
139+
if (!foundCoastFiForecast) {
140+
this.coastFiDate = 'Never';
141+
this.coastFiDateDistance = 'Forever';
142+
this.coastFiAge = undefined;
143+
} else {
144+
this.coastFiDate = foundCoastFiForecast.toDateString();
145+
this.coastFiDateDistance =
146+
this.forecast.getDistanceFromFirstMonthText(
147+
foundCoastFiForecast.date
148+
) || '0 Months';
149+
this.coastFiAge =
150+
birthdate && !isNaN(birthdate.getTime())
151+
? this.forecast.getDistanceFromDateText(
152+
foundCoastFiForecast.date,
153+
birthdate
154+
)
155+
: null;
156+
}
157+
}
113158
}
114159
}

src/app/forecasting/output/milestones/milestone.model.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,24 @@ export class Milestones {
55

66
public constructor(
77
public fiNumber: number,
8-
public leanFiNumber,
9-
public eclipseNumber
8+
public leanFiNumber: number,
9+
public eclipseNumber: number,
10+
public coastFiNumber: number | null = null
1011
) {
1112
this.milestones = this.calculateMilestones(
1213
fiNumber,
1314
leanFiNumber,
14-
eclipseNumber
15+
eclipseNumber,
16+
coastFiNumber
1517
);
1618
}
1719

18-
private calculateMilestones(fiNumber, leanFiNumber, eclipseNumber) {
20+
private calculateMilestones(
21+
fiNumber: number,
22+
leanFiNumber: number,
23+
eclipseNumber: number,
24+
coastFiNumber: number | null
25+
) {
1926
const milestones: Milestone[] = [
2027
new Milestone('FU$', round(fiNumber * 0.1)), // 2.5
2128
new Milestone('Half FI', round(fiNumber * 0.5)), // 12.5
@@ -27,6 +34,9 @@ export class Milestones {
2734
if (fiNumber !== leanFiNumber) {
2835
milestones.push(new Milestone('Lean FI', round(leanFiNumber))); // 0.7 * FI)
2936
}
37+
if (coastFiNumber !== null && coastFiNumber > 0) {
38+
milestones.push(new Milestone('Coast FI', round(coastFiNumber)));
39+
}
3040
if (eclipseNumber) {
3141
milestones.push(
3242
new Milestone('Contribution / Returns Eclipse', round(eclipseNumber))

src/app/forecasting/output/milestones/milestones.component.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,24 @@ export class MilestonesComponent implements OnInit, OnChanges {
3232
const eclipseForecast = this.forecast.monthlyForecasts.find(m => {
3333
return m.totalContributions <= m.totalReturns;
3434
});
35+
const coastFiNumber = this.calculateInput.coastFiNumber;
36+
3537
if (!eclipseForecast) {
36-
this.milestones = new Milestones(this.calculateInput.fiNumber, this.calculateInput.leanFiNumber, 0);
38+
this.milestones = new Milestones(
39+
this.calculateInput.fiNumber,
40+
this.calculateInput.leanFiNumber,
41+
0,
42+
coastFiNumber
43+
);
3744
return;
3845
}
3946

4047
const eclipseMarker = Math.min(eclipseForecast.totalContributions, eclipseForecast.totalReturns);
41-
this.milestones = new Milestones(this.calculateInput.fiNumber, this.calculateInput.leanFiNumber, eclipseMarker * 2);
48+
this.milestones = new Milestones(
49+
this.calculateInput.fiNumber,
50+
this.calculateInput.leanFiNumber,
51+
eclipseMarker * 2,
52+
coastFiNumber
53+
);
4254
}
4355
}

src/app/forecasting/output/milestones/text/text.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export class TextComponent implements OnInit, OnChanges {
8787
'FU$': 'F-You Money: 10% of your FI number (2.5x annual expenses)',
8888
'Half FI': 'Halfway to Financial Independence (12.5x annual expenses)',
8989
'Lean FI': 'Financially independent with a lean budget',
90+
'Coast FI': 'The amount where investment growth alone reaches FI by your target retirement age',
9091
'Flex FI': 'Flexible FI: 80% of your FI number (20x annual expenses)',
9192
'FI': 'Financial Independence: 25x annual expenses using the 4% rule',
9293
'Fat FI': 'Fat FI: 120% of your FI number (30x annual expenses)',

0 commit comments

Comments
 (0)