dev1/src/components/CareerRoadmap.js
Josh 8ac77b6ae1
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
Coach and chatbot fixes
2025-10-02 13:48:54 +00:00

1785 lines
62 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 users 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 (
<div className="mt-2" style={{ position: 'relative', width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem' }}>
<span>${p10.toLocaleString()}</span>
<span>${p90.toLocaleString()}</span>
</div>
<div
style={{
position: 'relative',
width: '100%',
height: '12px',
border: '1px solid #ccc',
marginTop: '4px',
marginBottom: '8px'
}}
>
{/* Median Marker */}
<div
style={{
position: 'absolute',
left: `${medianFrac}%`,
transform: 'translateX(-50%)',
top: 0,
bottom: 0,
width: '2px',
backgroundColor: 'black'
}}
>
<div
style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translate(-50%, -4px)',
backgroundColor: '#fff',
padding: '2px 6px',
fontSize: '0.75rem',
border: '1px solid #ccc',
borderRadius: '4px',
whiteSpace: 'nowrap'
}}
>
Median ${median.toLocaleString()}
</div>
</div>
{/* User Salary Marker */}
<div
style={{
position: 'absolute',
left: `${userFrac}%`,
transform: 'translateX(-50%)',
top: 0,
bottom: 0,
width: '2px',
backgroundColor: 'red'
}}
>
<div
style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translate(-50%, -4px)',
backgroundColor: '#fff',
padding: '2px 6px',
fontSize: '0.75rem',
border: '1px solid #ccc',
borderRadius: '4px',
whiteSpace: 'nowrap'
}}
>
${userSalary.toLocaleString()}
</div>
</div>
</div>
</div>
);
}
function EconomicProjectionsBar({ data }) {
if (!data) return null;
const {
area,
baseYear,
projectedYear,
base,
projection,
change,
annualOpenings,
occupationName
} = data;
if (!area || !base || !projection) {
return <p className="text-sm text-gray-500">No data for {area || 'this region'}.</p>;
}
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 (
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
<h4 className="text-lg font-semibold mb-2">{area}</h4>
<Bar data={barData} options={barOptions} />
<div className="mt-3 text-sm">
<p>
<strong>Change:</strong> {change?.toLocaleString() ?? 0} jobs
</p>
<p>
<strong>Annual Openings:</strong> {annualOpenings?.toLocaleString() ?? 0}
</p>
</div>
</div>
);
}
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 users 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 (
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
{careerProfileId ? (
<CareerCoach
userProfile={userProfile}
financialProfile={financialProfile}
scenarioRow={scenarioRow}
setScenarioRow={setScenarioRow}
careerProfileId={careerProfileId}
collegeProfile={collegeProfile}
onMilestonesCreated={handleMilestonesCreated}
onAiRiskFetched={(riskData) => { setAiRisk(riskData); }}
/>
) : (
<div className="bg-white p-4 rounded shadow text-center min-h-[80px] flex items-center justify-center">
Loading your roadmap
</div>
)}
{/* 1) Then your "Where Am I Now?" */}
<h2 className="text-2xl font-bold mb-4">Where you are now and where you are going:</h2>
{/* 1) Career */}
<div className="bg-white p-4 rounded shadow mb-4 flex flex-col justify-center items-center min-h-[80px]">
<p>
<strong>Target Career:</strong>{' '}
{scenarioRow?.career_name || '(Select a career)'}
</p>
{yearsInCareer && (
<p>
<strong>Time in this career:</strong> {yearsInCareer}{' '}
{yearsInCareer === '<1' ? 'year' : 'years'}
</p>
)}
{aiRisk?.riskLevel && (
<p className="text-center mt-2">
<strong>AI Automation Risk:</strong>{' '}
{aiRisk.riskLevel} <br />
<em>{aiRisk.reasoning}</em>
</p>
)}
</div>
{/* 2) Salary Benchmarks */}
<div className="flex flex-col md:flex-row gap-4">
{salaryLoading && (
<div className="bg-white p-4 rounded shadow w-full text-center">
<p className="text-sm text-gray-500">Loading salary data</p>
</div>
)}
{!salaryLoading && salaryData?.regional && (
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
<h4 className="font-medium mb-2">
Regional Salary Data ({userArea || 'U.S.'})
</h4>
<p>
10th percentile:{' '}
{salaryData.regional.regional_PCT10
? `$${parseFloat(salaryData.regional.regional_PCT10).toLocaleString()}`
: 'N/A'}
</p>
<p>
Median:{' '}
{salaryData.regional.regional_MEDIAN
? `$${parseFloat(salaryData.regional.regional_MEDIAN).toLocaleString()}`
: 'N/A'}
</p>
<p>
90th percentile:{' '}
{salaryData.regional.regional_PCT90
? `$${parseFloat(salaryData.regional.regional_PCT90).toLocaleString()}`
: 'N/A'}
</p>
<SalaryGauge
userSalary={userSalary}
percentileRow={salaryData.regional}
prefix="regional"
/>
</div>
)}
{!salaryLoading && salaryData?.national && (
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
<h4 className="font-medium mb-2">National Salary Data</h4>
<p>
10th percentile:{' '}
{salaryData.national.national_PCT10
? `$${parseFloat(salaryData.national.national_PCT10).toLocaleString()}`
: 'N/A'}
</p>
<p>
Median:{' '}
{salaryData.national.national_MEDIAN
? `$${parseFloat(salaryData.national.national_MEDIAN).toLocaleString()}`
: 'N/A'}
</p>
<p>
90th percentile:{' '}
{salaryData.national.national_PCT90
? `$${parseFloat(salaryData.national.national_PCT90).toLocaleString()}`
: 'N/A'}
</p>
<SalaryGauge
userSalary={userSalary}
percentileRow={salaryData.national}
prefix="national"
/>
</div>
)}
{!salaryLoading && !salaryData?.regional && !salaryData?.national && (
<div className="bg-white p-4 rounded shadow w-full text-center">
<p className="text-sm text-gray-500">No salary data found.</p>
</div>
)}
</div>
{/* 3) Economic Projections */}
<div className="flex flex-col md:flex-row gap-4 h-auto overflow-none">
{econLoading && (
<div className="bg-white p-4 rounded shadow w-full text-center">
<p className="text-sm text-gray-500">Loading projections</p>
</div>
)}
{!econLoading && economicProjections?.state && (
<EconomicProjectionsBar data={economicProjections.state} />
)}
{!econLoading && economicProjections?.national && (
<EconomicProjectionsBar data={economicProjections.national} />
)}
</div>
{!economicProjections?.state && !economicProjections?.national && (
<div className="bg-white p-4 rounded shadow">
<p className="text-sm text-gray-500">No economic data found.</p>
</div>
)}
{/* 4) Career Goals
<div className="bg-white p-4 rounded shadow">
<h3 className="text-lg font-semibold mb-2">Your Career Goals</h3>
<p className="text-gray-700">
{scenarioRow?.career_goals || 'No career goals entered yet.'}
</p>
</div>*/}
{/* --- FINANCIAL PROJECTION SECTION -------------------------------- */}
{showMissingBanner && (
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4">
<p className="text-sm text-gray-800">
To improve your projection, please add:
</p>
{!!missingKeys.length && (
<ul className="mt-2 ml-5 list-disc text-sm text-gray-800">
{missingKeys.map((k) => (
<li key={k}>{MISSING_LABELS[k] || k}</li>
))}
</ul>
)}
<Button
className="mt-2"
onClick={() => { setShowEditModal(true); setShowMissingBanner(false); }}
>
Add Details
</Button>
<button
className="ml-3 text-xs text-gray-600 underline"
onClick={() => setShowMissingBanner(false)}
>
Dismiss
</button>
</div>
)}
<div className="bg-white p-4 rounded shadow">
<h3 className="text-lg font-semibold mb-2 flex items-center justify-center gap-1">
Financial Projection
<InfoTooltip message="This projection uses the salary, expense, loan and contribution inputs youve set below. Click “Edit Simulation Inputs” to adjust them, or edit your financial information in Profile -> Financial Profile." />
</h3>
{/* Legal/assumptions disclaimer (static, matches simulator) */}
<FinancialDisclaimer />
{projectionData.length ? (
<div>
{/* Chart now full width */}
<div style={{ height: 360, width: '100%' }}>
<Line
ref={chartRef}
data={{
labels: projectionData.map(p => 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
}}
/>
</div>
<Button onClick={() => chartRef.current?.resetZoom()}
className="mt-2 text-xs text-blue-600 hover:underline"
>
Reset Zoom
</Button>
{loanPayoffMonth && hasStudentLoan && (
<p className="font-semibold text-sm mt-2">
Loan Paid Off:&nbsp;
<span className="text-yellow-600">{loanPayoffMonth}</span>
</p>
)}
</div>
) : (
<p className="text-sm text-gray-500">No financial projection data found.</p>
)}
</div>
{/* Milestones stacked list under chart */}
<div className="mt-4 bg-white p-4 rounded shadow">
<h4 className="text-lg font-semibold mb-2">
Milestones
<InfoTooltip message="Milestones are career or life events—promotions, relocations, degree completions, etc.—that may change your income or spending. They feed directly into the financial projection if they have a financial impact." />
</h4>
<MilestonePanel
groups={milestoneGroups}
onEdit={onEditMilestone}
onSelect={(m) => {
setDrawerMilestone(m);
setDrawerOpen(true);
}}
onAddNewMilestone={() => setAddingNewMilestone(true)}
/>
</div>
{/* 6) Simulation length + Edit scenario */}
<div className="mt-4 space-x-2">
<label className="font-medium">Simulation Length (years):</label>
<input
type="text"
value={simulationYearsInput}
onChange={handleSimulationYearsChange}
onBlur={handleSimulationYearsBlur}
className="border rounded p-1 w-16"
/>
<Button onClick={() => setShowEditModal(true)} className="ml-2">
Edit Simulation Inputs
</Button>
</div>
<ScenarioEditModal
key={careerProfileId}
show={showEditModal}
onClose={(didSave) => {
setShowEditModal(false);
if (didSave) reloadScenarioAndCollege(); // 👈 refresh after save
}}
scenario={scenarioRow}
financialProfile={financialProfile}
setFinancialProfile={setFinancialProfile}
collegeProfile={collegeProfile}
setCollegeProfile={setCollegeProfile}
authFetch={authFetch}
/>
{/* (E1) Interest Strategy */}
<label className="ml-4 font-medium">Interest Rate:</label>
<select
value={interestStrategy}
onChange={(e) => setInterestStrategy(e.target.value)}
className="border rounded p-1"
>
<option value="NONE">No Interest</option>
<option value="FLAT">Flat Rate</option>
<option value="RANDOM">Random</option>
</select>
{/* (E2) If FLAT => show the annual rate */}
{interestStrategy === 'FLAT' && (
<div className="inline-block ml-4">
<label className="mr-1">Annual Rate (%):</label>
<input
type="number"
step="0.01"
value={flatAnnualRate}
onChange={(e) => setFlatAnnualRate(parseFloatOrZero(e.target.value, 0.06))}
className="border rounded p-1 w-20"
/>
</div>
)}
{/* (E3) If RANDOM => show the random range */}
{interestStrategy === 'RANDOM' && (
<div className="inline-block ml-4">
<label className="mr-1">Min Return (%):</label>
<input
type="number"
step="0.01"
value={randomRangeMin}
onChange={(e) => setRandomRangeMin(parseFloatOrZero(e.target.value, -0.02))}
className="border rounded p-1 w-20 mr-2"
/>
<label className="mr-1">Max Return (%):</label>
<input
type="number"
step="0.01"
value={randomRangeMax}
onChange={(e) => setRandomRangeMax(parseFloatOrZero(e.target.value, 0.02))}
className="border rounded p-1 w-20"
/>
</div>
)}
{/* ───────────────────────────────────────────────
1. EDIT EXISTING MILESTONE (modal pops from grid, unchanged)
─────────────────────────────────────────────── */}
{milestoneForModal && (
<MilestoneEditModal
careerProfileId={careerProfileId}
milestones={scenarioMilestones}
milestone={milestoneForModal} /* ← edit mode */
fetchMilestones={fetchMilestones}
onClose={(didSave) => {
if (didSave) handleMilestonesCreated();
setMilestoneForModal(null);
}}
/>
)}
{/* ───────────────────────────────────────────────
2. ADD-NEW MILESTONE (same modal, milestone = null)
─────────────────────────────────────────────── */}
{addingNewMilestone && (
<MilestoneEditModal
careerProfileId={careerProfileId}
milestones={scenarioMilestones}
milestone={null} /* ← create mode */
fetchMilestones={fetchMilestones}
onClose={(didSave) => {
setAddingNewMilestone(false);
if (didSave) fetchMilestones();
}}
/>
)}
{/* ───────────────────────────────────────────────
3. RIGHT-HAND DRAWER
─────────────────────────────────────────────── */}
<MilestoneDrawer
open={drawerOpen}
milestone={drawerMilestone}
onClose={() => 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 */}
{/* <div className="bg-white p-4 rounded shadow mt-4">
<Button onClick={handleAiClick} disabled={aiLoading || clickCount >= DAILY_CLICK_LIMIT}>
{buttonLabel}
</Button>
{aiLoading && <p>Generating your next steps…</p>}
{/* If we have structured recs, show checkboxes
{recommendations.length > 0 && (
<div className="mt-3">
<h3 className="font-semibold">Select the Advice You Want to Keep</h3>
<ul className="mt-2 space-y-2">
{recommendations.map((m) => (
<li key={m.id} className="flex items-start gap-2">
<input
type="checkbox"
checked={selectedIds.includes(m.id)}
onChange={() => handleToggle(m.id)}
/>
<div className="flex flex-col text-left">
<strong>{m.title}</strong>
<span>{m.date}</span>
<p className="text-sm">{m.description}</p>
</div>
</li>
))}
</ul>
{selectedIds.length > 0 && (
<Button className="mt-3" onClick={handleCreateSelectedMilestones}>
Create Milestones from Selected
</Button>
)}
</div>
)}
</div>*/}
</div>
);
}