From eed3767172382334c4536472148f5adec5d9fee1 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 22 Apr 2025 13:27:59 +0000 Subject: [PATCH] Added scenario length user input field and drafted scenario multi-view and scenario container components. --- src/components/MilestoneTracker.js | 31 ++- src/components/MultiScenarioView.js | 162 ++++++++++++ .../PremiumOnboarding/CollegeOnboarding.js | 2 +- src/components/ScenarioContainer.js | 231 ++++++++++++++++++ src/utils/FinancialProjectionService.js | 8 +- user_profile.db | Bin 106496 -> 106496 bytes 6 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 src/components/MultiScenarioView.js create mode 100644 src/components/ScenarioContainer.js diff --git a/src/components/MilestoneTracker.js b/src/components/MilestoneTracker.js index bd6c395..723a7a4 100644 --- a/src/components/MilestoneTracker.js +++ b/src/components/MilestoneTracker.js @@ -58,6 +58,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { const [projectionData, setProjectionData] = useState([]); const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); + const [simulationYearsInput, setSimulationYearsInput] = useState("20"); + const [showEditModal, setShowEditModal] = useState(false); // Possibly loaded from location.state @@ -66,6 +68,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { loanPayoffMonth: initialLoanPayoffMonth = null } = location.state || {}; + const simulationYears = parseInt(simulationYearsInput, 10) || 20; // ------------------------- // 1. Fetch career paths + financialProfile on mount // ------------------------- @@ -198,7 +201,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary, // The key: impacts - milestoneImpacts: allImpacts + milestoneImpacts: allImpacts, + + simulationYears, }; // 5) Run the simulation @@ -218,7 +223,19 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { console.error('Error fetching initial milestones/impacts or simulating:', err); } })(); - }, [financialProfile, collegeProfile, selectedCareer, careerPathId]); + }, [financialProfile, collegeProfile, simulationYears, selectedCareer, careerPathId]); + + const handleSimulationYearsChange = (e) => { + setSimulationYearsInput(e.target.value); // let user type partial/blank + }; + + const handleSimulationYearsBlur = () => { + // Optionally, onBlur you can “normalize” the value + // (e.g. if they left it blank, revert to "20"). + if (simulationYearsInput.trim() === "") { + setSimulationYearsInput("20"); + } + }; // ------------------------------------------------- // 4. reSimulate() => re-fetch everything (financial, college, milestones & impacts), @@ -453,6 +470,16 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { )} +
+ + +
+ setPendingCareerForModal(careerName)} setPendingCareerForModal={setPendingCareerForModal} diff --git a/src/components/MultiScenarioView.js b/src/components/MultiScenarioView.js new file mode 100644 index 0000000..1e87384 --- /dev/null +++ b/src/components/MultiScenarioView.js @@ -0,0 +1,162 @@ +// src/components/MultiScenarioView.js +import React, { useEffect, useState } from 'react'; +import authFetch from '../utils/authFetch.js'; +import ScenarioContainer from './ScenarioContainer.js'; +import { v4 as uuidv4 } from 'uuid'; + +export default function MultiScenarioView() { + const [loading, setLoading] = useState(false); + const [financialProfile, setFinancialProfile] = useState(null); + const [scenarios, setScenarios] = useState([]); // each scenario corresponds to a row in career_paths + + // For error reporting + const [error, setError] = useState(null); + + // 1) On mount, fetch the user’s single financial profile + all career_paths. + useEffect(() => { + async function loadData() { + setLoading(true); + setError(null); + try { + // A) fetch financial profile + let finRes = await authFetch('/api/premium/financial-profile'); + if (!finRes.ok) throw new Error(`FIN profile error: ${finRes.status}`); + let finData = await finRes.json(); + + // B) fetch all career_paths (scenarios) + let scenRes = await authFetch('/api/premium/career-profile/all'); + if (!scenRes.ok) throw new Error(`Scenarios error: ${scenRes.status}`); + let scenData = await scenRes.json(); + + setFinancialProfile(finData); + setScenarios(scenData.careerPaths || []); + } catch (err) { + console.error('Error loading premium data:', err); + setError(err.message || 'Failed to load data'); + } finally { + setLoading(false); + } + } + loadData(); + }, []); + + // 2) “Add Scenario” => create a brand new row in career_paths + async function handleAddScenario() { + try { + // You might prompt user for a scenario name, or just default + const body = { + career_name: 'New Scenario ' + new Date().toLocaleDateString(), + status: 'planned', + start_date: new Date().toISOString(), + college_enrollment_status: 'not_enrolled', + currently_working: 'no' + }; + const res = await authFetch('/api/premium/career-profile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) throw new Error(`Add scenario error: ${res.status}`); + const data = await res.json(); + + // re-fetch scenarios or just push a new scenario object + const newRow = { + id: data.career_path_id, + user_id: null, // we can skip if not needed + career_name: body.career_name, + status: body.status, + start_date: body.start_date, + projected_end_date: null, + college_enrollment_status: body.college_enrollment_status, + currently_working: body.currently_working + }; + setScenarios(prev => [...prev, newRow]); + } catch (err) { + console.error('Failed adding scenario:', err); + alert('Could not add scenario'); + } + } + + // 3) “Clone” => POST a new row in career_paths with copied fields + async function handleCloneScenario(sourceScenario) { + try { + // A simple approach: just create a new row with the same fields + // Then copy the existing scenario fields + const body = { + career_name: sourceScenario.career_name + ' (Copy)', + status: sourceScenario.status, + start_date: sourceScenario.start_date, + projected_end_date: sourceScenario.projected_end_date, + college_enrollment_status: sourceScenario.college_enrollment_status, + currently_working: sourceScenario.currently_working + }; + const res = await authFetch('/api/premium/career-profile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) throw new Error(`Clone scenario error: ${res.status}`); + const data = await res.json(); + + const newScenarioId = data.career_path_id; + + // Optionally, also clone the scenario’s milestones, if you want them duplicated: + // (You’d fetch all existing milestones for sourceScenario, then re-insert them for newScenario.) + // This example just leaves that out for brevity. + + // Add it to local state + const newRow = { + id: newScenarioId, + career_name: body.career_name, + status: body.status, + start_date: body.start_date, + projected_end_date: body.projected_end_date, + college_enrollment_status: body.college_enrollment_status, + currently_working: body.currently_working + }; + setScenarios(prev => [...prev, newRow]); + } catch (err) { + console.error('Failed cloning scenario:', err); + alert('Could not clone scenario'); + } + } + + // 4) “Remove” => (If you want a delete scenario) + async function handleRemoveScenario(scenarioId) { + try { + // If you have a real DELETE endpoint for career_paths, use it: + // For now, we’ll just remove from the local UI: + setScenarios(prev => prev.filter(s => s.id !== scenarioId)); + // Optionally, implement an API call: + // await authFetch(`/api/premium/career-profile/${scenarioId}`, { method: 'DELETE' }); + } catch (err) { + console.error('Failed removing scenario:', err); + alert('Could not remove scenario'); + } + } + + if (loading) return

