diff --git a/backend/server3.js b/backend/server3.js index 41c8fe9..da56036 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -71,14 +71,17 @@ const authenticatePremiumUser = (req, res, next) => { // GET the latest selected career profile app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (req, res) => { try { - const [rows] = await pool.query(` - SELECT * + const sql = ` + SELECT + *, + DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date, + DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date FROM career_profiles WHERE user_id = ? ORDER BY start_date DESC LIMIT 1 - `, [req.id]); - + `; + const [rows] = await pool.query(sql, [req.id]); res.json(rows[0] || {}); } catch (error) { console.error('Error fetching latest career profile:', error); @@ -89,13 +92,16 @@ app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (re // GET all career profiles for the user app.get('/api/premium/career-profile/all', authenticatePremiumUser, async (req, res) => { try { - const [rows] = await pool.query(` - SELECT * + const sql = ` + SELECT + *, + DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date, + DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date FROM career_profiles WHERE user_id = ? ORDER BY start_date ASC - `, [req.id]); - + `; + const [rows] = await pool.query(sql, [req.id]); res.json({ careerProfiles: rows }); } catch (error) { console.error('Error fetching career profiles:', error); @@ -107,13 +113,18 @@ app.get('/api/premium/career-profile/all', authenticatePremiumUser, async (req, app.get('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, async (req, res) => { const { careerProfileId } = req.params; try { - const [rows] = await pool.query(` - SELECT * + const sql = ` + SELECT + *, + DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date, + DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date FROM career_profiles WHERE id = ? AND user_id = ? - `, [careerProfileId, req.id]); - + LIMIT 1 + `; + const [rows] = await pool.query(sql, [careerProfileId, req.id]); + if (!rows[0]) { return res.status(404).json({ error: 'Career profile not found or not yours.' }); } @@ -124,6 +135,7 @@ app.get('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, } }); + // POST a new career profile (upsert) app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => { const { diff --git a/src/components/MilestoneTracker.js b/src/components/MilestoneTracker.js index 41e79b7..7de3787 100644 --- a/src/components/MilestoneTracker.js +++ b/src/components/MilestoneTracker.js @@ -1,35 +1,39 @@ import React, { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -import { Line } from 'react-chartjs-2'; +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 { Filler } from 'chart.js'; 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 CareerSearch from './CareerSearch.js'; import MilestoneTimeline from './MilestoneTimeline.js'; -import AISuggestedMilestones from './AISuggestedMilestones.js'; import ScenarioEditModal from './ScenarioEditModal.js'; -import parseFloatOrZero from '../utils/ParseFloatorZero.js'; + +// If you need AI suggestions in the future: +// import AISuggestedMilestones from './AISuggestedMilestones.js'; import './MilestoneTracker.css'; import './MilestoneTimeline.css'; -// Register Chart + annotation plugin ChartJS.register( LineElement, + BarElement, CategoryScale, LinearScale, Filler, @@ -39,130 +43,311 @@ ChartJS.register( annotationPlugin ); -const MilestoneTracker = ({ selectedCareer: initialCareer }) => { +// ---------------------- +// 1) Remove decimals from SOC code +// ---------------------- +function stripSocCode(fullSoc) { + if (!fullSoc) return ''; + return fullSoc.split('.')[0]; +} + +// ---------------------- +// 2) Salary Gauge +// ---------------------- +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); +} + +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 ${median.toLocaleString()} +
+
+ +
+
+ ${userSalary.toLocaleString()} +
+
+
+
+ ); +} + +// ---------------------- +// 3) Economic Bar +// ---------------------- +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(); +} + +// ---------------------- +// 4) MilestoneTracker +// ---------------------- +export default function MilestoneTracker({ selectedCareer: initialCareer }) { const location = useLocation(); const apiURL = process.env.REACT_APP_API_URL; - // -------------------------------------------------- - // State - // -------------------------------------------------- - // User and Financial Profile Data const [userProfile, setUserProfile] = useState(null); const [financialProfile, setFinancialProfile] = useState(null); - // Career & Scenario Data + const [masterCareerRatings, setMasterCareerRatings] = useState([]); + const [existingCareerProfiles, setExistingCareerProfiles] = useState([]); const [selectedCareer, setSelectedCareer] = useState(initialCareer || null); const [careerProfileId, setCareerProfileId] = useState(null); - const [existingCareerProfiles, setExistingCareerProfiles] = useState([]); const [scenarioRow, setScenarioRow] = useState(null); const [collegeProfile, setCollegeProfile] = useState(null); - // Milestones & Simulation - const [scenarioMilestones, setScenarioMilestones] = useState([]); - const [projectionData, setProjectionData] = useState([]); - const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); - const [simulationYearsInput, setSimulationYearsInput] = useState('20'); - const simulationYears = parseInt(simulationYearsInput, 10) || 20; - - // Salary Data & Economic Projections + const [strippedSocCode, setStrippedSocCode] = useState(null); const [salaryData, setSalaryData] = useState(null); const [economicProjections, setEconomicProjections] = useState(null); - // UI Toggles - const [showEditModal, setShowEditModal] = useState(false); - const [pendingCareerForModal, setPendingCareerForModal] = useState(null); - const [showAISuggestions, setShowAISuggestions] = useState(false); + const [scenarioMilestones, setScenarioMilestones] = useState([]); + const [projectionData, setProjectionData] = useState([]); + const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); + + const [simulationYearsInput, setSimulationYearsInput] = useState('20'); + const simulationYears = parseInt(simulationYearsInput, 10) || 20; + + const [showEditModal, setShowEditModal] = useState(false); - // If coming from location.state const { - projectionData: initialProjectionData = [], - loanPayoffMonth: initialLoanPayoffMonth = null + projectionData: initProjData = [], + loanPayoffMonth: initLoanMonth = null } = location.state || {}; - // -------------------------------------------------- - // 1) Fetch User Profile & Financial Profile - // -------------------------------------------------- + // 1) Fetch user + financial useEffect(() => { - const fetchUserProfile = async () => { + const fetchUser = async () => { try { - const res = await authFetch('/api/user-profile'); // or wherever user profile is fetched - if (res.ok) { - const data = await res.json(); - setUserProfile(data); - } else { - console.error('Failed to fetch user profile:', res.status); - } - } catch (error) { - console.error('Error fetching user profile:', error); + const r = await authFetch('/api/user-profile'); + if (r.ok) setUserProfile(await r.json()); + } catch (err) { + console.error('Error user-profile =>', err); } }; - - const fetchFinancialProfile = async () => { + const fetchFin = async () => { try { - const res = await authFetch(`${apiURL}/premium/financial-profile`); - if (res.ok) { - const data = await res.json(); - setFinancialProfile(data); - } else { - console.error('Failed to fetch financial profile:', res.status); - } - } catch (error) { - console.error('Error fetching financial profile:', error); + const r = await authFetch(`${apiURL}/premium/financial-profile`); + if (r.ok) setFinancialProfile(await r.json()); + } catch (err) { + console.error('Error financial =>', err); } }; - - fetchUserProfile(); - fetchFinancialProfile(); + fetchUser(); + fetchFin(); }, [apiURL]); - const userLocation = userProfile?.area || ''; - const userSalary = financialProfile?.current_salary ?? 0; + const userSalary = parseFloatOrZero(financialProfile?.current_salary, 0); + const userArea = userProfile?.area || 'U.S.'; + const userState = getFullStateName(userProfile?.state || '') || 'United States'; - // -------------------------------------------------- - // 2) Fetch user’s Career Profiles => set initial scenario - // -------------------------------------------------- + // 2) load local JSON => masterCareerRatings useEffect(() => { - const fetchCareerProfiles = async () => { - const res = await authFetch(`${apiURL}/premium/career-profile/all`); - if (!res || !res.ok) return; - const data = await res.json(); - setExistingCareerProfiles(data.careerProfiles); + fetch('/careers_with_ratings.json') + .then((res) => { + if (!res.ok) throw new Error('Failed to load local career data'); + return res.json(); + }) + .then((data) => setMasterCareerRatings(data)) + .catch((err) => console.error('Error loading local career data =>', err)); + }, []); + + // 3) fetch user’s career-profiles + useEffect(() => { + const fetchProfiles = async () => { + const r = await authFetch(`${apiURL}/premium/career-profile/all`); + if (!r || !r.ok) return; + const d = await r.json(); + setExistingCareerProfiles(d.careerProfiles); - // If there's a career in location.state, pick that const fromPopout = location.state?.selectedCareer; if (fromPopout) { setSelectedCareer(fromPopout); setCareerProfileId(fromPopout.career_profile_id); } else { - // Else try localStorage - const storedCareerProfileId = localStorage.getItem('lastSelectedCareerProfileId'); - if (storedCareerProfileId) { - const matchingCareer = data.careerProfiles.find((p) => p.id === storedCareerProfileId); - if (matchingCareer) { - setSelectedCareer(matchingCareer); - setCareerProfileId(storedCareerProfileId); + const stored = localStorage.getItem('lastSelectedCareerProfileId'); + if (stored) { + const match = d.careerProfiles.find((p) => p.id === stored); + if (match) { + setSelectedCareer(match); + setCareerProfileId(stored); return; } } - - // Fallback to the "latest" scenario - const latest = await authFetch(`${apiURL}/premium/career-profile/latest`); - if (latest && latest.ok) { - const latestData = await latest.json(); - if (latestData?.id) { - setSelectedCareer(latestData); - setCareerProfileId(latestData.id); + // 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); } } } }; - - fetchCareerProfiles(); + fetchProfiles(); }, [apiURL, location.state]); - // -------------------------------------------------- - // 3) Fetch scenarioRow + collegeProfile for chosen careerProfileId - // -------------------------------------------------- + // 4) scenarioRow + college useEffect(() => { if (!careerProfileId) { setScenarioRow(null); @@ -170,239 +355,214 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { setScenarioMilestones([]); return; } - localStorage.setItem('lastSelectedCareerProfileId', careerProfileId); const fetchScenario = async () => { - const scenRes = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`); - if (scenRes.ok) { - const data = await scenRes.json(); - setScenarioRow(data); - } else { - console.error('Failed to fetch scenario row:', scenRes.status); - setScenarioRow(null); - } + const s = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`); + if (s.ok) setScenarioRow(await s.json()); }; - const fetchCollege = async () => { - const colRes = await authFetch( - `${apiURL}/premium/college-profile?careerProfileId=${careerProfileId}` - ); - if (colRes.ok) { - const data = await colRes.json(); - setCollegeProfile(data); - } else { - setCollegeProfile(null); - } + const c = await authFetch(`${apiURL}/premium/college-profile?careerProfileId=${careerProfileId}`); + if (c.ok) setCollegeProfile(await c.json()); }; - fetchScenario(); fetchCollege(); }, [careerProfileId, apiURL]); - // -------------------------------------------------- - // 4) Fetch Salary Data for selectedCareer + userLocation - // -------------------------------------------------- + // 5) from scenarioRow.career_name => find the full SOC => strip useEffect(() => { - if (!selectedCareer?.soc_code) { + 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; } - - const areaParam = userLocation || 'U.S.'; - - const fetchSalaryData = async () => { + (async () => { try { - const queryParams = new URLSearchParams({ - socCode: selectedCareer.soc_code, - area: areaParam + const qs = new URLSearchParams({ + socCode: strippedSocCode, + area: userArea }).toString(); - - const res = await fetch(`/api/salary?${queryParams}`); - if (!res.ok) { - console.error('Error fetching salary data:', res.status); + const url = `${apiURL}/salary?${qs}`; + console.log('[Salary fetch =>]', url); + const r = await fetch(url); + if (!r.ok) { + console.error('[Salary fetch non-200 =>]', r.status); setSalaryData(null); return; } - - const data = await res.json(); - if (data.error) { - console.log('No salary data found for these params:', data.error); - } - setSalaryData(data); + const dd = await r.json(); + console.log('[Salary success =>]', dd); + setSalaryData(dd); } catch (err) { - console.error('Exception fetching salary data:', err); + console.error('[Salary fetch error]', err); setSalaryData(null); } - }; + })(); + }, [strippedSocCode, userArea, apiURL]); - fetchSalaryData(); - }, [selectedCareer, userLocation]); - - // -------------------------------------------------- - // 5) (Optional) Fetch Economic Projections - // -------------------------------------------------- + // 7) Econ useEffect(() => { - if (!selectedCareer?.career_name) { + if (!strippedSocCode || !userState) { setEconomicProjections(null); return; } - - const fetchEconomicProjections = async () => { + (async () => { + const qs = new URLSearchParams({ state: userState }).toString(); + const econUrl = `${apiURL}/projections/${strippedSocCode}?${qs}`; + console.log('[Econ fetch =>]', econUrl); try { - const encodedCareer = encodeURIComponent(selectedCareer.career_name); - const res = await authFetch('/api/projections/:socCode'); - if (res.ok) { - const data = await res.json(); - setEconomicProjections(data); + const r = await authFetch(econUrl); + if (!r.ok) { + console.error('[Econ fetch non-200 =>]', r.status); + setEconomicProjections(null); + return; } + const econData = await r.json(); + console.log('[Econ success =>]', econData); + setEconomicProjections(econData); } catch (err) { - console.error('Error fetching economic projections:', err); + console.error('[Econ fetch error]', err); setEconomicProjections(null); } - }; + })(); + }, [strippedSocCode, userState, apiURL]); - fetchEconomicProjections(); - }, [selectedCareer, apiURL]); - - - // -------------------------------------------------- - // 6) Once we have scenario + financial + college => run simulation - // -------------------------------------------------- - useEffect(() => { - if (!financialProfile || !scenarioRow || !collegeProfile) return; - - (async () => { + // 8) Build financial projection + const buildProjection = async () => { try { - // 1) Fetch milestones for this scenario - const milRes = await authFetch(`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`); - if (!milRes.ok) { - console.error('Failed to fetch milestones for scenario', careerProfileId); + 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 milestonesData = await milRes.json(); - const allMilestones = milestonesData.milestones || []; + const md = await mr.json(); + const allMilestones = md.milestones || []; setScenarioMilestones(allMilestones); - // 2) Fetch impacts for each milestone - const impactPromises = allMilestones.map((m) => + function parseScenarioOverride(overrideVal, fallbackVal) { + // If the DB field is NULL => means user never entered anything + if (overrideVal === null) { + return fallbackVal; + } + // Otherwise user typed a number, even if it's "0" + return parseFloatOrZero(overrideVal, fallbackVal); +} + + + const imPromises = allMilestones.map((m) => authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`) .then((r) => (r.ok ? r.json() : null)) - .then((data) => data?.impacts || []) - .catch((err) => { - console.warn('Error fetching impacts for milestone', m.id, err); + .then((dd) => dd?.impacts || []) + .catch((e) => { + console.warn('Error fetching impacts =>', e); return []; }) ); - const impactsForEach = await Promise.all(impactPromises); + const impactsForEach = await Promise.all(imPromises); + const allImpacts = allMilestones + .map((m, i) => ({ ...m, impacts: impactsForEach[i] || [] })) + .flatMap((m) => m.impacts); - // Flatten all milestone impacts - const allImpacts = allMilestones.map((m, i) => ({ - ...m, - impacts: impactsForEach[i] || [], - })).flatMap((m) => m.impacts); - - /******************************************************* - * A) Parse numeric "financialProfile" fields - *******************************************************/ + const f = financialProfile; const financialBase = { - currentSalary: parseFloatOrZero(financialProfile.current_salary, 0), - additionalIncome: parseFloatOrZero(financialProfile.additional_income, 0), - monthlyExpenses: parseFloatOrZero(financialProfile.monthly_expenses, 0), - monthlyDebtPayments: parseFloatOrZero(financialProfile.monthly_debt_payments, 0), - retirementSavings: parseFloatOrZero(financialProfile.retirement_savings, 0), - emergencySavings: parseFloatOrZero(financialProfile.emergency_fund, 0), - retirementContribution: parseFloatOrZero(financialProfile.retirement_contribution, 0), - emergencyContribution: parseFloatOrZero(financialProfile.emergency_contribution, 0), - extraCashEmergencyPct: parseFloatOrZero(financialProfile.extra_cash_emergency_pct, 50), - extraCashRetirementPct: parseFloatOrZero(financialProfile.extra_cash_retirement_pct, 50), + 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) }; - /******************************************************* - * B) Parse scenario overrides from "scenarioRow" - *******************************************************/ + const s = scenarioRow; const scenarioOverrides = { - monthlyExpenses: parseFloatOrZero( - scenarioRow.planned_monthly_expenses, - financialBase.monthlyExpenses - ), - monthlyDebtPayments: parseFloatOrZero( - scenarioRow.planned_monthly_debt_payments, - financialBase.monthlyDebtPayments - ), - monthlyRetirementContribution: parseFloatOrZero( - scenarioRow.planned_monthly_retirement_contribution, - financialBase.retirementContribution - ), - monthlyEmergencyContribution: parseFloatOrZero( - scenarioRow.planned_monthly_emergency_contribution, - financialBase.emergencyContribution - ), - surplusEmergencyAllocation: parseFloatOrZero( - scenarioRow.planned_surplus_emergency_pct, - financialBase.extraCashEmergencyPct - ), - surplusRetirementAllocation: parseFloatOrZero( - scenarioRow.planned_surplus_retirement_pct, - financialBase.extraCashRetirementPct - ), - additionalIncome: parseFloatOrZero( - scenarioRow.planned_additional_income, - financialBase.additionalIncome - ), - }; + 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 + ), + }; - /******************************************************* - * C) Parse numeric "collegeProfile" fields - *******************************************************/ + + const c = collegeProfile; const collegeData = { - studentLoanAmount: parseFloatOrZero(collegeProfile.existing_college_debt, 0), - interestRate: parseFloatOrZero(collegeProfile.interest_rate, 5), - loanTerm: parseFloatOrZero(collegeProfile.loan_term, 10), - loanDeferralUntilGraduation: !!collegeProfile.loan_deferral_until_graduation, - academicCalendar: collegeProfile.academic_calendar || 'monthly', - annualFinancialAid: parseFloatOrZero(collegeProfile.annual_financial_aid, 0), - calculatedTuition: parseFloatOrZero(collegeProfile.tuition, 0), - extraPayment: parseFloatOrZero(collegeProfile.extra_payment, 0), + 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: - collegeProfile.college_enrollment_status === 'currently_enrolled' || - collegeProfile.college_enrollment_status === 'prospective_student', - gradDate: collegeProfile.expected_graduation || null, - programType: collegeProfile.program_type || null, - creditHoursPerYear: parseFloatOrZero(collegeProfile.credit_hours_per_year, 0), - hoursCompleted: parseFloatOrZero(collegeProfile.hours_completed, 0), - programLength: parseFloatOrZero(collegeProfile.program_length, 0), + 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(collegeProfile.expected_salary) || - parseFloatOrZero(financialProfile.current_salary, 0), + parseFloatOrZero(c.expected_salary) || parseFloatOrZero(f.current_salary, 0) }; - /******************************************************* - * D) Combine them into a single mergedProfile - *******************************************************/ const mergedProfile = { - // Financial base currentSalary: financialBase.currentSalary, - // scenario overrides (with scenario > financial precedence) monthlyExpenses: scenarioOverrides.monthlyExpenses, monthlyDebtPayments: scenarioOverrides.monthlyDebtPayments, - - // big items from financialProfile that had no scenario override retirementSavings: financialBase.retirementSavings, emergencySavings: financialBase.emergencySavings, - - // scenario overrides for monthly contributions monthlyRetirementContribution: scenarioOverrides.monthlyRetirementContribution, monthlyEmergencyContribution: scenarioOverrides.monthlyEmergencyContribution, - - // scenario overrides for surplus distribution surplusEmergencyAllocation: scenarioOverrides.surplusEmergencyAllocation, surplusRetirementAllocation: scenarioOverrides.surplusRetirementAllocation, - - // scenario override for additionalIncome additionalIncome: scenarioOverrides.additionalIncome, - // college fields + // college studentLoanAmount: collegeData.studentLoanAmount, interestRate: collegeData.interestRate, loanTerm: collegeData.loanTerm, @@ -419,17 +579,15 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { programLength: collegeData.programLength, expectedSalary: collegeData.expectedSalary, - // scenario horizon + milestone impacts startDate: new Date().toISOString(), simulationYears, milestoneImpacts: allImpacts }; - // 3) Run the simulation - const { projectionData: pData, loanPaidOffMonth: payoff } = + const { projectionData: pData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile); - // 4) Add cumulative net savings + // Build "cumulativeNetSavings" ourselves, plus each row has .retirementSavings and .emergencySavings let cumu = mergedProfile.emergencySavings || 0; const finalData = pData.map((mo) => { cumu += mo.netSavings || 0; @@ -437,41 +595,34 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { }); setProjectionData(finalData); - setLoanPayoffMonth(payoff); + setLoanPayoffMonth(loanPaidOffMonth); } catch (err) { - console.error('Error in scenario simulation:', err); - } - })(); -}, [ - financialProfile, - scenarioRow, - collegeProfile, - careerProfileId, - apiURL, - simulationYears -]); - - // -------------------------------------------------- - // Handlers & Chart Setup - // -------------------------------------------------- - const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value); - const handleSimulationYearsBlur = () => { - if (!simulationYearsInput.trim()) { - setSimulationYearsInput('20'); + console.error('Error in scenario simulation =>', err); } }; - // Build chart annotations from scenarioMilestones + useEffect(() => { + if (!financialProfile || !scenarioRow || !collegeProfile) return; + buildProjection(); + }, [financialProfile, scenarioRow, collegeProfile, careerProfileId, apiURL, simulationYears]); + + // Handlers + const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value); + const handleSimulationYearsBlur = () => { + if (!simulationYearsInput.trim()) setSimulationYearsInput('20'); + }; + + // -- Annotations -- + // 1) Milestone lines const milestoneAnnotationLines = {}; scenarioMilestones.forEach((m) => { if (!m.date) return; const d = new Date(m.date); if (isNaN(d)) return; - const year = d.getUTCFullYear(); - const month = String(d.getUTCMonth() + 1).padStart(2, '0'); - const short = `${year}-${month}`; - + 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}`] = { @@ -489,9 +640,12 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { }; }); - // Loan payoff line + // 2) Check if there's ever a positive loan balance + const hasStudentLoan = projectionData.some((p) => p.loanBalance > 0); + + // 3) Conditionally add the loan payoff annotation const annotationConfig = {}; - if (loanPayoffMonth) { + if (loanPayoffMonth && hasStudentLoan) { annotationConfig.loanPaidOffLine = { type: 'line', xMin: loanPayoffMonth, @@ -511,238 +665,234 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { } }; } + const allAnnotations = { ...milestoneAnnotationLines, ...annotationConfig }; - // Salary Gauge - function getRelativePosition(userSal, p10, p90) { - if (!p10 || !p90) return 0; // avoid NaN - if (userSal < p10) return 0; - if (userSal > p90) return 1; - return (userSal - p10) / (p90 - p10); - } - - const SalaryGauge = ({ userSalary, percentileRow, prefix = 'regional' }) => { - if (!percentileRow) return null; - const p10 = percentileRow[`${prefix}_PCT10`]; - const p90 = percentileRow[`${prefix}_PCT90`]; - if (!p10 || !p90) return null; - - const fraction = getRelativePosition(userSalary, p10, p90) * 100; - - return ( -
-
-
-
-

