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 api from '../auth/apiClient.js';
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, { MISSING_LABELS } 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 FinancialDisclaimer from './FinancialDisclaimer.js';
import { Button } from './ui/button.js';
import { Pencil } from 'lucide-react';
import ScenarioEditModal from './ScenarioEditModal.js';
import InfoTooltip from "./ui/infoTooltip.js";
import "../styles/legacy/MilestoneTimeline.legacy.css";
// --------------
// Register ChartJS Plugins
// --------------
ChartJS.register(
LineElement,
BarElement,
CategoryScale,
LinearScale,
TimeScale,
Filler,
PointElement,
Tooltip,
Legend,
zoomPlugin,
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' | 'RANDOM'
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 [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);
const [missingKeys, setMissingKeys] = useState([]);
// 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 chat = useContext(ChatCtx) || {};
const setChatSnapshot = chat?.setChatSnapshot;
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null; // unchanged; {} is truthy
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=${encodeURIComponent(careerProfileId)}`);
if (c.status === 404) {
setCollegeProfile({}); // no profile yet → use defaults
return;
}
if (c.ok) {
const ct = c.headers.get('content-type') || '';
if (ct.includes('application/json')) {
setCollegeProfile(await c.json());
} else {
// defensive: upstream returned HTML (e.g., 502 body with wrong status)
setCollegeProfile({});
}
return;
}
// non-OK (e.g., 502)
console.error('college-profile fetch failed', c.status);
setCollegeProfile({});
}, [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' }
};
// Compute if any savings are negative to pick a sane baseline
const minSavings = projectionData.reduce((min, p) => {
const e = Number(p.emergencySavings ?? 0);
const r = Number(p.retirementSavings ?? 0);
const t = Number(p.totalSavings ?? 0);
return Math.min(min, e, r, t);
}, Infinity);
const hasNegSavings = Number.isFinite(minSavings) && minSavings < 0;
const xAndYScales = {
x: {
type: 'time',
time: { unit: 'month' },
ticks: { maxRotation: 0, autoSkip: true }
},
y: {
beginAtZero: !hasNegSavings,
// give a little room below the smallest negative so the fill doesn't sit on the axis
min: hasNegSavings ? Math.floor(minSavings * 1.05) : undefined,
ticks: {
callback: (val) => val.toLocaleString()
}
}
};
/* ──────────────────────────────────────────────────────────────
* 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);
sessionStorage.removeItem('suppressOnboardingGuard');
}
}, [location.state, location.pathname]);
/* -------------------------------------------------------------
* 1) Fetch user + financial on first mount
* ------------------------------------------------------------*/
useEffect(() => {
(async () => {
const up = await authFetch('/api/user-profile?fields=area,state');
if (up.ok && (up.headers.get('content-type')||'').includes('application/json')) {
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 && (fp.headers.get('content-type')||'').includes('application/json')) {
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]);
/* ------------------------------------------------------------------
* 2) Whenever the careerProfileId changes, clear the modal check flag
* -----------------------------------------------------------------*/
useEffect(() => {
modalGuard.current.checked = false;
}, [careerProfileId]);
/* ------------------------------------------------------------------
* 3) Missing-fields modal – single authoritative effect
* -----------------------------------------------------------------*/
useEffect(() => {
if (!dataReady || !careerProfileId) return; // wait for all rows
/* 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 }
);
setMissingKeys(missing);
setShowMissingBanner(missing.length > 0);
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
useEffect(() => {
if (financialProfile && scenarioRow && collegeProfile !== null) {
buildProjection(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(() => {
if (typeof setChatSnapshot === 'function') {
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]);
// ─────────────────────────────────────────────────────────────
// Resolve SOC without shipping any bulk JSON to the browser
// Order: scenarioRow.soc_code → selectedCareer.code → resolve by title
// ─────────────────────────────────────────────────────────────
const resolveSoc = useCallback(async () => {
// 1) scenarioRow already has it?
const fromScenario = scenarioRow?.soc_code || scenarioRow?.socCode;
if (fromScenario) {
setFullSocCode(fromScenario);
setStrippedSocCode(stripSocCode(fromScenario));
return;
}
// 2) selectedCareer from onboarding/search
const fromSelected = selectedCareer?.code || selectedCareer?.soc_code || selectedCareer?.socCode;
if (fromSelected) {
setFullSocCode(fromSelected);
setStrippedSocCode(stripSocCode(fromSelected));
return;
}
// 3) Fallback: resolve via tiny title→SOC endpoint (no bulk dataset)
const title = (scenarioRow?.career_name || '').trim();
if (!title) {
setFullSocCode(null);
setStrippedSocCode(null);
return;
}
try {
const r = await authFetch(`/api/careers/resolve?title=${encodeURIComponent(title)}`);
if (r.ok && (r.headers.get('content-type')||'').includes('application/json')) {
const j = await r.json(); // { title, soc_code, ... }
if (j?.soc_code) {
setFullSocCode(j.soc_code);
setStrippedSocCode(stripSocCode(j.soc_code));
return;
}
}
// not found
setFullSocCode(null);
setStrippedSocCode(null);
} catch (e) {
console.error('SOC resolve failed', e);
setFullSocCode(null);
setStrippedSocCode(null);
}
}, [scenarioRow, selectedCareer]);
// 3) fetch user’s career-profiles
// utilities you already have in this file
// • getAllCareerProfiles()
// • createCareerProfileFromSearch()
useEffect(() => {
let cancelled = false;
(async function init () {
// ─────────────────────────────────────────────────────────────
// 0) Prefer a real profile. Use ephemeral ONLY if none matches.
// Precedence when arriving with premiumOnboardingState:
// a) SOC match
// b) title match
// c) school match (via college_profile/all)
// ─────────────────────────────────────────────────────────────
if (!careerId) {
const pos = location.state?.premiumOnboardingState;
const sc = pos?.selectedCareer;
if (sc) {
const chosenSoc = sc.code || sc.soc_code || sc.socCode || '';
const chosenName = (sc.title || sc.career_name || '').trim();
// 1) fetch all user profiles
let all = [];
try {
all = await getAllCareerProfiles(); // uses authFetch
} catch { /* ignore; backend will log */ }
const bySoc = chosenSoc
? all.find(p => p.soc_code === chosenSoc)
: null;
const norm = (s='') => s.toLowerCase().replace(/\s+/g,' ').trim();
const byTitle = !bySoc && chosenName
? all.find(p => norm(p.career_name||p.scenario_title||'') === norm(chosenName))
: null;
let bySchool = null;
if (!bySoc && !byTitle && pos.selectedSchool) {
const schoolName = norm(pos.selectedSchool?.INSTNM || '');
try {
const r = await authFetch('/api/premium/college-profile/all');
if (r.ok && (r.headers.get('content-type')||'').includes('application/json')) {
const { collegeProfiles = [] } = await r.json();
const ids = new Set(
collegeProfiles
.filter(cp => norm(cp.selected_school || '') === schoolName)
.map(cp => cp.career_profile_id)
);
// pick newest start_date among candidates
const sorted = [...all].sort((a,b) => (a.start_date > b.start_date ? -1 : 1));
bySchool = sorted.find(p => ids.has(p.id)) || null;
}
} catch { /* ignore */ }
}
const match = bySoc || byTitle || bySchool;
if (match && !cancelled) {
// ✅ Normal mode: select existing profile
setCareerProfileId(match.id);
setSelectedCareer(match);
localStorage.setItem('lastSelectedCareerProfileId', match.id);
window.history.replaceState({}, '', location.pathname); // clear bridge
return; // let the rest of the effects run as usual
}
// ⚠️ No matching profile → ephemeral (financial profile is still used)
if (!cancelled) {
setCareerProfileId(null);
setSelectedCareer(sc);
setScenarioRow({
id: 'temp',
scenario_title: chosenName,
career_name : chosenName,
status : 'planned',
start_date : new Date().toISOString().slice(0,10),
college_enrollment_status: ''
});
const sch = pos.selectedSchool;
setCollegeProfile(sch ? {
selected_school : sch?.INSTNM || '',
selected_program : sch?.CIPDESC || '',
tuition : Number(sch?.['In_state cost'] ?? sch?.['Out_state cost'] ?? 0) || 0
} : {});
if (chosenSoc) {
setFullSocCode(chosenSoc);
setStrippedSocCode(chosenSoc.split('.')[0]);
}
window.history.replaceState({}, '', location.pathname); // clear bridge
return;
}
}
}
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]);
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]);
useEffect(() => { resolveSoc(); }, [resolveSoc]);
async function fetchAiRisk(socCode, careerName, description, tasks) {
let aiRisk = null;
try {
// 1) Check server2 for existing entry
const localRiskRes = await api.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 api.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 api.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,
credentials: 'include'
});
if (res.ok) {
setSalaryData(await res.json());
setSalaryLoading(false);
} else {
console.error('[Salary fetch]', res.status);
setSalaryLoading(false);
}
} 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 && (res.headers.get('content-type')||'').includes('application/json')) {
setEconomicProjections(await res.json());
setEconLoading(false);
} else {
console.error('[Econ fetch]', res.status);
setEconLoading(false);
}
} 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.enrollment_date || 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
};
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 === null) 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) => Number(p.emergencySavings ?? 0)),
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) => Number(p.retirementSavings ?? 0)),
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) => Number(p.totalSavings ?? 0)),
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) => Number(p.loanBalance ?? 0)),
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);
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 = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`);
if (!profRes.ok) return;
const ct = profRes.headers.get('content-type') || '';
const { milestones: profMs = [] } = ct.includes('application/json') ? await profRes.json() : { milestones: [] };
const merged = profMs;
setScenarioMilestones(merged);
// use explicit null-check so {} works, and ensure we see latest value
if (financialProfile && scenarioRow && collegeProfile !== null) {
buildProjection(merged);
} // single rebuild
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId]); // ← NOTICE: no buildProjection here
const handleMilestonesCreated = useCallback(
(count = 0) => {
// optional toast
if (count) console.log(`💾 ${count} milestone(s) saved – refreshing list…`);
fetchMilestones();
},
[fetchMilestones]
);
// Refresh milestones when the chat layer announces changes
useEffect(() => {
const handler = (e) => {
// If an event specifies a scenarioId, ignore other scenarios
const incoming = e?.detail?.scenarioId;
if (incoming && String(incoming) !== String(careerProfileId)) return;
fetchMilestones();
};
window.addEventListener('aptiva:milestones:changed', handler);
return () => window.removeEventListener('aptiva:milestones:changed', handler);
}, [careerProfileId, fetchMilestones]);
return (
{careerProfileId ? (
{ setAiRisk(riskData); }}
/>
) : (
Loading your roadmap…
)}
{/* 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 && (
To improve your projection, please add:
{!!missingKeys.length && (
{missingKeys.map((k) => (
- {MISSING_LABELS[k] || k}
))}
)}
)}
Financial Projection
{/* Legal/assumptions disclaimer (static, matches simulator) */}
{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 RANDOM => show the random range */}
{interestStrategy === 'RANDOM' && (
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 && (
)}
)}
*/}
);
}