dev1/src/components/CareerRoadmap.js

1158 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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 } from 'react';
import { useLocation } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
LineElement,
BarElement,
CategoryScale,
LinearScale,
Filler,
PointElement,
Tooltip,
Legend
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
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 { Button } from './ui/button.js';
import CareerSelectDropdown from './CareerSelectDropdown.js';
import MilestoneTimeline from './MilestoneTimeline.js';
import ScenarioEditModal from './ScenarioEditModal.js';
import './CareerRoadmap.css';
import './MilestoneTimeline.css';
// --------------
// Register ChartJS Plugins
// --------------
ChartJS.register(
LineElement,
BarElement,
CategoryScale,
LinearScale,
Filler,
PointElement,
Tooltip,
Legend,
annotationPlugin
);
// --------------
// Helper Functions
// --------------
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();
}
/**
* parseAiJson
* If ChatGPT returns a fenced code block like:
* ```json
* [ { ... }, ... ]
* ```
* we extract that JSON. Otherwise, we parse the raw string.
*/
function parseAiJson(rawText) {
const fencedRegex = /```json\s*([\s\S]*?)\s*```/i;
const match = rawText.match(fencedRegex);
if (match) {
const jsonStr = match[1].trim();
const arr = JSON.parse(jsonStr);
// Add an "id" for each milestone
arr.forEach((m) => {
m.id = crypto.randomUUID();
});
return arr;
} else {
// fallback if no fences
const arr = JSON.parse(rawText);
arr.forEach((m) => {
m.id = crypto.randomUUID();
});
return arr;
}
}
export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const location = useLocation();
const apiURL = process.env.REACT_APP_API_URL;
const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'MONTE_CARLO'
const [flatAnnualRate, setFlatAnnualRate] = useState(0.06); // 6% default
const [randomRangeMin, setRandomRangeMin] = useState(-0.02); // -3% monthly
const [randomRangeMax, setRandomRangeMax] = useState(0.02); // 8% monthly
// Basic states
const [userProfile, setUserProfile] = useState(null);
const [financialProfile, setFinancialProfile] = useState(null);
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
const [existingCareerProfiles, setExistingCareerProfiles] = useState([]);
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
const [careerProfileId, setCareerProfileId] = useState(null);
const [scenarioRow, setScenarioRow] = useState(null);
const [collegeProfile, setCollegeProfile] = useState(null);
const [strippedSocCode, setStrippedSocCode] = useState(null);
const [salaryData, setSalaryData] = useState(null);
const [economicProjections, setEconomicProjections] = useState(null);
// Milestones & Projection
const [scenarioMilestones, setScenarioMilestones] = useState([]);
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
// 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 {
projectionData: initProjData = [],
loanPayoffMonth: initLoanMonth = null
} = location.state || {};
// 1) Fetch user + financial
useEffect(() => {
async function fetchUser() {
try {
const r = await authFetch('/api/user-profile');
if (r.ok) setUserProfile(await r.json());
} catch (err) {
console.error('Error user-profile =>', err);
}
}
async function fetchFin() {
try {
const r = await authFetch(`${apiURL}/premium/financial-profile`);
if (r.ok) setFinancialProfile(await r.json());
} catch (err) {
console.error('Error financial =>', err);
}
}
fetchUser();
fetchFin();
}, [apiURL]);
const userSalary = parseFloatOrZero(financialProfile?.current_salary, 0);
const userArea = userProfile?.area || 'U.S.';
const userState = getFullStateName(userProfile?.state || '') || 'United States';
useEffect(() => {
let timer;
if (buttonDisabled) {
timer = setTimeout(() => setButtonDisabled(false), RATE_LIMIT_SECONDS * 1000);
}
return () => clearTimeout(timer);
}, [buttonDisabled]);
useEffect(() => {
const storedRecs = localStorage.getItem('aiRecommendations');
if (storedRecs) {
try {
const arr = JSON.parse(storedRecs);
arr.forEach((m) => {
if (!m.id) {
m.id = crypto.randomUUID();
}
});
setRecommendations(arr);
} catch (err) {
console.error('Error parsing stored AI recs =>', err);
}
}
}, []);
useEffect(() => {
if (recommendations.length > 0) {
localStorage.setItem('aiRecommendations', JSON.stringify(recommendations));
} else {
// if it's empty, we can remove from localStorage if you want
localStorage.removeItem('aiRecommendations');
}
}, [recommendations]);
// 2) load local JSON => masterCareerRatings
useEffect(() => {
fetch('/careers_with_ratings.json')
.then((res) => {
if (!res.ok) throw new Error('Failed to load local career data');
return res.json();
})
.then((data) => setMasterCareerRatings(data))
.catch((err) => console.error('Error loading local career data =>', err));
}, []);
// 3) fetch users career-profiles
useEffect(() => {
async function fetchProfiles() {
const r = await authFetch(`${apiURL}/premium/career-profile/all`);
if (!r || !r.ok) return;
const d = await r.json();
setExistingCareerProfiles(d.careerProfiles);
const fromPopout = location.state?.selectedCareer;
if (fromPopout) {
setSelectedCareer(fromPopout);
setCareerProfileId(fromPopout.career_profile_id);
} else {
const stored = localStorage.getItem('lastSelectedCareerProfileId');
if (stored) {
const match = d.careerProfiles.find((p) => p.id === stored);
if (match) {
setSelectedCareer(match);
setCareerProfileId(stored);
return;
}
}
// fallback => latest
const lr = await authFetch(`${apiURL}/premium/career-profile/latest`);
if (lr && lr.ok) {
const ld = await lr.json();
if (ld?.id) {
setSelectedCareer(ld);
setCareerProfileId(ld.id);
}
}
}
}
fetchProfiles();
}, [apiURL, location.state]);
// 4) scenarioRow + college
useEffect(() => {
if (!careerProfileId) {
setScenarioRow(null);
setCollegeProfile(null);
setScenarioMilestones([]);
return;
}
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
async function fetchScenario() {
const s = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`);
if (s.ok) setScenarioRow(await s.json());
}
async function fetchCollege() {
const c = await authFetch(`${apiURL}/premium/college-profile?careerProfileId=${careerProfileId}`);
if (c.ok) setCollegeProfile(await c.json());
}
fetchScenario();
fetchCollege();
}, [careerProfileId, apiURL]);
// 5) from scenarioRow => find the full SOC => strip
useEffect(() => {
if (!scenarioRow?.career_name || !masterCareerRatings.length) {
setStrippedSocCode(null);
return;
}
const lower = scenarioRow.career_name.trim().toLowerCase();
const found = masterCareerRatings.find(
(obj) => obj.title?.trim().toLowerCase() === lower
);
if (!found) {
console.warn('No matching SOC =>', scenarioRow.career_name);
setStrippedSocCode(null);
return;
}
setStrippedSocCode(stripSocCode(found.soc_code));
}, [scenarioRow, masterCareerRatings]);
// 6) Salary
useEffect(() => {
if (!strippedSocCode) {
setSalaryData(null);
return;
}
(async () => {
try {
const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea }).toString();
const url = `${apiURL}/salary?${qs}`;
const r = await fetch(url);
if (!r.ok) {
console.error('[Salary fetch non-200 =>]', r.status);
setSalaryData(null);
return;
}
const dd = await r.json();
setSalaryData(dd);
} catch (err) {
console.error('[Salary fetch error]', err);
setSalaryData(null);
}
})();
}, [strippedSocCode, userArea, apiURL]);
// 7) Econ
useEffect(() => {
if (!strippedSocCode || !userState) {
setEconomicProjections(null);
return;
}
(async () => {
const qs = new URLSearchParams({ state: userState }).toString();
const econUrl = `${apiURL}/projections/${strippedSocCode}?${qs}`;
try {
const r = await authFetch(econUrl);
if (!r.ok) {
console.error('[Econ fetch non-200 =>]', r.status);
setEconomicProjections(null);
return;
}
const econData = await r.json();
setEconomicProjections(econData);
} catch (err) {
console.error('[Econ fetch error]', err);
setEconomicProjections(null);
}
})();
}, [strippedSocCode, userState, apiURL]);
// 8) Build financial projection
async function buildProjection() {
try {
const milUrl = `${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`;
const mr = await authFetch(milUrl);
if (!mr.ok) {
console.error('Failed to fetch milestones =>', mr.status);
return;
}
const md = await mr.json();
const allMilestones = md.milestones || [];
setScenarioMilestones(allMilestones);
// fetch impacts
const imPromises = allMilestones.map((m) =>
authFetch(`${apiURL}/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);
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)
};
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,
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,
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) return;
buildProjection();
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, apiURL, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
// Build chart datasets / annotations
const milestoneAnnotationLines = {};
scenarioMilestones.forEach((m) => {
if (!m.date) return;
const d = new Date(m.date);
if (isNaN(d)) return;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const short = `${yyyy}-${mm}`;
if (!projectionData.some((p) => p.month === short)) return;
milestoneAnnotationLines[`milestone_${m.id}`] = {
type: 'line',
xMin: short,
xMax: short,
borderColor: 'orange',
borderWidth: 2,
label: {
display: true,
content: m.title || 'Milestone',
color: 'orange',
position: 'end'
}
};
});
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 hasStudentLoan = projectionData.some((p) => p.loanBalance > 0);
const annotationConfig = {};
if (loanPayoffMonth && hasStudentLoan) {
annotationConfig.loanPaidOffLine = {
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 },
rotation: 0,
yAdjust: -10
}
};
}
const allAnnotations = { ...milestoneAnnotationLines, ...annotationConfig };
const emergencyData = {
label: 'Emergency Savings',
data: projectionData.map((p) => p.emergencySavings),
borderColor: 'rgba(255, 159, 64, 1)',
backgroundColor: 'rgba(255, 159, 64, 0.2)',
tension: 0.4,
fill: true
};
const retirementData = {
label: 'Retirement Savings',
data: projectionData.map((p) => p.retirementSavings),
borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4,
fill: true
};
const totalSavingsData = {
label: 'Total Savings',
data: projectionData.map((p) => p.totalSavings),
borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: 'rgba(54, 162, 235, 0.2)',
tension: 0.4,
fill: true
};
const loanBalanceData = {
label: 'Loan Balance',
data: projectionData.map((p) => p.loanBalance),
borderColor: 'rgba(255, 99, 132, 1)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
tension: 0.4,
fill: {
target: 'origin',
above: 'rgba(255,99,132,0.3)',
below: 'transparent'
}
};
const chartDatasets = [emergencyData, retirementData];
if (hasStudentLoan) chartDatasets.push(loanBalanceData);
chartDatasets.push(totalSavingsData);
const yearsInCareer = getYearsInCareer(scenarioRow?.start_date);
// -- AI Handler --
async function handleAiClick() {
if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
alert('You have reached the daily limit for suggestions.');
return;
}
if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
alert('You have reached your daily limit of AI-generated recommendations. Please check back tomorrow.');
return;
}
setAiLoading(true);
setSelectedIds([]);
const oldRecTitles = recommendations.map(r => r.title.trim()).filter(Boolean);
const acceptedTitles = scenarioMilestones.map(m => (m.title || '').trim()).filter(Boolean);
const allToAvoid = [...oldRecTitles, ...acceptedTitles];
try {
const payload = {
userProfile,
scenarioRow,
financialProfile,
collegeProfile,
previouslyUsedTitles: allToAvoid
};
const res = await authFetch('/api/premium/ai/next-steps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('AI request failed');
const data = await res.json();
const rawText = data.recommendations || '';
const arr = parseAiJson(rawText);
setRecommendations(arr);
localStorage.setItem('aiRecommendations', JSON.stringify(arr));
// Update click count
setClickCount(prev => {
const newCount = prev + 1;
localStorage.setItem('aiClickCount', newCount);
return newCount;
});
} catch (err) {
console.error('Error fetching AI next steps =>', err);
} finally {
setAiLoading(false);
}
}
function handleToggle(recId) {
setSelectedIds((prev) => {
if (prev.includes(recId)) {
return prev.filter((x) => x !== recId);
} else {
return [...prev, recId];
}
});
}
async function handleCreateSelectedMilestones() {
if (!careerProfileId) return;
const confirm = window.confirm('Create the selected AI suggestions as milestones?');
if (!confirm) return;
const selectedRecs = recommendations.filter((r) => selectedIds.includes(r.id));
if (!selectedRecs.length) return;
// Use the AI-suggested date:
const payload = selectedRecs.map((rec) => ({
title: rec.title,
description: rec.description || '',
date: rec.date, // <-- use AI's date, not today's date
career_profile_id: careerProfileId
}));
try {
const r = await authFetch('/api/premium/milestone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ milestones: payload })
});
if (!r.ok) throw new Error('Failed to create new milestones');
// re-run projection to see them in the chart
await buildProjection();
// optionally clear
alert('Milestones created successfully!');
setSelectedIds([]);
} catch (err) {
console.error('Error creating milestones =>', err);
alert('Error saving new AI milestones.');
}
}
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?';
return (
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
<h2 className="text-2xl font-bold mb-4">Where Am I Now?</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>Current Career:</strong>{' '}
{scenarioRow?.career_name || '(Select a career)'}
</p>
{yearsInCareer && (
<p>
<strong>Time in this career:</strong> {yearsInCareer}{' '}
{yearsInCareer === '<1' ? 'year' : 'years'}
</p>
)}
</div>
{/* 2) Salary Benchmarks */}
<div className="flex flex-col md:flex-row gap-4">
{salaryData?.regional && (
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
<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>
)}
{salaryData?.national && (
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
<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>
)}
</div>
{/* 3) Economic Projections */}
<div className="flex flex-col md:flex-row gap-4">
{economicProjections?.state && (
<EconomicProjectionsBar data={economicProjections.state} />
)}
{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>
{/* 5) Financial Projection */}
<div className="bg-white p-4 rounded shadow">
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
{projectionData.length > 0 ? (
<>
<Line
data={{
labels: projectionData.map((p) => p.month),
datasets: chartDatasets
}}
options={{
responsive: true,
plugins: {
legend: { position: 'bottom' },
tooltip: { mode: 'index', intersect: false },
annotation: { annotations: allAnnotations }
},
scales: {
y: {
beginAtZero: false,
ticks: {
callback: (val) => `$${val.toLocaleString()}`
}
}
}
}}
/>
{loanPayoffMonth && hasStudentLoan && (
<p className="font-semibold text-sm mt-2">
Loan Paid Off at:{' '}
<span className="text-yellow-600">{loanPayoffMonth}</span>
</p>
)}
</>
) : (
<p className="text-sm text-gray-500">No financial projection data found.</p>
)}
</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
</Button>
</div>
<ScenarioEditModal
show={showEditModal}
onClose={() => {
setShowEditModal(false);
window.location.reload();
}}
scenario={scenarioRow}
financialProfile={financialProfile}
setFinancialProfile={setFinancialProfile}
collegeProfile={collegeProfile}
setCollegeProfile={setCollegeProfile}
apiURL={apiURL}
authFetch={authFetch}
/>
{/* (E1) Interest Strategy */}
<label className="ml-4 font-medium">Interest Strategy:</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="MONTE_CARLO">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 MONTE_CARLO => show the random range */}
{interestStrategy === 'MONTE_CARLO' && (
<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>
)}
{/* 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>
);
}