MultiScnearioView Final Retirement number and readiness score.

This commit is contained in:
Josh 2025-06-25 14:10:51 +00:00
parent 38088ac38b
commit 4af7300117
3 changed files with 112 additions and 15 deletions

View File

@ -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>
)} )}

View File

@ -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 ─ */

View File

@ -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 thats all you need) monthsCovered, // (or just yearsCovered if thats all you need)
finalEmergencySavings: +currentEmergencySavings.toFixed(2), finalEmergencySavings: +currentEmergencySavings.toFixed(2),