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.status === 404) {
// user skipped onboarding β treat as empty object
setFinancialProfile({});
} else 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) {
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
const handleMilestonesCreated = useCallback(
(count = 0) => {
// optional toast
if (count) console.log(`πΎ ${count} milestone(s) saved β refreshing listβ¦`);
fetchMilestones();
},
[fetchMilestones]
);
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 && (
)}
{!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 && (
)}
{/* 3) Economic Projections */}
{econLoading && (
)}
{!econLoading && economicProjections?.state && (
)}
{!econLoading && economicProjections?.national && (
)}
{!economicProjections?.state && !economicProjections?.national && (
)}
{/* 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
{selectedIds.length > 0 && (
)}
)}
*/}
);
}