import React, { useState, useEffect, useRef, useMemo, useCallback, useContext } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { Line, Bar } from 'react-chartjs-2'; import { format } from 'date-fns'; // β¬… install if not already import zoomPlugin from 'chartjs-plugin-zoom'; import axios from 'axios'; import { Chart as ChartJS, LineElement, BarElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, TimeScale, Legend } from 'chart.js'; import annotationPlugin from 'chartjs-plugin-annotation'; import MilestonePanel from './MilestonePanel.js'; import MilestoneDrawer from './MilestoneDrawer.js'; import MilestoneEditModal from './MilestoneEditModal.js'; import buildChartMarkers from '../utils/buildChartMarkers.js'; import getMissingFields from '../utils/getMissingFields.js'; import 'chartjs-adapter-date-fns'; import authFetch from '../utils/authFetch.js'; import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; import parseFloatOrZero from '../utils/ParseFloatorZero.js'; import { getFullStateName } from '../utils/stateUtils.js'; import CareerCoach from "./CareerCoach.js"; import ChatCtx from '../contexts/ChatCtx.js'; import { Button } from './ui/button.js'; import { Pencil } from 'lucide-react'; import ScenarioEditModal from './ScenarioEditModal.js'; import parseAIJson from "../utils/parseAIJson.js"; // your shared parser import InfoTooltip from "./ui/infoTooltip.js"; import differenceInMonths from 'date-fns/differenceInMonths'; import "../styles/legacy/MilestoneTimeline.legacy.css"; // -------------- // Register ChartJS Plugins // -------------- ChartJS.register( LineElement, BarElement, CategoryScale, LinearScale, TimeScale, Filler, PointElement, Tooltip, Legend, zoomPlugin, // πŸ‘ˆ ←–––– only if you kept the zoom config annotationPlugin ); /* ----------------------------------------------------------- * * Helpers for β€œremember last career” logic * ----------------------------------------------------------- */ // (A) getAllCareerProfiles – one small wrapper around the endpoint async function getAllCareerProfiles() { const res = await authFetch('/api/premium/career-profile/all'); if (!res.ok) throw new Error('career-profile/all failed'); const json = await res.json(); return json.careerProfiles || []; } // (B) createCareerProfileFromSearch – called when user chose a SOC with // no existing career-profile row. Feel free to add more fields. async function createCareerProfileFromSearch(selCareer) { const careerName = (selCareer.title || '').trim(); if (!careerName) { throw new Error('createCareerProfileFromSearch: selCareer.title is required'); } /* ----------------------------------------------------------- * 1) Do we already have that title? * --------------------------------------------------------- */ const all = await getAllCareerProfiles(); // wrapper uses authFetch const existing = all.find( p => (p.career_name || '').trim().toLowerCase() === careerName.toLowerCase() ); if (existing) return existing; // βœ… reuse the row / id /* ----------------------------------------------------------- * 2) Otherwise create it and refetch the full row * --------------------------------------------------------- */ const payload = { career_name : careerName, scenario_title: careerName, start_date : new Date().toISOString().slice(0, 10) }; const post = await authFetch('/api/premium/career-profile', { method : 'POST', headers: { 'Content-Type': 'application/json' }, body : JSON.stringify(payload) }); if (!post.ok) { throw new Error(`career-profile create failed (${post.status})`); } const { career_profile_id: newId } = await post.json(); if (!newId) throw new Error('server did not return career_profile_id'); const get = await authFetch(`/api/premium/career-profile/${newId}`); if (get.ok) return await get.json(); // full row with every column // Extremely rare fallback return { id: newId, career_name: careerName, scenario_title: careerName }; } // -------------- // Helper Functions // -------------- function shouldSkipModalOnce(profileId) { const key = `skipMissingModalFor`; const stored = sessionStorage.getItem(key); if (stored && stored === String(profileId)) { sessionStorage.removeItem(key); // one-time use return true; } return false; } /* ---------- helper: "&" ↔ "and", collapse spaces, etc. ---------- */ function normalizeTitle(str = '') { return str .toLowerCase() .replace(/\s*&\s*/g, ' and ') // β€œfoo & bar” β†’ β€œfoo and bar” .replace(/[–—]/g, '-') // long dashes β†’ plain hyphen .replace(/\s+/g, ' ') // squeeze double-spaces .trim(); } function stripSocCode(fullSoc) { if (!fullSoc) return ''; return fullSoc.split('.')[0]; } function getRelativePosition(userSal, p10, p90) { if (!p10 || !p90) return 0; if (userSal < p10) return 0; if (userSal > p90) return 1; return (userSal - p10) / (p90 - p10); } // A simple gauge for the user’s salary vs. percentiles function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) { if (!percentileRow) return null; const p10 = parseFloatOrZero(percentileRow[`${prefix}_PCT10`], 0); const p90 = parseFloatOrZero(percentileRow[`${prefix}_PCT90`], 0); const median = parseFloatOrZero(percentileRow[`${prefix}_MEDIAN`], 0); if (!p10 || !p90 || p10 >= p90) { return null; } const userFrac = getRelativePosition(userSalary, p10, p90) * 100; const medianFrac = getRelativePosition(median, p10, p90) * 100; return (
${p10.toLocaleString()} ${p90.toLocaleString()}
{/* Median Marker */}
Median ${median.toLocaleString()}
{/* User Salary Marker */}
${userSalary.toLocaleString()}
); } function EconomicProjectionsBar({ data }) { if (!data) return null; const { area, baseYear, projectedYear, base, projection, change, annualOpenings, occupationName } = data; if (!area || !base || !projection) { return

No data for {area || 'this region'}.

; } const barData = { labels: [`${occupationName || 'Career'}: ${area}`], datasets: [ { label: `Jobs in ${baseYear}`, data: [base], backgroundColor: 'rgba(75,192,192,0.6)' }, { label: `Jobs in ${projectedYear}`, data: [projection], backgroundColor: 'rgba(255,99,132,0.6)' } ] }; const barOptions = { responsive: true, plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y.toLocaleString()}` } } }, scales: { y: { beginAtZero: true, ticks: { callback: (val) => val.toLocaleString() } } } }; return (

{area}

Change: {change?.toLocaleString() ?? 0} jobs

Annual Openings: {annualOpenings?.toLocaleString() ?? 0}

); } function getYearsInCareer(startDateString) { if (!startDateString) return null; const start = new Date(startDateString); if (isNaN(start)) return null; const now = new Date(); const diffMs = now - start; const diffYears = diffMs / (1000 * 60 * 60 * 24 * 365.25); if (diffYears < 1) { return '<1'; } return Math.floor(diffYears).toString(); } export default function CareerRoadmap({ selectedCareer: initialCareer }) { const { careerId } = useParams(); const location = useLocation(); const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'MONTE_CARLO' const [flatAnnualRate, setFlatAnnualRate] = useState(0.06); const [randomRangeMin, setRandomRangeMin] = useState(-0.02); const [randomRangeMax, setRandomRangeMax] = useState(0.02); // Basic states const [userProfile, setUserProfile] = useState(null); const [financialProfile, setFinancialProfile] = useState(null); const [masterCareerRatings, setMasterCareerRatings] = useState([]); const [existingCareerProfiles, setExistingCareerProfiles] = useState([]); const [selectedCareer, setSelectedCareer] = useState(initialCareer || null); const [careerProfileId, setCareerProfileId] = useState(null); const [scenarioRow, setScenarioRow] = useState(null); const [collegeProfile, setCollegeProfile] = useState(null); const [fullSocCode, setFullSocCode] = useState(null); // new line const [strippedSocCode, setStrippedSocCode] = useState(null); const [salaryData, setSalaryData] = useState(null); const [economicProjections, setEconomicProjections] = useState(null); const [salaryLoading, setSalaryLoading] = useState(false); const [econLoading, setEconLoading] = useState(false); // Milestones & Projection const [scenarioMilestones, setScenarioMilestones] = useState([]); const [projectionData, setProjectionData] = useState([]); const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); const [milestoneForModal, setMilestoneForModal] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [focusMid , setFocusMid ] = useState(null); const [drawerMilestone, setDrawerMilestone] = useState(null); const [impactsById, setImpactsById] = useState({}); // id β†’ [impacts] const [addingNewMilestone, setAddingNewMilestone] = useState(false); const [showMissingBanner, setShowMissingBanner] = useState(false); // Config const [simulationYearsInput, setSimulationYearsInput] = useState('20'); const simulationYears = parseInt(simulationYearsInput, 10) || 20; const [showEditModal, setShowEditModal] = useState(false); // AI const [aiLoading, setAiLoading] = useState(false); const [recommendations, setRecommendations] = useState([]); // parsed array const [selectedIds, setSelectedIds] = useState([]); // which rec IDs are checked const [lastClickTime, setLastClickTime] = useState(null); const RATE_LIMIT_SECONDS = 15; // adjust as needed const [buttonDisabled, setButtonDisabled] = useState(false); const [aiRisk, setAiRisk] = useState(null); const { setChatSnapshot } = useContext(ChatCtx); const reloadScenarioAndCollege = useCallback(async () => { if (!careerProfileId) return; const s = await authFetch( `api/premium/career-profile/${careerProfileId}` ); if (s.ok) { const row = await s.json(); if (!row.college_enrollment_status) row.college_enrollment_status = "not_enrolled"; setScenarioRow(row); } const c = await authFetch( `api/premium/college-profile?careerProfileId=${careerProfileId}` ); if (c.ok) setCollegeProfile(await c.json()); }, [careerProfileId]); const milestoneGroups = useMemo(() => { if (!scenarioMilestones.length) return []; const buckets = {}; scenarioMilestones.forEach(m => { if (!m.date) return; const monthKey = m.date.slice(0, 7); // β€œ2026-04” (buckets[monthKey] = buckets[monthKey] || []).push(m); }); return Object.entries(buckets) .map(([month, items]) => ({ month, monthLabel: format(new Date(`${month}-01`), 'MMM yyyy'), items })) .sort((a, b) => (a.month > b.month ? 1 : -1)); }, [scenarioMilestones]); /* ---------- build thin orange milestone markers + loan-payoff line ---------- */ const markerAnnotations = useMemo( () => buildChartMarkers(milestoneGroups), [milestoneGroups] ); const loanPayoffLine = useMemo(() => { const hasStudentLoan = projectionData.some((p) => p.loanBalance > 0); if (!hasStudentLoan || !loanPayoffMonth) return {}; // <-- guard added return { loanPaidOff: { type: 'line', xMin: loanPayoffMonth, xMax: loanPayoffMonth, borderColor: 'rgba(255,206,86,1)', borderWidth: 2, borderDash: [6, 6], label: { display: true, content: 'Loan Paid Off', position: 'end', backgroundColor: 'rgba(255,206,86,0.8)', color: '#000', font: { size: 12 }, yAdjust: -10 } } }; }, [loanPayoffMonth]); const allAnnotations = useMemo( () => ({ ...markerAnnotations, ...loanPayoffLine }), [markerAnnotations, loanPayoffLine] ); /* -------- shared chart config -------- */ const zoomConfig = { pan: { enabled: true, mode: 'x' }, zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' } }; const xAndYScales = { x: { type: 'time', time: { unit: 'month' }, ticks: { maxRotation: 0, autoSkip: true } }, y: { beginAtZero: true, ticks: { callback: (val) => val.toLocaleString() // comma-format big numbers } } }; /* ────────────────────────────────────────────────────────────── * ONE-TIME β€œMISSING FIELDS” GUARD * modalGuard.current = { checked: bool, skip: bool } * β€’ checked β†’ we already ran the test for this profile * β€’ skip β†’ suppress first check (set by onboarding OR * by sessionStorage flag for this profile) * ────────────────────────────────────────────────────────────── */ const modalGuard = useRef({ checked: false, skip: false }); /* ------------------------------------------------------------- * 0) If we landed here via onboarding, skip the very first check * ------------------------------------------------------------*/ useEffect(() => { if (location.state?.fromOnboarding) { modalGuard.current.skip = true; // suppress once window.history.replaceState({}, '', location.pathname); } }, [location.state, location.pathname]); /* ------------------------------------------------------------- * 1) Fetch user + financial on first mount * ------------------------------------------------------------*/ useEffect(() => { (async () => { const up = await authFetch('/api/user-profile'); if (up.ok) setUserProfile(await up.json()); const fp = await authFetch('api/premium/financial-profile'); if (fp.ok) setFinancialProfile(await fp.json()); })(); }, []); /* quick derived helpers */ const userSalary = parseFloatOrZero(financialProfile?.current_salary); const userArea = userProfile?.area || 'U.S.'; const userState = getFullStateName(userProfile?.state || '') || 'United States'; /* ------------------------------------------------------------- * 2) Determine the active careerProfileId once * ------------------------------------------------------------*/ useEffect(() => { let id = careerId; if (!id) id = localStorage.getItem('lastSelectedCareerProfileId'); if (id) { setCareerProfileId(id); localStorage.setItem('lastSelectedCareerProfileId', id); // one-shot modal skip from sessionStorage modalGuard.current.skip ||= shouldSkipModalOnce(id); } }, [careerId]); useEffect(() => { let timer; if (buttonDisabled) { timer = setTimeout(() => setButtonDisabled(false), RATE_LIMIT_SECONDS * 1000); } return () => clearTimeout(timer); }, [buttonDisabled]); /* ------------------------------------------------------------------ * 1) Restore AI recommendations (unchanged behaviour) * -----------------------------------------------------------------*/ useEffect(() => { const json = localStorage.getItem('aiRecommendations'); if (!json) return; try { const arr = JSON.parse(json).map((m) => ({ ...m, id: m.id || crypto.randomUUID() })); setRecommendations(arr); } catch (err) { console.error('Error parsing stored AI recs', err); } }, []); /* ------------------------------------------------------------------ * 2) Whenever the careerProfileId changes, clear the modal check flag * -----------------------------------------------------------------*/ useEffect(() => { modalGuard.current.checked = false; }, [careerProfileId]); /* ------------------------------------------------------------------ * 3) Missing-fields modal – single authoritative effect * -----------------------------------------------------------------*/ const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null; useEffect(() => { if (!dataReady || !careerProfileId) return; // wait for all rows /* run once per profile‑id ------------------------------------------------ */ if (modalGuard.current.checked) return; modalGuard.current.checked = true; /* derive once, local to this effect -------------------------------------- */ const status = (scenarioRow?.college_enrollment_status || '').toLowerCase(); const requireCollege = ['currently_enrolled','prospective_student','deferred'] .includes(status); const missing = getMissingFields( { scenario: scenarioRow, financial: financialProfile, college: collegeProfile }, { requireCollegeData: requireCollege } ); if (missing.length) { /* if we arrived *directly* from onboarding we silently skip the banner once, but we still want the Edit‑Scenario modal to open */ if (modalGuard.current.skip) { setShowEditModal(true); } else { setShowMissingBanner(true); } } }, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]); useEffect(() => { if ( financialProfile && scenarioRow && collegeProfile ) { buildProjection(scenarioMilestones); // uses the latest scenarioMilestones } }, [ financialProfile, scenarioRow, collegeProfile, scenarioMilestones, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax ]); /** * Snapshot for the Support-bot: only UI state, no domain data */ const uiSnap = useMemo(() => ({ page : 'CareerRoadmap', panels: { careerCoachLoaded : !!scenarioRow?.career_name, salaryBenchmarks : !!salaryData, econProjections : !!economicProjections, financialProjection : !!projectionData.length, milestonesPanel : !!scenarioMilestones.length, editScenarioModalUp : showEditModal, drawerOpen : drawerOpen }, counts: { milestonesTotal : scenarioMilestones.length, milestonesDone : scenarioMilestones.filter(m => m.completed).length, yearsSimulated : simulationYears } }), [ selectedCareer, salaryData, economicProjections, projectionData.length, scenarioMilestones, showEditModal, drawerOpen, simulationYears ]); /* push the snapshot to the chat context */ useEffect(() => setChatSnapshot(uiSnap), [uiSnap, setChatSnapshot]); useEffect(() => { if (recommendations.length > 0) { localStorage.setItem('aiRecommendations', JSON.stringify(recommendations)); } else { // if it's empty, we can remove from localStorage if you want localStorage.removeItem('aiRecommendations'); } }, [recommendations]); // 2) load local JSON => masterCareerRatings useEffect(() => { fetch('/careers_with_ratings.json') .then((res) => { if (!res.ok) throw new Error('Failed to load local career data'); return res.json(); }) .then((data) => setMasterCareerRatings(data)) .catch((err) => console.error('Error loading local career data =>', err)); }, []); // 3) fetch user’s career-profiles // utilities you already have in this file // β€’ getAllCareerProfiles() // β€’ createCareerProfileFromSearch() useEffect(() => { let cancelled = false; (async function init () { /* 1 β–Έ get every row the user owns */ const r = await authFetch('api/premium/career-profile/all'); if (!r?.ok || cancelled) return; const { careerProfiles=[] } = await r.json(); setExistingCareerProfiles(careerProfiles); /* 2 β–Έ what does the UI say the user just picked? */ const chosen = location.state?.selectedCareer ?? JSON.parse(localStorage.getItem('selectedCareer') || '{}'); /* 2A β–Έ they clicked a career elsewhere in the app */ if (chosen.code) { let row = careerProfiles.find(p => p.soc_code === chosen.code); if (!row) { try { row = await createCareerProfileFromSearch(chosen); } catch { /* swallow – API will have logged */ } } if (row && !cancelled) { setCareerProfileId(row.id); setSelectedCareer(row); localStorage.setItem('lastSelectedCareerProfileId', row.id); } /* clear the one-shot navigate state */ if (!cancelled) window.history.replaceState({}, '', location.pathname); return; } /* 2B β–Έ deep-link /career-roadmap/:id */ if (careerId) { const row = careerProfiles.find(p => String(p.id) === String(careerId)); if (row && !cancelled) { setCareerProfileId(row.id); setSelectedCareer(row); localStorage.setItem('lastSelectedCareerProfileId', row.id); } return; } /* 2C β–Έ last profile the user touched */ const stored = localStorage.getItem('lastSelectedCareerProfileId'); if (stored) { const row = careerProfiles.find(p => String(p.id) === stored); if (row && !cancelled) { setCareerProfileId(row.id); setSelectedCareer(row); return; } } /* 2D β–Έ otherwise: newest profile, if any */ if (careerProfiles.length && !cancelled) { const latest = careerProfiles.at(-1); // ASC order β†’ last = newest setCareerProfileId(latest.id); setSelectedCareer(latest); localStorage.setItem('lastSelectedCareerProfileId', latest.id); } })(); return () => { cancelled = true; }; /* fires only when the navigation key changes or when :id changes */ }, [location.key, careerId]); /* ------------------------------------------------------------------ * 4) refresh scenario + college whenever the active profile-id changes * -----------------------------------------------------------------*/ useEffect(() => { if (!careerProfileId) return; // nothing to fetch // clear any stale UI traces while the new fetch runs setScenarioRow(null); setCollegeProfile(null); setScenarioMilestones([]); // remember for other tabs / future visits localStorage.setItem('lastSelectedCareerProfileId', careerProfileId); // fetch both rows in parallel (defined via useCallback) reloadScenarioAndCollege(); }, [careerProfileId, reloadScenarioAndCollege]); const refetchScenario = useCallback(async () => { if (!careerProfileId) return; const r = await authFetch('api/premium/career-profile/${careerProfileId}'); if (r.ok) setScenarioRow(await r.json()); }, [careerProfileId]); // 5) from scenarioRow => find the full SOC => strip useEffect(() => { if (!scenarioRow?.career_name || !masterCareerRatings.length) { setStrippedSocCode(null); setFullSocCode(null); return; } const target = normalizeTitle(scenarioRow.career_name); const found = masterCareerRatings.find( (obj) => normalizeTitle(obj.title || '') === target ); if (!found) { console.warn('No matching SOC =>', scenarioRow.career_name); setStrippedSocCode(null); setFullSocCode(null); return; } setStrippedSocCode(stripSocCode(found.soc_code)); setFullSocCode(found.soc_code); }, [scenarioRow, masterCareerRatings]); useEffect(() => { if (!fullSocCode || !scenarioRow || scenarioRow.riskLevel) return; (async () => { const risk = await fetchAiRisk( fullSocCode, scenarioRow?.career_name, scenarioRow?.job_description || "", scenarioRow?.tasks || [] ); setAiRisk(risk); if (risk && scenarioRow) { const updated = { ...scenarioRow, riskLevel: risk.riskLevel, riskReasoning: risk.reasoning }; setScenarioRow(updated); } })(); }, [fullSocCode, scenarioRow]); async function fetchAiRisk(socCode, careerName, description, tasks) { let aiRisk = null; try { // 1) Check server2 for existing entry const localRiskRes = await axios.get('api/ai-risk/${socCode}'); aiRisk = localRiskRes.data; // { socCode, riskLevel, ... } } catch (err) { // 2) If 404 => call server3 if (err.response && err.response.status === 404) { try { // Call GPT via server3 const aiRes = await axios.post('api/public/ai-risk-analysis', { socCode, careerName, jobDescription: description, tasks }); const { riskLevel, reasoning } = aiRes.data; // Prepare the upsert payload const storePayload = { socCode, careerName, riskLevel, reasoning }; // Only set jobDescription if non-empty if ( aiRes.data.jobDescription && aiRes.data.jobDescription.trim().length > 0 ) { storePayload.jobDescription = aiRes.data.jobDescription; } // Only set tasks if it's a non-empty array if ( Array.isArray(aiRes.data.tasks) && aiRes.data.tasks.length > 0 ) { storePayload.tasks = aiRes.data.tasks; } // 3) Store in server2 await axios.post('api/ai-risk', storePayload); // Construct final object for usage here aiRisk = { socCode, careerName, jobDescription: description, tasks, riskLevel, reasoning }; } catch (err2) { console.error("Error calling server3 or storing AI risk:", err2); // fallback } } else { console.error("Error fetching AI risk from server2 =>", err); } } return aiRisk; } /* 6) Salary ------------------------------------------------------- */ useEffect(() => { // show blank state instantly whenever the SOC or area changes setSalaryData(null); setSalaryLoading(true); if (!strippedSocCode) { setSalaryLoading(false); return; } const ctrl = new AbortController(); (async () => { try { const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea }); const res = await fetch(`api/salary?${qs}`, { signal: ctrl.signal }); if (res.ok) { setSalaryData(await res.json()); setSalaryLoading(false); } else { console.error('[Salary fetch]', res.status); } } catch (e) { if (e.name !== 'AbortError') console.error('[Salary fetch error]', e); setSalaryLoading(false); } })(); // cancel if strippedSocCode / userArea changes before the fetch ends return () => ctrl.abort(); }, [strippedSocCode, userArea]); /* 7) Economic Projections ---------------------------------------- */ useEffect(() => { setEconomicProjections(null); setEconLoading(true); if (!strippedSocCode || !userState) { setEconLoading(false); return; } const ctrl = new AbortController(); (async () => { try { const qs = new URLSearchParams({ state: userState }); const res = await authFetch( `api/projections/${strippedSocCode}?${qs}`, { signal: ctrl.signal } ); if (res.ok) { setEconomicProjections(await res.json()); setEconLoading(false); } else { console.error('[Econ fetch]', res.status); } } catch (e) { if (e.name !== 'AbortError') console.error('[Econ fetch error]', e); setEconLoading(false); } })(); return () => ctrl.abort(); }, [strippedSocCode, userState]); // 8) Build financial projection async function buildProjection(milestones) { if (!milestones?.length) return; const allMilestones = milestones || []; try { setScenarioMilestones(allMilestones); // fetch impacts const imPromises = allMilestones.map((m) => authFetch(`api/premium/milestone-impacts?milestone_id=${m.id}`) .then((r) => (r.ok ? r.json() : null)) .then((dd) => dd?.impacts || []) .catch((e) => { console.warn('Error fetching impacts =>', e); return []; }) ); const impactsForEach = await Promise.all(imPromises); const allImpacts = allMilestones .map((m, i) => ({ ...m, impacts: impactsForEach[i] || [] })) .flatMap((m) => m.impacts); /* NEW – build a quick lookup table and expose it */ const map = {}; allImpacts.forEach((imp) => { (map[imp.milestone_id] = map[imp.milestone_id] || []).push(imp); }); setImpactsById(map); // <-- saves for the modal const f = financialProfile; const financialBase = { currentSalary: parseFloatOrZero(f.current_salary, 0), additionalIncome: parseFloatOrZero(f.additional_income, 0), monthlyExpenses: parseFloatOrZero(f.monthly_expenses, 0), monthlyDebtPayments: parseFloatOrZero(f.monthly_debt_payments, 0), retirementSavings: parseFloatOrZero(f.retirement_savings, 0), emergencySavings: parseFloatOrZero(f.emergency_fund, 0), retirementContribution: parseFloatOrZero(f.retirement_contribution, 0), emergencyContribution: parseFloatOrZero(f.emergency_contribution, 0), extraCashEmergencyPct: parseFloatOrZero(f.extra_cash_emergency_pct, 50), extraCashRetirementPct: parseFloatOrZero(f.extra_cash_retirement_pct, 50) }; function parseScenarioOverride(overrideVal, fallbackVal) { if (overrideVal === null) { return fallbackVal; } return parseFloatOrZero(overrideVal, fallbackVal); } const s = scenarioRow; const scenarioOverrides = { monthlyExpenses: parseScenarioOverride( s.planned_monthly_expenses, financialBase.monthlyExpenses ), monthlyDebtPayments: parseScenarioOverride( s.planned_monthly_debt_payments, financialBase.monthlyDebtPayments ), monthlyRetirementContribution: parseScenarioOverride( s.planned_monthly_retirement_contribution, financialBase.retirementContribution ), monthlyEmergencyContribution: parseScenarioOverride( s.planned_monthly_emergency_contribution, financialBase.emergencyContribution ), surplusEmergencyAllocation: parseScenarioOverride( s.planned_surplus_emergency_pct, financialBase.extraCashEmergencyPct ), surplusRetirementAllocation: parseScenarioOverride( s.planned_surplus_retirement_pct, financialBase.extraCashRetirementPct ), additionalIncome: parseScenarioOverride( s.planned_additional_income, financialBase.additionalIncome ) }; const c = collegeProfile; const collegeData = { studentLoanAmount: parseFloatOrZero(c.existing_college_debt, 0), interestRate: parseFloatOrZero(c.interest_rate, 5), loanTerm: parseFloatOrZero(c.loan_term, 10), loanDeferralUntilGraduation: !!c.loan_deferral_until_graduation, academicCalendar: c.academic_calendar || 'monthly', annualFinancialAid: parseFloatOrZero(c.annual_financial_aid, 0), calculatedTuition: parseFloatOrZero(c.tuition, 0), extraPayment: parseFloatOrZero(c.extra_payment, 0), inCollege: c.college_enrollment_status === 'currently_enrolled' || c.college_enrollment_status === 'prospective_student', gradDate: c.expected_graduation || null, programType: c.program_type || null, creditHoursPerYear: parseFloatOrZero(c.credit_hours_per_year, 0), hoursCompleted: parseFloatOrZero(c.hours_completed, 0), programLength: parseFloatOrZero(c.program_length, 0), expectedSalary: parseFloatOrZero(c.expected_salary) || parseFloatOrZero(f.current_salary, 0) }; /* ── NEW: auto-extend horizon to cover furthest milestone ── */ let horizonYears = simulationYears; // default from the input box if (allMilestones.length) { // last dated milestone β†’ Date object const last = allMilestones .filter(m => m.date) .reduce( (max, m) => (new Date(m.date) > max ? new Date(m.date) : max), new Date() ); const months = Math.ceil((last - new Date()) / (1000 * 60 * 60 * 24 * 30.44)); const years = Math.ceil(months / 12) + 1; // +1 yr buffer horizonYears = Math.max(simulationYears, years); } const mergedProfile = { currentSalary: financialBase.currentSalary, monthlyExpenses: scenarioOverrides.monthlyExpenses, monthlyDebtPayments: scenarioOverrides.monthlyDebtPayments, retirementSavings: financialBase.retirementSavings, emergencySavings: financialBase.emergencySavings, monthlyRetirementContribution: scenarioOverrides.monthlyRetirementContribution, monthlyEmergencyContribution: scenarioOverrides.monthlyEmergencyContribution, surplusEmergencyAllocation: scenarioOverrides.surplusEmergencyAllocation, surplusRetirementAllocation: scenarioOverrides.surplusRetirementAllocation, additionalIncome: scenarioOverrides.additionalIncome, // college studentLoanAmount: collegeData.studentLoanAmount, interestRate: collegeData.interestRate, loanTerm: collegeData.loanTerm, loanDeferralUntilGraduation: collegeData.loanDeferralUntilGraduation, academicCalendar: collegeData.academicCalendar, annualFinancialAid: collegeData.annualFinancialAid, calculatedTuition: collegeData.calculatedTuition, extraPayment: collegeData.extraPayment, enrollmentDate: collegeProfile.enrollmentDate || null, inCollege: collegeData.inCollege, gradDate: collegeData.gradDate, programType: collegeData.programType, creditHoursPerYear: collegeData.creditHoursPerYear, hoursCompleted: collegeData.hoursCompleted, programLength: collegeData.programLength, expectedSalary: collegeData.expectedSalary, startDate: new Date().toISOString().slice(0, 10), simulationYears: horizonYears, milestoneImpacts: allImpacts, interestStrategy, flatAnnualRate, monthlyReturnSamples: [], // or keep an array if you have historical data randomRangeMin, randomRangeMax }; console.log('Merged profile to simulate =>', mergedProfile); const { projectionData: pData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile); let cumu = mergedProfile.emergencySavings || 0; const finalData = pData.map((mo) => { cumu += mo.netSavings || 0; return { ...mo, cumulativeNetSavings: cumu }; }); setProjectionData(finalData); setLoanPayoffMonth(loanPaidOffMonth); } catch (err) { console.error('Error in scenario simulation =>', err); } } useEffect(() => { if (!financialProfile || !scenarioRow || !collegeProfile) return; fetchMilestones(); }, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]); const [clickCount, setClickCount] = useState(() => { const storedCount = localStorage.getItem('aiClickCount'); const storedDate = localStorage.getItem('aiClickDate'); const today = new Date().toISOString().slice(0, 10).slice(0, 10); if (storedDate !== today) { localStorage.setItem('aiClickDate', today); localStorage.setItem('aiClickCount', '0'); return 0; } return parseInt(storedCount || '0', 10); }); const DAILY_CLICK_LIMIT = 10; // example limit per day const emergencyData = { label: 'Emergency Savings', data: projectionData.map((p) => p.emergencySavings), borderColor: 'rgba(255, 159, 64, 1)', backgroundColor: 'rgba(255, 159, 64, 0.2)', tension: 0.4, fill: true }; const retirementData = { label: 'Retirement Savings', data: projectionData.map((p) => p.retirementSavings), borderColor: 'rgba(75, 192, 192, 1)', backgroundColor: 'rgba(75, 192, 192, 0.2)', tension: 0.4, fill: true }; const totalSavingsData = { label: 'Total Savings', data: projectionData.map((p) => p.totalSavings), borderColor: 'rgba(54, 162, 235, 1)', backgroundColor: 'rgba(54, 162, 235, 0.2)', tension: 0.4, fill: true }; const loanBalanceData = { label: 'Loan Balance', data: projectionData.map((p) => p.loanBalance), borderColor: 'rgba(255, 99, 132, 1)', backgroundColor: 'rgba(255, 99, 132, 0.2)', tension: 0.4, fill: { target: 'origin', above: 'rgba(255,99,132,0.3)', below: 'transparent' } }; const hasStudentLoan = useMemo( () => projectionData.some(p => (p.loanBalance ?? 0) > 0), [projectionData] ); const chartDatasets = [emergencyData, retirementData]; if (hasStudentLoan) chartDatasets.push(loanBalanceData); chartDatasets.push(totalSavingsData); const yearsInCareer = getYearsInCareer(scenarioRow?.start_date); // -- AI Handler -- async function handleAiClick() { if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) { alert('You have reached the daily limit for suggestions.'); return; } setAiLoading(true); setSelectedIds([]); const oldRecTitles = recommendations.map(r => r.title.trim()).filter(Boolean); const acceptedTitles = scenarioMilestones.map(m => (m.title || '').trim()).filter(Boolean); const allToAvoid = [...oldRecTitles, ...acceptedTitles]; try { const payload = { userProfile, scenarioRow, financialProfile, collegeProfile, previouslyUsedTitles: allToAvoid }; const res = await authFetch('/api/premium/ai/next-steps', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) throw new Error('AI request failed'); const data = await res.json(); const rawText = data.recommendations || ''; const arr = parseAIJson(rawText); setRecommendations(arr); localStorage.setItem('aiRecommendations', JSON.stringify(arr)); // Update click count setClickCount(prev => { const newCount = prev + 1; localStorage.setItem('aiClickCount', newCount); return newCount; }); } catch (err) { console.error('Error fetching AI next steps =>', err); } finally { setAiLoading(false); } } function handleSimulationYearsChange(e) { setSimulationYearsInput(e.target.value); } function handleSimulationYearsBlur() { if (!simulationYearsInput.trim()) setSimulationYearsInput('20'); } const buttonLabel = recommendations.length > 0 ? 'New Suggestions' : 'What Should I Do Next?'; const chartRef = useRef(null); const onEditMilestone = useCallback((m) => { setMilestoneForModal({ ...m, impacts: impactsById[m.id] || [] // give the modal what it needs }); }, [impactsById]); const currentIdRef = useRef(null); /* 1️⃣ The only deps it really needs */ const fetchMilestones = useCallback(async () => { if (!careerProfileId) return; const [profRes, uniRes] = await Promise.all([ authFetch(`api/premium/milestones?careerProfileId=${careerProfileId}`), authFetch(`api/premium/milestones?careerProfileId=universal`) ]); if (!profRes.ok || !uniRes.ok) return; const [{ milestones: profMs }, { milestones: uniMs }] = await Promise.all([profRes.json(), uniRes.json()]); const merged = [...profMs, ...uniMs]; setScenarioMilestones(merged); if (financialProfile && scenarioRow && collegeProfile) { buildProjection(merged); } // single rebuild }, [financialProfile, scenarioRow, careerProfileId]); // ← NOTICE: no buildProjection here return (
{/* 0) New CareerCoach at the top */} { // store it in local state setAiRisk(riskData); }} /> {/* 1) Then your "Where Am I Now?" */}

Where you are now and where you are going:

{/* 1) Career */}

Target Career:{' '} {scenarioRow?.career_name || '(Select a career)'}

{yearsInCareer && (

Time in this career: {yearsInCareer}{' '} {yearsInCareer === '<1' ? 'year' : 'years'}

)} {aiRisk?.riskLevel && (

AI Automation Risk:{' '} {aiRisk.riskLevel}
{aiRisk.reasoning}

)}
{/* 2) Salary Benchmarks */}
{salaryLoading && (

Loading salary data…

)} {!salaryLoading && salaryData?.regional && (

Regional Salary Data ({userArea || 'U.S.'})

10th percentile:{' '} {salaryData.regional.regional_PCT10 ? `$${parseFloat(salaryData.regional.regional_PCT10).toLocaleString()}` : 'N/A'}

Median:{' '} {salaryData.regional.regional_MEDIAN ? `$${parseFloat(salaryData.regional.regional_MEDIAN).toLocaleString()}` : 'N/A'}

90th percentile:{' '} {salaryData.regional.regional_PCT90 ? `$${parseFloat(salaryData.regional.regional_PCT90).toLocaleString()}` : 'N/A'}

)} {!salaryLoading && salaryData?.national && (

National Salary Data

10th percentile:{' '} {salaryData.national.national_PCT10 ? `$${parseFloat(salaryData.national.national_PCT10).toLocaleString()}` : 'N/A'}

Median:{' '} {salaryData.national.national_MEDIAN ? `$${parseFloat(salaryData.national.national_MEDIAN).toLocaleString()}` : 'N/A'}

90th percentile:{' '} {salaryData.national.national_PCT90 ? `$${parseFloat(salaryData.national.national_PCT90).toLocaleString()}` : 'N/A'}

)} {!salaryLoading && !salaryData?.regional && !salaryData?.national && (

No salary data found.

)}
{/* 3) Economic Projections */}
{econLoading && (

Loading projections…

)} {!econLoading && economicProjections?.state && ( )} {!econLoading && economicProjections?.national && ( )}
{!economicProjections?.state && !economicProjections?.national && (

No economic data found.

)} {/* 4) Career Goals

Your Career Goals

{scenarioRow?.career_goals || 'No career goals entered yet.'}

*/} {/* --- FINANCIAL PROJECTION SECTION -------------------------------- */} {showMissingBanner && (

We need a few basics (income, expenses, etc.) before we can show a full projection.

)}

Financial Projection

{projectionData.length ? (
{/* Chart – now full width */}
p.month), datasets: chartDatasets }} options={{ maintainAspectRatio: false, plugins: { legend: { position: 'bottom' }, tooltip: { mode: 'index', intersect: false }, annotation: { annotations: allAnnotations }, // βœ… new zoom: zoomConfig }, scales: xAndYScales // unchanged }} />
{loanPayoffMonth && hasStudentLoan && (

Loan Paid Off:  {loanPayoffMonth}

)}
) : (

No financial projection data found.

)}
{/* Milestones – stacked list under chart */}

Milestones

{ setDrawerMilestone(m); setDrawerOpen(true); }} onAddNewMilestone={() => setAddingNewMilestone(true)} />
{/* 6) Simulation length + Edit scenario */}
{ setShowEditModal(false); if (didSave) reloadScenarioAndCollege(); // πŸ‘ˆ refresh after save }} scenario={scenarioRow} financialProfile={financialProfile} setFinancialProfile={setFinancialProfile} collegeProfile={collegeProfile} setCollegeProfile={setCollegeProfile} authFetch={authFetch} /> {/* (E1) Interest Strategy */} {/* (E2) If FLAT => show the annual rate */} {interestStrategy === 'FLAT' && (
setFlatAnnualRate(parseFloatOrZero(e.target.value, 0.06))} className="border rounded p-1 w-20" />
)} {/* (E3) If MONTE_CARLO => show the random range */} {interestStrategy === 'MONTE_CARLO' && (
setRandomRangeMin(parseFloatOrZero(e.target.value, -0.02))} className="border rounded p-1 w-20 mr-2" /> setRandomRangeMax(parseFloatOrZero(e.target.value, 0.02))} className="border rounded p-1 w-20" />
)} {/* ─────────────────────────────────────────────── 1. EDIT EXISTING MILESTONE (modal pops from grid, unchanged) ─────────────────────────────────────────────── */} {milestoneForModal && ( { if (didSave) handleMilestonesCreated(); setMilestoneForModal(null); }} /> )} {/* ─────────────────────────────────────────────── 2. ADD-NEW MILESTONE (same modal, milestone = null) ─────────────────────────────────────────────── */} {addingNewMilestone && ( { setAddingNewMilestone(false); if (didSave) fetchMilestones(); }} /> )} {/* ─────────────────────────────────────────────── 3. RIGHT-HAND DRAWER ─────────────────────────────────────────────── */} setDrawerOpen(false)} onTaskToggle={(id, newStatus) => { /* optimistic update or just refetch */ fetchMilestones(); }} onAddNewMilestone={() => { setDrawerOpen(false); // close drawer first setAddingNewMilestone(true); // then open modal in create mode }} /> {/* 7) AI Next Steps */} {/*
{aiLoading &&

Generating your next steps…

} {/* If we have structured recs, show checkboxes {recommendations.length > 0 && (

Select the Advice You Want to Keep

    {recommendations.map((m) => (
  • handleToggle(m.id)} />
    {m.title} {m.date}

    {m.description}

  • ))}
{selectedIds.length > 0 && ( )}
)}
*/}
); }