Loading scenarios...

; + if (error) return

Error: {error}

; + + return ( +
+ {scenarios.map((scen) => ( + handleCloneScenario(scen)} + onRemove={() => handleRemoveScenario(scen.id)} + // Optionally refresh the scenario if user changes it + onScenarioUpdated={(updated) => { + setScenarios(prev => prev.map(s => s.id === scen.id ? { ...s, ...updated } : s)); + }} + /> + ))} + +
+ +
+
+ ); +} diff --git a/src/components/PremiumOnboarding/CollegeOnboarding.js b/src/components/PremiumOnboarding/CollegeOnboarding.js index 6a569c0..c1a015a 100644 --- a/src/components/PremiumOnboarding/CollegeOnboarding.js +++ b/src/components/PremiumOnboarding/CollegeOnboarding.js @@ -266,7 +266,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData, careerPathId }) } setAutoTuition(Math.round(estimate)); - // We do NOT auto-update parent's data. We'll do that in handleSubmit or if you prefer, you can store it in parent's data anyway. + }, [ icTuitionData, selected_school, program_type, credit_hours_per_year, is_in_state, is_in_district, schoolData diff --git a/src/components/ScenarioContainer.js b/src/components/ScenarioContainer.js new file mode 100644 index 0000000..960bf71 --- /dev/null +++ b/src/components/ScenarioContainer.js @@ -0,0 +1,231 @@ +// src/components/ScenarioContainer.js +import React, { useState, useEffect } from 'react'; +import { Line } from 'react-chartjs-2'; +import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; + +// Reuse your existing: +import ScenarioEditModal from './ScenarioEditModal.js'; +import MilestoneTimeline from './MilestoneTimeline.js'; +import AISuggestedMilestones from './AISuggestedMilestones.js'; +import authFetch from '../utils/authFetch.js'; + +export default function ScenarioContainer({ + scenario, // from career_paths row + financialProfile, // single row, shared across user + onClone, + onRemove, + onScenarioUpdated // callback to parent to store updated scenario data +}) { + const [collegeProfile, setCollegeProfile] = useState(null); + const [milestones, setMilestones] = useState([]); + const [universalMilestones, setUniversalMilestones] = useState([]); + + const [projectionData, setProjectionData] = useState([]); + const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null); + + const [editOpen, setEditOpen] = useState(false); + + // 1) Fetch the college profile for this scenario + useEffect(() => { + if (!scenario?.id) return; + async function loadCollegeProfile() { + try { + const res = await authFetch(`/api/premium/college-profile?careerPathId=${scenario.id}`); + if (res.ok) { + const data = await res.json(); + setCollegeProfile(data); + } else { + console.warn('No college profile found or error:', res.status); + setCollegeProfile({}); + } + } catch (err) { + console.error('Failed fetching college profile:', err); + } + } + loadCollegeProfile(); + }, [scenario]); + + // 2) Fetch scenario’s milestones (where is_universal=0) + universal (is_universal=1) + useEffect(() => { + if (!scenario?.id) return; + async function loadMilestones() { + try { + const [scenRes, uniRes] = await Promise.all([ + authFetch(`/api/premium/milestones?careerPathId=${scenario.id}`), + // for universal: we do an extra call with no careerPathId. + // But your current code always requires a careerPathId. So you might + // create a new endpoint /api/premium/milestones?is_universal=1 or something. + // We'll assume you have it: + authFetch(`/api/premium/milestones?careerPathId=universal`) + ]); + + let scenarioData = scenRes.ok ? (await scenRes.json()) : { milestones: [] }; + let universalData = uniRes.ok ? (await uniRes.json()) : { milestones: [] }; + + setMilestones(scenarioData.milestones || []); + setUniversalMilestones(universalData.milestones || []); + } catch (err) { + console.error('Failed to load milestones:', err); + } + } + loadMilestones(); + }, [scenario]); + + // 3) Whenever we have financialProfile + collegeProfile + milestones, run the simulation + useEffect(() => { + if (!financialProfile || !collegeProfile) return; + + // Merge them into the userProfile object for the simulator: + const mergedProfile = { + // Financial fields + currentSalary: financialProfile.current_salary || 0, + monthlyExpenses: financialProfile.monthly_expenses || 0, + monthlyDebtPayments: financialProfile.monthly_debt_payments || 0, + retirementSavings: financialProfile.retirement_savings || 0, + emergencySavings: financialProfile.emergency_fund || 0, + monthlyRetirementContribution: financialProfile.retirement_contribution || 0, + monthlyEmergencyContribution: financialProfile.emergency_contribution || 0, + surplusEmergencyAllocation: financialProfile.extra_cash_emergency_pct || 50, + surplusRetirementAllocation: financialProfile.extra_cash_retirement_pct || 50, + + // College fields (scenario-based) + studentLoanAmount: collegeProfile.existing_college_debt || 0, + interestRate: collegeProfile.interest_rate || 5, + loanTerm: collegeProfile.loan_term || 10, + loanDeferralUntilGraduation: !!collegeProfile.loan_deferral_until_graduation, + academicCalendar: collegeProfile.academic_calendar || 'semester', + annualFinancialAid: collegeProfile.annual_financial_aid || 0, + calculatedTuition: collegeProfile.tuition || 0, + extraPayment: collegeProfile.extra_payment || 0, + gradDate: collegeProfile.expected_graduation || null, + programType: collegeProfile.program_type || '', + creditHoursPerYear: collegeProfile.credit_hours_per_year || 0, + hoursCompleted: collegeProfile.hours_completed || 0, + programLength: collegeProfile.program_length || 0, + + // We assume user’s baseline “inCollege” from the DB: + inCollege: + collegeProfile.college_enrollment_status === 'currently_enrolled' || + collegeProfile.college_enrollment_status === 'prospective_student', + + // If you store expected_salary in collegeProfile + expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary, + + // Flatten the scenario + universal milestones’ impacts + milestoneImpacts: buildAllImpacts([...milestones, ...universalMilestones]) + }; + + const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile); + setProjectionData(projectionData); + setLoanPaidOffMonth(loanPaidOffMonth); + }, [financialProfile, collegeProfile, milestones, universalMilestones]); + + // Helper: Flatten all milestone impacts into one array for the simulator + function buildAllImpacts(allMilestones) { + let impacts = []; + for (let m of allMilestones) { + // Possibly fetch m.impacts if you store them directly on the milestone + // or if you fetch them separately. + // If your code stores them as `m.impacts = [ { direction, amount, ... } ]` + if (m.impacts) { + impacts.push(...m.impacts); + } + // If you also want a milestone that sets a new salary, handle that logic too + // E.g., { impact_type: 'SALARY_CHANGE', start_date: m.date, newSalary: m.new_salary } + } + return impacts; + } + + // 4) We’ll display a single line chart with Net Savings (or cumulativeNetSavings) + const labels = projectionData.map((p) => p.month); + const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0); + + // Grab final row for some KPIs + const finalRow = projectionData[projectionData.length - 1] || {}; + const finalRet = finalRow.retirementSavings?.toFixed(0) || '0'; + const finalEmerg = finalRow.emergencySavings?.toFixed(0) || '0'; + + // 5) Handle “Edit” scenario -> open your existing `ScenarioEditModal.js` + // But that modal currently references setFinancialProfile, setCollegeProfile directly, + // so you may want a specialized version that changes only this scenario’s row. + // For simplicity, we’ll just show how to open it: + + return ( +
+

