diff --git a/src/components/ScenarioContainer.js b/src/components/ScenarioContainer.js index 16e583b..8c2d14e 100644 --- a/src/components/ScenarioContainer.js +++ b/src/components/ScenarioContainer.js @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Line } from 'react-chartjs-2'; import { Chart as ChartJS } from 'chart.js'; import annotationPlugin from 'chartjs-plugin-annotation'; +import moment from 'moment'; import { Button } from './ui/button.js'; import authFetch from '../utils/authFetch.js'; @@ -34,13 +35,16 @@ export default function ScenarioContainer({ // Projection const [projectionData, setProjectionData] = useState([]); 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 const [interestStrategy, setInterestStrategy] = useState('NONE'); const [flatAnnualRate, setFlatAnnualRate] = useState(0.06); const [randomRangeMin, setRandomRangeMin] = useState(-0.02); const [randomRangeMax, setRandomRangeMax] = useState(0.02); + // The “milestone modal” for editing const [showMilestoneModal, setShowMilestoneModal] = useState(false); @@ -218,6 +222,17 @@ export default function ScenarioContainer({ }); 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 const scenarioOverrides = { @@ -311,7 +326,7 @@ export default function ScenarioContainer({ expectedSalary: collegeData.expectedSalary, startDate: (localScenario.start_date || new Date().toISOString().slice(0,10)), - simulationYears: simYears, + simulationYears: simYearsEngine, milestoneImpacts: allImpacts, @@ -321,17 +336,21 @@ export default function ScenarioContainer({ randomRangeMax }; - const { projectionData: pData, loanPaidOffMonth } = + const { projectionData: pData, loanPaidOffMonth, readinessScore:simReadiness, retirementAtMilestone } = simulateFinancialProjection(mergedProfile); + const sliceTo = simYearsUI * 12; // months we want to keep let cumulative = mergedProfile.emergencySavings || 0; - const finalData = pData.map((row) => { + + const finalData = pData.slice(0, sliceTo).map(row => { cumulative += row.netSavings || 0; return { ...row, cumulativeNetSavings: cumulative }; }); setProjectionData(finalData); setLoanPaidOffMonth(loanPaidOffMonth); + setReadinessScore(simReadiness); + setRetireBalAtMilestone(retirementAtMilestone); }, [ financialProfile, localScenario, @@ -392,6 +411,10 @@ export default function ScenarioContainer({ chartDatasets.push(totalSavingsData); const milestoneAnnotations = milestones + .filter((m) => // <-- filter FIRST + m.title === "Retirement" || + (impactsByMilestone[m.id] ?? []).length > 0 + ) .map((m) => { if (!m.date) return null; const d = new Date(m.date); @@ -407,13 +430,15 @@ export default function ScenarioContainer({ type: 'line', xMin: short, xMax: short, - borderColor: 'orange', + borderColor: m.title === 'Retirement' ? 'black' : 'orange', borderWidth: 2, label: { display: true, content: m.title || 'Milestone', - color: 'orange', - position: 'end' + backgroundColor: m.title === 'Retirement' ? 'black' : 'orange', + color: 'white', + position: 'end', + padding: 4 } }; }) @@ -908,8 +933,23 @@ export default function ScenarioContainer({
Loan Paid Off: {loanPaidOffMonth || 'N/A'}
Final Retirement:{' '} - {Math.round( - projectionData[projectionData.length - 1].retirementSavings + {Math.round(retireBalAtMilestone)} + {readinessScore != null && ( + = 80 ? '#15803d' + : readinessScore >= 60 ? '#ca8a04' : '#dc2626', + color: '#fff' + }} + > + {readinessScore}/100 + )}
)} diff --git a/src/components/ScenarioEditModal.js b/src/components/ScenarioEditModal.js index 52766b6..cea2b57 100644 --- a/src/components/ScenarioEditModal.js +++ b/src/components/ScenarioEditModal.js @@ -512,6 +512,35 @@ async function handleSave() { if (!scenRes.ok) throw new Error(await scenRes.text()); 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… ─ */ /* ─── 4) update localStorage so CareerRoadmap re-hydrates ─ */ diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js index 6f8cd60..bddfb9c 100644 --- a/src/utils/FinancialProjectionService.js +++ b/src/utils/FinancialProjectionService.js @@ -202,8 +202,10 @@ const randomRangeMax = num(_randomRangeMax); * 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 retirementStartISO = _retStart ? moment(_retStart).startOf('month') @@ -358,9 +360,14 @@ function simulateDrawdown(opts){ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) { const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months'); - const reachedRetirement = - retirementSpendMonthly > 0 && - currentSimDate.isSameOrAfter(retirementStartISO); + if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) { + reachedRetirement = true; + firstRetirementBalance = currentRetirementSavings; // capture once + } + + const isRetiredThisMonth = + retirementSpendMonthly > 0 && + currentSimDate.isSameOrAfter(retirementStartISO) // figure out if we are in the college window let stillInCollege = false; @@ -506,8 +513,8 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax let leftover = netMonthlyIncome - totalMonthlyExpenses; // baseline contributions - const monthlyRetContrib = reachedRetirement ? 0 : monthlyRetirementContribution; - const monthlyEmergContrib = reachedRetirement ? 0 : monthlyEmergencyContribution; + const monthlyRetContrib = isRetiredThisMonth ? 0 : monthlyRetirementContribution; + const monthlyEmergContrib = isRetiredThisMonth ? 0 : monthlyEmergencyContribution; const baselineContributions = monthlyRetContrib + monthlyEmergContrib; let effectiveRetirementContribution = 0; let effectiveEmergencyContribution = 0; @@ -585,6 +592,9 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax wasInDeferral = (stillInCollege && loanDeferralUntilGraduation); } + if (!firstRetirementBalance && reachedRetirement) { + firstRetirementBalance = currentRetirementSavings; +} // final loanPaidOffMonth if never set if (loanBalance <= 0 && !loanPaidOffMonth) { @@ -611,9 +621,27 @@ const yearsCovered = Math.round(monthsCovered / 12); /* ---- 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 { projectionData, loanPaidOffMonth, + readinessScore, + retirementAtMilestone : firstRetirementBalance ?? 0, yearsCovered, // <-- add these two monthsCovered, // (or just yearsCovered if that’s all you need) finalEmergencySavings: +currentEmergencySavings.toFixed(2),