MultiScnearioView Final Retirement number and readiness score.
This commit is contained in:
parent
38088ac38b
commit
4af7300117
@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { Chart as ChartJS } from 'chart.js';
|
import { Chart as ChartJS } from 'chart.js';
|
||||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
import authFetch from '../utils/authFetch.js';
|
import authFetch from '../utils/authFetch.js';
|
||||||
@ -34,7 +35,9 @@ export default function ScenarioContainer({
|
|||||||
// Projection
|
// Projection
|
||||||
const [projectionData, setProjectionData] = useState([]);
|
const [projectionData, setProjectionData] = useState([]);
|
||||||
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
|
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
|
||||||
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
const [simulationYearsInput, setSimulationYearsInput] = useState('50');
|
||||||
|
const [readinessScore, setReadinessScore] = useState(null);
|
||||||
|
const [retireBalAtMilestone, setRetireBalAtMilestone] = useState(0);
|
||||||
|
|
||||||
// Interest
|
// Interest
|
||||||
const [interestStrategy, setInterestStrategy] = useState('NONE');
|
const [interestStrategy, setInterestStrategy] = useState('NONE');
|
||||||
@ -42,6 +45,7 @@ export default function ScenarioContainer({
|
|||||||
const [randomRangeMin, setRandomRangeMin] = useState(-0.02);
|
const [randomRangeMin, setRandomRangeMin] = useState(-0.02);
|
||||||
const [randomRangeMax, setRandomRangeMax] = useState(0.02);
|
const [randomRangeMax, setRandomRangeMax] = useState(0.02);
|
||||||
|
|
||||||
|
|
||||||
// The “milestone modal” for editing
|
// The “milestone modal” for editing
|
||||||
const [showMilestoneModal, setShowMilestoneModal] = useState(false);
|
const [showMilestoneModal, setShowMilestoneModal] = useState(false);
|
||||||
|
|
||||||
@ -218,6 +222,17 @@ export default function ScenarioContainer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const simYears = parseInt(simulationYearsInput, 10) || 20;
|
const simYears = parseInt(simulationYearsInput, 10) || 20;
|
||||||
|
const simYearsUI = Math.max(1, parseInt(simulationYearsInput, 10) || 20);
|
||||||
|
|
||||||
|
const yearsUntilRet = localScenario.retirement_start_date
|
||||||
|
? Math.ceil(
|
||||||
|
moment(localScenario.retirement_start_date)
|
||||||
|
.startOf('month')
|
||||||
|
.diff(moment().startOf('month'), 'months') / 12
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const simYearsEngine = Math.max(simYearsUI, yearsUntilRet + 1);
|
||||||
|
|
||||||
// scenario overrides
|
// scenario overrides
|
||||||
const scenarioOverrides = {
|
const scenarioOverrides = {
|
||||||
@ -311,7 +326,7 @@ export default function ScenarioContainer({
|
|||||||
expectedSalary: collegeData.expectedSalary,
|
expectedSalary: collegeData.expectedSalary,
|
||||||
|
|
||||||
startDate: (localScenario.start_date || new Date().toISOString().slice(0,10)),
|
startDate: (localScenario.start_date || new Date().toISOString().slice(0,10)),
|
||||||
simulationYears: simYears,
|
simulationYears: simYearsEngine,
|
||||||
|
|
||||||
milestoneImpacts: allImpacts,
|
milestoneImpacts: allImpacts,
|
||||||
|
|
||||||
@ -321,17 +336,21 @@ export default function ScenarioContainer({
|
|||||||
randomRangeMax
|
randomRangeMax
|
||||||
};
|
};
|
||||||
|
|
||||||
const { projectionData: pData, loanPaidOffMonth } =
|
const { projectionData: pData, loanPaidOffMonth, readinessScore:simReadiness, retirementAtMilestone } =
|
||||||
simulateFinancialProjection(mergedProfile);
|
simulateFinancialProjection(mergedProfile);
|
||||||
|
|
||||||
|
const sliceTo = simYearsUI * 12; // months we want to keep
|
||||||
let cumulative = mergedProfile.emergencySavings || 0;
|
let cumulative = mergedProfile.emergencySavings || 0;
|
||||||
const finalData = pData.map((row) => {
|
|
||||||
|
const finalData = pData.slice(0, sliceTo).map(row => {
|
||||||
cumulative += row.netSavings || 0;
|
cumulative += row.netSavings || 0;
|
||||||
return { ...row, cumulativeNetSavings: cumulative };
|
return { ...row, cumulativeNetSavings: cumulative };
|
||||||
});
|
});
|
||||||
|
|
||||||
setProjectionData(finalData);
|
setProjectionData(finalData);
|
||||||
setLoanPaidOffMonth(loanPaidOffMonth);
|
setLoanPaidOffMonth(loanPaidOffMonth);
|
||||||
|
setReadinessScore(simReadiness);
|
||||||
|
setRetireBalAtMilestone(retirementAtMilestone);
|
||||||
}, [
|
}, [
|
||||||
financialProfile,
|
financialProfile,
|
||||||
localScenario,
|
localScenario,
|
||||||
@ -392,6 +411,10 @@ export default function ScenarioContainer({
|
|||||||
chartDatasets.push(totalSavingsData);
|
chartDatasets.push(totalSavingsData);
|
||||||
|
|
||||||
const milestoneAnnotations = milestones
|
const milestoneAnnotations = milestones
|
||||||
|
.filter((m) => // <-- filter FIRST
|
||||||
|
m.title === "Retirement" ||
|
||||||
|
(impactsByMilestone[m.id] ?? []).length > 0
|
||||||
|
)
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
if (!m.date) return null;
|
if (!m.date) return null;
|
||||||
const d = new Date(m.date);
|
const d = new Date(m.date);
|
||||||
@ -407,13 +430,15 @@ export default function ScenarioContainer({
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
xMin: short,
|
xMin: short,
|
||||||
xMax: short,
|
xMax: short,
|
||||||
borderColor: 'orange',
|
borderColor: m.title === 'Retirement' ? 'black' : 'orange',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
label: {
|
label: {
|
||||||
display: true,
|
display: true,
|
||||||
content: m.title || 'Milestone',
|
content: m.title || 'Milestone',
|
||||||
color: 'orange',
|
backgroundColor: m.title === 'Retirement' ? 'black' : 'orange',
|
||||||
position: 'end'
|
color: 'white',
|
||||||
|
position: 'end',
|
||||||
|
padding: 4
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -908,8 +933,23 @@ export default function ScenarioContainer({
|
|||||||
<div style={{ marginTop: '0.5rem' }}>
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
|
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
|
||||||
<strong>Final Retirement:</strong>{' '}
|
<strong>Final Retirement:</strong>{' '}
|
||||||
{Math.round(
|
{Math.round(retireBalAtMilestone)}
|
||||||
projectionData[projectionData.length - 1].retirementSavings
|
{readinessScore != null && (
|
||||||
|
<span
|
||||||
|
title="Retirement income / desired"
|
||||||
|
style={{
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
padding: '0 6px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
background:
|
||||||
|
readinessScore >= 80 ? '#15803d'
|
||||||
|
: readinessScore >= 60 ? '#ca8a04' : '#dc2626',
|
||||||
|
color: '#fff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{readinessScore}/100
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -512,6 +512,35 @@ async function handleSave() {
|
|||||||
if (!scenRes.ok) throw new Error(await scenRes.text());
|
if (!scenRes.ok) throw new Error(await scenRes.text());
|
||||||
const { career_profile_id } = await scenRes.json();
|
const { career_profile_id } = await scenRes.json();
|
||||||
|
|
||||||
|
// ─── AUTO-CREATE / UPDATE “Retirement” milestone ──────────────────
|
||||||
|
if (formData.retirement_start_date) {
|
||||||
|
const payload = {
|
||||||
|
title : 'Retirement',
|
||||||
|
description : 'User-defined retirement date (auto-generated)',
|
||||||
|
date : formData.retirement_start_date,
|
||||||
|
career_profile_id: career_profile_id,
|
||||||
|
progress : 0,
|
||||||
|
status : 'planned',
|
||||||
|
is_universal : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ask the backend if one already exists for this scenario
|
||||||
|
const check = await authFetch(
|
||||||
|
`/api/premium/milestones?careerProfileId=${career_profile_id}`
|
||||||
|
).then(r => r.json());
|
||||||
|
|
||||||
|
const existing = (check.milestones || []).find(m => m.title === 'Retirement');
|
||||||
|
|
||||||
|
await authFetch(
|
||||||
|
existing ? `/api/premium/milestones/${existing.id}` : '/api/premium/milestone',
|
||||||
|
{
|
||||||
|
method : existing ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body : JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── 3) (optional) upsert college profile – keep yours… ─ */
|
/* ─── 3) (optional) upsert college profile – keep yours… ─ */
|
||||||
|
|
||||||
/* ─── 4) update localStorage so CareerRoadmap re-hydrates ─ */
|
/* ─── 4) update localStorage so CareerRoadmap re-hydrates ─ */
|
||||||
|
@ -202,8 +202,10 @@ const randomRangeMax = num(_randomRangeMax);
|
|||||||
* 2) CLAMP THE SCENARIO START TO MONTH-BEGIN
|
* 2) CLAMP THE SCENARIO START TO MONTH-BEGIN
|
||||||
***************************************************/
|
***************************************************/
|
||||||
|
|
||||||
const scenarioStartClamped = moment(startDate || new Date()).startOf('month');
|
const scenarioStartClamped = moment().startOf('month'); // always “today”
|
||||||
|
|
||||||
|
let reachedRetirement = false; // flip to true once we hit the month
|
||||||
|
let firstRetirementBalance = null; // snapshot of balance in that first month
|
||||||
const retirementSpendMonthly = num(_retSpend);
|
const retirementSpendMonthly = num(_retSpend);
|
||||||
const retirementStartISO =
|
const retirementStartISO =
|
||||||
_retStart ? moment(_retStart).startOf('month')
|
_retStart ? moment(_retStart).startOf('month')
|
||||||
@ -358,9 +360,14 @@ function simulateDrawdown(opts){
|
|||||||
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
|
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
|
||||||
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
|
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
|
||||||
|
|
||||||
const reachedRetirement =
|
if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) {
|
||||||
retirementSpendMonthly > 0 &&
|
reachedRetirement = true;
|
||||||
currentSimDate.isSameOrAfter(retirementStartISO);
|
firstRetirementBalance = currentRetirementSavings; // capture once
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRetiredThisMonth =
|
||||||
|
retirementSpendMonthly > 0 &&
|
||||||
|
currentSimDate.isSameOrAfter(retirementStartISO)
|
||||||
|
|
||||||
// figure out if we are in the college window
|
// figure out if we are in the college window
|
||||||
let stillInCollege = false;
|
let stillInCollege = false;
|
||||||
@ -506,8 +513,8 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
|
|||||||
let leftover = netMonthlyIncome - totalMonthlyExpenses;
|
let leftover = netMonthlyIncome - totalMonthlyExpenses;
|
||||||
|
|
||||||
// baseline contributions
|
// baseline contributions
|
||||||
const monthlyRetContrib = reachedRetirement ? 0 : monthlyRetirementContribution;
|
const monthlyRetContrib = isRetiredThisMonth ? 0 : monthlyRetirementContribution;
|
||||||
const monthlyEmergContrib = reachedRetirement ? 0 : monthlyEmergencyContribution;
|
const monthlyEmergContrib = isRetiredThisMonth ? 0 : monthlyEmergencyContribution;
|
||||||
const baselineContributions = monthlyRetContrib + monthlyEmergContrib;
|
const baselineContributions = monthlyRetContrib + monthlyEmergContrib;
|
||||||
let effectiveRetirementContribution = 0;
|
let effectiveRetirementContribution = 0;
|
||||||
let effectiveEmergencyContribution = 0;
|
let effectiveEmergencyContribution = 0;
|
||||||
@ -585,6 +592,9 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
|
|||||||
wasInDeferral = (stillInCollege && loanDeferralUntilGraduation);
|
wasInDeferral = (stillInCollege && loanDeferralUntilGraduation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!firstRetirementBalance && reachedRetirement) {
|
||||||
|
firstRetirementBalance = currentRetirementSavings;
|
||||||
|
}
|
||||||
|
|
||||||
// final loanPaidOffMonth if never set
|
// final loanPaidOffMonth if never set
|
||||||
if (loanBalance <= 0 && !loanPaidOffMonth) {
|
if (loanBalance <= 0 && !loanPaidOffMonth) {
|
||||||
@ -611,9 +621,27 @@ const yearsCovered = Math.round(monthsCovered / 12);
|
|||||||
|
|
||||||
/* ---- 9) RETURN ------------------------------------------------ */
|
/* ---- 9) RETURN ------------------------------------------------ */
|
||||||
|
|
||||||
|
/* 9.1 » Readiness Score (0-100, whole-number) */
|
||||||
|
const safeWithdrawalRate = flatAnnualRate; // 6 % for now (plug 0.04 if you prefer)
|
||||||
|
const projectedAnnualFlow = (firstRetirementBalance ?? currentRetirementSavings)
|
||||||
|
* safeWithdrawalRate;
|
||||||
|
const desiredAnnualIncome = retirementSpendMonthly // user override
|
||||||
|
? retirementSpendMonthly * 12
|
||||||
|
: monthlyExpenses * 12; // fallback to current spend
|
||||||
|
|
||||||
|
const readinessScore = desiredAnnualIncome > 0
|
||||||
|
? Math.min(
|
||||||
|
100,
|
||||||
|
Math.round((projectedAnnualFlow / desiredAnnualIncome) * 100)
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectionData,
|
projectionData,
|
||||||
loanPaidOffMonth,
|
loanPaidOffMonth,
|
||||||
|
readinessScore,
|
||||||
|
retirementAtMilestone : firstRetirementBalance ?? 0,
|
||||||
yearsCovered, // <-- add these two
|
yearsCovered, // <-- add these two
|
||||||
monthsCovered, // (or just yearsCovered if that’s all you need)
|
monthsCovered, // (or just yearsCovered if that’s all you need)
|
||||||
finalEmergencySavings: +currentEmergencySavings.toFixed(2),
|
finalEmergencySavings: +currentEmergencySavings.toFixed(2),
|
||||||
|
Loading…
Reference in New Issue
Block a user