- You are at {Math.round(fraction)}% between the 10th and 90th percentiles ( - {prefix}). -

-
- ); + // Build the chart datasets: + const emergencyData = { + label: 'Emergency Savings', + data: projectionData.map((p) => p.emergencySavings), + borderColor: 'rgba(255, 159, 64, 1)', // orange + 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 + }; + + // The total leftover each month (sum of any net gains so far). + 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 + }; + + // We'll insert the Loan Balance dataset only if they actually have a loan + 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' + } + }; + + // The final dataset array: + // 1) Emergency + // 2) Retirement + // 3) Loan (conditional) + // 4) Total + const chartDatasets = [emergencyData, retirementData]; + if (hasStudentLoan) { + // Insert loan after the first two lines, or wherever you prefer + chartDatasets.push(loanBalanceData); + } + chartDatasets.push(totalSavingsData); + + const yearsInCareer = getYearsInCareer(scenarioRow?.start_date); + return (
- {/* 1) Career dropdown */} - { - setSelectedCareer(selected); - setCareerProfileId(selected?.id || null); - }} - loading={!existingCareerProfiles.length} - authFetch={authFetch} - /> +

Where Am I Now?

- {/* 2) Salary Data Display */} - {salaryData && ( -
-

Salary Overview

- {/* Regional Salaries */} - {salaryData.regional && ( -
-

Regional Salaries (Area: {userLocation || 'U.S.'})

-

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

-

- 25th percentile:{' '} - ${salaryData.regional.regional_PCT25?.toLocaleString() ?? 'N/A'} -

-

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

-

- 75th percentile:{' '} - ${salaryData.regional.regional_PCT75?.toLocaleString() ?? 'N/A'} -

-

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

- -
- )} - {/* National Salaries */} - {salaryData.national && ( -
-

National Salaries

-

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

-

- 25th percentile:{' '} - ${salaryData.national.national_PCT25?.toLocaleString() ?? 'N/A'} -

-

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

-

- 75th percentile:{' '} - ${salaryData.national.national_PCT75?.toLocaleString() ?? 'N/A'} -

-

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

- -
- )} -

- Your current salary: ${userSalary.toLocaleString()} + {/* 1) Career */} +

+ { + setSelectedCareer(sel); + setCareerProfileId(sel?.id || null); + }} + loading={!existingCareerProfiles.length} + authFetch={authFetch} + /> +
+

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

+ {yearsInCareer && ( +

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

+ )} +
+
+ + {/* 2) Salary Benchmarks */} +
+ {salaryData?.regional && ( +
+

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

+

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

+ +

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

+ +

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

+ + +
+ )} + + {salaryData?.national && ( +
+

National Data

+

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

+ +

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

+ +

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

+ + +
+ )} +
+ + {/* 3) Economic Projections */} +
+ {economicProjections?.state && ( + + )} + {economicProjections?.national && ( + + )} +
+ {!economicProjections?.state && !economicProjections?.national && ( +
+

No economic data found.

)} - {/* 3) Milestone Timeline */} - {}} - /> - - {/* 4) AI Suggestions Button */} - {!showAISuggestions && ( - - )} - - {/* 5) AI-Suggested Milestones */} - {showAISuggestions && ( - +

Your Milestones

+ {}} /> - )} +
- {/* 6) Financial Projection Chart */} - {projectionData.length > 0 && ( -
-

Financial Projection

- p.month), - datasets: [ - { - label: 'Total Savings', - data: projectionData.map((p) => p.cumulativeNetSavings), - borderColor: 'rgba(54, 162, 235, 1)', - backgroundColor: 'rgba(54, 162, 235, 0.2)', - tension: 0.4, - fill: true - }, - { - 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' + {/* 5) Financial Projection */} +
+

Financial Projection

+ {projectionData.length > 0 ? ( + <> + p.month), + datasets: chartDatasets + }} + options={{ + responsive: true, + plugins: { + legend: { position: 'bottom' }, + tooltip: { mode: 'index', intersect: false }, + annotation: { + annotations: allAnnotations } }, - { - 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 - } - ] - }} - options={{ - responsive: true, - plugins: { - legend: { position: 'bottom' }, - tooltip: { mode: 'index', intersect: false }, - annotation: { - annotations: allAnnotations - } - }, - scales: { - y: { - beginAtZero: false, - ticks: { - callback: (value) => `$${value.toLocaleString()}` + scales: { + y: { + beginAtZero: false, + ticks: { + callback: (val) => `$${val.toLocaleString()}` + } } } - } - }} - /> -
- {loanPayoffMonth && ( -

- Loan Paid Off at: {loanPayoffMonth} + }} + /> + {loanPayoffMonth && hasStudentLoan && ( +

+ Loan Paid Off at:{' '} + {loanPayoffMonth}

)} -
-
- )} + + ) : ( +

No financial projection data found.

+ )} +
- {/* 7) Simulation length + "Edit" => open ScenarioEditModal */} -
+ {/* 6) Simulation length + Edit scenario */} +
{ Edit
- - {/* 8) Economic Projections Section */} - {economicProjections && ( -
-

Economic Projections

-

- Growth Outlook: {economicProjections.growthOutlook || 'N/A'} -

-

- AI Automation Risk: {economicProjections.aiRisk || 'N/A'} -

- {economicProjections.chatGPTAnalysis && ( -
-

ChatGPT Analysis:

-

{economicProjections.chatGPTAnalysis}

-
- )} -
- )} - - {/* 9) Career Search & Potential new scenario creation */} - { - setPendingCareerForModal(careerObj.title); - }} - /> - {pendingCareerForModal && ( - - )} - - {/* 10) Scenario Edit Modal */} { @@ -808,8 +919,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { apiURL={apiURL} authFetch={authFetch} /> +
); -}; - -export default MilestoneTracker; +} diff --git a/src/components/PremiumOnboarding/OnboardingContainer.js b/src/components/PremiumOnboarding/OnboardingContainer.js index 0214685..37cb6d1 100644 --- a/src/components/PremiumOnboarding/OnboardingContainer.js +++ b/src/components/PremiumOnboarding/OnboardingContainer.js @@ -21,6 +21,16 @@ const OnboardingContainer = () => { const nextStep = () => setStep(step + 1); const prevStep = () => setStep(step - 1); + function parseFloatOrNull(value) { + // If user left it blank ("" or undefined), treat it as NULL. + if (value == null || value === '') { + return null; + } + const parsed = parseFloat(value); + // If parseFloat can't parse, also return null + return isNaN(parsed) ? null : parsed; +} + console.log('Final collegeData in OnboardingContainer:', collegeData); // Final “all done” submission when user finishes the last step @@ -29,17 +39,14 @@ const OnboardingContainer = () => { // Build a scenarioPayload that includes optional planned_* fields: const scenarioPayload = { ...careerData, - planned_monthly_expenses: parseFloat(careerData.planned_monthly_expenses) || 0, - planned_monthly_debt_payments: parseFloat(careerData.planned_monthly_debt_payments) || 0, - planned_monthly_retirement_contribution: - parseFloat(careerData.planned_monthly_retirement_contribution) || 0, - planned_monthly_emergency_contribution: - parseFloat(careerData.planned_monthly_emergency_contribution) || 0, - planned_surplus_emergency_pct: parseFloat(careerData.planned_surplus_emergency_pct) || 0, - planned_surplus_retirement_pct: - parseFloat(careerData.planned_surplus_retirement_pct) || 0, - planned_additional_income: parseFloat(careerData.planned_additional_income) || 0, - }; + planned_monthly_expenses: parseFloatOrNull(careerData.planned_monthly_expenses), + planned_monthly_debt_payments: parseFloatOrNull(careerData.planned_monthly_debt_payments), + planned_monthly_retirement_contribution: parseFloatOrNull(careerData.planned_monthly_retirement_contribution), + planned_monthly_emergency_contribution: parseFloatOrNull(careerData.planned_monthly_emergency_contribution), + planned_surplus_emergency_pct: parseFloatOrNull(careerData.planned_surplus_emergency_pct), + planned_surplus_retirement_pct: parseFloatOrNull(careerData.planned_surplus_retirement_pct), + planned_additional_income: parseFloatOrNull(careerData.planned_additional_income), +}; // 1) POST career-profile (scenario) const careerRes = await authFetch('/api/premium/career-profile', { @@ -62,21 +69,28 @@ const OnboardingContainer = () => { }); if (!financialRes.ok) throw new Error('Failed to save financial profile'); - // 3) POST college-profile (now uses career_profile_id) - const mergedCollege = { - ...collegeData, - career_profile_id, - college_enrollment_status: careerData.college_enrollment_status, - }; - const collegeRes = await authFetch('/api/premium/college-profile', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mergedCollege), - }); - if (!collegeRes.ok) throw new Error('Failed to save college profile'); + // 3) Only do college-profile if user is "currently_enrolled" or "prospective_student" + if ( + careerData.college_enrollment_status === 'currently_enrolled' || + careerData.college_enrollment_status === 'prospective_student' + ) { + const mergedCollege = { + ...collegeData, + career_profile_id, + college_enrollment_status: careerData.college_enrollment_status, + }; + const collegeRes = await authFetch('/api/premium/college-profile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mergedCollege), + }); + if (!collegeRes.ok) throw new Error('Failed to save college profile'); + } else { + console.log('Skipping college-profile upsert because user is not enrolled/planning.'); + } - // All done → navigate away - navigate('/milestone-tracker'); + // Done => navigate + navigate('/milestone-tracker'); } catch (err) { console.error(err); // (optionally show error to user) diff --git a/src/components/ScenarioEditModal.js b/src/components/ScenarioEditModal.js index b76c788..972c4c9 100644 --- a/src/components/ScenarioEditModal.js +++ b/src/components/ScenarioEditModal.js @@ -606,16 +606,24 @@ export default function ScenarioEditModal({ collegePayload.loan_deferral_until_graduation = 1; } - // 3) Upsert college - const colRes = await authFetch('/api/premium/college-profile', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(collegePayload) - }); - if (!colRes.ok) { - const msg2 = await colRes.text(); - throw new Error(`College upsert failed: ${msg2}`); - } + // 3) Upsert or skip + if (finalCollegeStatus === 'currently_enrolled' || + finalCollegeStatus === 'prospective_student') + { + const colRes = await authFetch('/api/premium/college-profile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(collegePayload), + }); + if (!colRes.ok) { + const msg2 = await colRes.text(); + throw new Error(`College upsert failed: ${msg2}`); + } + } else { + console.log('Skipping college-profile upsert in EditScenarioModal because user not enrolled'); + // Optionally: if you want to delete an existing college profile: + // await authFetch(`/api/premium/college-profile/delete/${updatedScenarioId}`, { method: 'DELETE' }); + } // 4) Re-fetch scenario, college, financial => aggregator => simulate const [scenResp2, colResp2, finResp] = await Promise.all([ diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js index 01f7bc7..b4fcc58 100644 --- a/src/utils/FinancialProjectionService.js +++ b/src/utils/FinancialProjectionService.js @@ -433,6 +433,7 @@ export function simulateFinancialProjection(userProfile) { loanBalance: +loanBalance.toFixed(2), loanPaymentThisMonth: +(monthlyLoanPayment + extraPayment).toFixed(2), + totalSavings: (currentEmergencySavings + currentRetirementSavings).toFixed(2), fedYTDgross: +fedYTDgross.toFixed(2), fedYTDtax: +fedYTDtax.toFixed(2), diff --git a/src/utils/fetchCareerEnrichment.js b/src/utils/fetchCareerEnrichment.js new file mode 100644 index 0000000..5b29cf2 --- /dev/null +++ b/src/utils/fetchCareerEnrichment.js @@ -0,0 +1,22 @@ +// utils/fetchCareerEnrichment.js + +import axios from 'axios'; + +export async function fetchCareerEnrichment(apiUrl, socCode, area) { + // strippedSoc = remove decimals from e.g. "15-1132.00" => "15-1132" + const strippedSoc = socCode.includes('.') ? socCode.split('.')[0] : socCode; + + const [cipData, jobDetailsData, economicData, salaryData] = await Promise.all([ + axios.get(`${apiUrl}/cip/${socCode}`).catch(() => null), + axios.get(`${apiUrl}/onet/career-description/${socCode}`).catch(() => null), + axios.get(`${apiUrl}/projections/${strippedSoc}`, { params: { area } }).catch(() => null), + axios.get(`${apiUrl}/salary`, { params: { socCode: strippedSoc, area } }).catch(() => null), + ]); + + return { + cip: cipData?.data || null, + jobDetails: jobDetailsData?.data || null, + economic: economicData?.data || null, + salary: salaryData?.data || null, + }; +}