{scenario.career_name || 'Untitled Scenario'}

+

Status: {scenario.status}

+ + + +
+ Loan Paid Off: {loanPaidOffMonth || 'N/A'}
+ Final Retirement: ${finalRet}
+ Final Emergency: ${finalEmerg} +
+ + {/* The timeline for this scenario. We pass careerPathId */} + {}} + onMilestoneUpdated={() => { + // re-fetch or something + // We'll just force a re-fetch of scenario’s milestones + // or re-run the entire load effect + }} + /> + + {/* Show AI suggestions if you like */} + + +
+ + + +
+ + {/* Reuse your existing ScenarioEditModal that expects + setFinancialProfile, setCollegeProfile, etc. + However, you might want a specialized "ScenarioEditModal" that updates + the DB fields for *this* scenario. For now, we just show how to open. */} + setEditOpen(false)} + financialProfile={financialProfile} + setFinancialProfile={() => { + // If you truly want scenario-specific financial data, + // you’d do a more advanced approach. + // For now, do nothing or re-fetch from server. + }} + collegeProfile={collegeProfile} + setCollegeProfile={(updated) => { + setCollegeProfile((prev) => ({ ...prev, ...updated })); + }} + apiURL="/api" + authFetch={authFetch} + /> +
+ ); +} diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js index d619a0f..9f5347a 100644 --- a/src/utils/FinancialProjectionService.js +++ b/src/utils/FinancialProjectionService.js @@ -135,7 +135,11 @@ export function simulateFinancialProjection(userProfile) { stateCode = 'GA', // Financial milestone impacts - milestoneImpacts = [] + milestoneImpacts = [], + + // Simulation duration + simulationYears = 20 + } = userProfile; /*************************************************** @@ -204,7 +208,7 @@ export function simulateFinancialProjection(userProfile) { /*************************************************** * 6) SETUP FOR THE SIMULATION LOOP ***************************************************/ - const maxMonths = 240; // 20 years + const maxMonths = simulationYears*12; // 20 years let loanBalance = Math.max(studentLoanAmount, 0); let loanPaidOffMonth = null; diff --git a/user_profile.db b/user_profile.db index b44135456a1eb52805db1afd13edd1071d182485..b4507c2450ca8089ad050728f5350dfcc6bba013 100644 GIT binary patch delta 236 zcmZoTz}9epZGtr8+leyHjBht4EZNV&$RE$ZAJ4aQW8+%B$ve+2Gi;P&-LO|DXR9&u4^CYi4M${l7jVs{qh(HyHSD@ZZ=hXt0=n R`g(sxT@_+nv@C!z006ZKLGJ(n delta 236 zcmZoTz}9epZGtr8>xnYXjITE)EZNV&Ai%)DAIGX`3ym7O%1G!%=HWmEVuvH fXJi$CIPM1jjm?4vi}|Oo_h-~qA;w9|0vH1T#Xv$Y