From 5a5f2068d9d469daa8285b2f90b249a937528525 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 4 Apr 2025 11:49:52 +0000 Subject: [PATCH] FinancialProfileForm updates to accommodate school/program/degree selection and addition of tuition field. --- backend/server3.js | 39 ++- package-lock.json | 28 +- package.json | 2 + src/App.js | 38 +-- src/components/AISuggestedMilestones.js | 12 +- src/components/FinancialProfileForm.js | 398 +++++++++++++++++------- src/components/GettingStarted.js | 2 +- src/components/MilestoneTimeline.js | 11 + src/components/MilestoneTracker.js | 147 ++++++++- src/utils/FinancialProjectionService.js | 110 +++++++ src/utils/authFetch.js | 5 +- user_profile.db | Bin 57344 -> 57344 bytes 12 files changed, 631 insertions(+), 161 deletions(-) create mode 100644 src/utils/FinancialProjectionService.js diff --git a/backend/server3.js b/backend/server3.js index 88eaf44..34d2a4b 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -10,6 +10,9 @@ import { v4 as uuidv4 } from 'uuid'; import path from 'path'; import { fileURLToPath } from 'url'; + + + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -374,11 +377,11 @@ app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req, const { currentSalary, additionalIncome, monthlyExpenses, monthlyDebtPayments, retirementSavings, retirementContribution, emergencyFund, - inCollege, expectedGraduation, partTimeIncome, tuitionPaid, collegeLoanTotal + inCollege, expectedGraduation, partTimeIncome, tuitionPaid, collegeLoanTotal, + careerPathId // ✅ Required to run simulation } = req.body; try { - // Upsert-style logic: Check if exists const existing = await db.get(`SELECT id FROM financial_profile WHERE user_id = ?`, [req.userId]); if (existing) { @@ -409,7 +412,36 @@ app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req, ]); } - res.status(200).json({ message: 'Financial profile saved.' }); + // ✅ Run projection only if careerPathId is provided + if (!careerPathId) { + return res.status(200).json({ message: 'Financial profile saved. No projection generated.' }); + } + + const milestones = await db.all( + `SELECT * FROM milestones WHERE user_id = ? AND career_path_id = ? ORDER BY date ASC`, + [req.userId, careerPathId] + ); + + const projectionData = simulateFinancialProjection({ + currentSalary, + additionalIncome, + monthlyExpenses, + monthlyDebtPayments, + retirementSavings, + retirementContribution, + emergencySavings: emergencyFund, + studentLoanAmount: collegeLoanTotal, + studentLoanAPR: 5.5, // placeholder default, can be user-supplied later + loanTermYears: 10, // placeholder default, can be user-supplied later + milestones, + gradDate: expectedGraduation, + fullTimeCollegeStudent: !!inCollege, + partTimeIncome, + startDate: moment() + }); + + return res.status(200).json({ message: 'Financial profile saved.', projectionData }); + } catch (error) { console.error('Error saving financial profile:', error); res.status(500).json({ error: 'Failed to save financial profile.' }); @@ -417,6 +449,7 @@ app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req, }); + // Retrieve career history app.get('/api/premium/career-history', authenticatePremiumUser, async (req, res) => { try { diff --git a/package-lock.json b/package-lock.json index 4aa728c..3f9f41f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "axios": "^1.7.9", "bcrypt": "^5.1.1", "chart.js": "^4.4.7", + "chartjs-plugin-annotation": "^3.1.0", "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", "clsx": "^2.1.1", @@ -26,6 +27,7 @@ "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "lucide-react": "^0.483.0", + "moment": "^2.30.1", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", @@ -6191,6 +6193,15 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-plugin-annotation": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz", + "integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=4.0.0" + } + }, "node_modules/check-types": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", @@ -12830,6 +12841,15 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -18429,9 +18449,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", "peer": true, "bin": { @@ -18439,7 +18459,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { diff --git a/package.json b/package.json index 24e1a82..cd93fc7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "axios": "^1.7.9", "bcrypt": "^5.1.1", "chart.js": "^4.4.7", + "chartjs-plugin-annotation": "^3.1.0", "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", "clsx": "^2.1.1", @@ -21,6 +22,7 @@ "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "lucide-react": "^0.483.0", + "moment": "^2.30.1", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", diff --git a/src/App.js b/src/App.js index f9cbd8a..ee6e42b 100644 --- a/src/App.js +++ b/src/App.js @@ -12,7 +12,8 @@ import MilestoneTracker from "./components/MilestoneTracker.js"; import './App.css'; function App() { - + console.log("App rendered"); + const [isAuthenticated, setIsAuthenticated] = useState(() => { return !!localStorage.getItem('token'); // Check localStorage }); @@ -29,37 +30,22 @@ function App() { } /> {/* Protected routes */} - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> - : } - /> + {isAuthenticated && ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + + )} {/* Catch-all for unknown routes */} } /> - ); } diff --git a/src/components/AISuggestedMilestones.js b/src/components/AISuggestedMilestones.js index c13b608..3284e8f 100644 --- a/src/components/AISuggestedMilestones.js +++ b/src/components/AISuggestedMilestones.js @@ -2,11 +2,21 @@ import React, { useEffect, useState } from 'react'; -const AISuggestedMilestones = ({ userId, career, careerPathId, authFetch, activeView }) => { +const AISuggestedMilestones = ({ userId, career, careerPathId, authFetch, activeView, projectionData }) => { const [suggestedMilestones, setSuggestedMilestones] = useState([]); const [selected, setSelected] = useState([]); const [loading, setLoading] = useState(false); + useEffect(() => { + if (!Array.isArray(projectionData)) { + console.warn('⚠️ projectionData is not an array:', projectionData); + return; + } + + console.log('📊 projectionData sample:', projectionData.slice(0, 3)); + }, [projectionData]); + + useEffect(() => { if (!career) return; setSuggestedMilestones([ diff --git a/src/components/FinancialProfileForm.js b/src/components/FinancialProfileForm.js index b0fbd64..ec1967b 100644 --- a/src/components/FinancialProfileForm.js +++ b/src/components/FinancialProfileForm.js @@ -1,75 +1,199 @@ +// Updated FinancialProfileForm.js with autosuggest for school and full field list restored import React, { useState, useEffect } from "react"; import { useLocation, useNavigate } from 'react-router-dom'; import authFetch from '../utils/authFetch.js'; +function FinancialProfileForm() { + const navigate = useNavigate(); + const location = useLocation(); -export default function FinancialProfileForm() { - const location = useLocation(); - const navigate = useNavigate(); - console.log("🔍 FinancialProfileForm mounted"); - console.log("🔍 location.state:", location.state); - const initialCareer = location?.state?.selectedCareer; - const [selectedCareer, setSelectedCareer] = useState(initialCareer || null); - const userId = localStorage.getItem("userId"); - const [formData, setFormData] = useState({ - currentSalary: "", - additionalIncome: "", - monthlyExpenses: "", - monthlyDebtPayments: "", - retirementSavings: "", - retirementContribution: "", - emergencyFund: "", - inCollege: false, - expectedGraduation: "", - partTimeIncome: "", - tuitionPaid: "", - collegeLoanTotal: "" - }); + const [userId] = useState(() => localStorage.getItem("userId")); + const [selectedCareer] = useState(() => location.state?.selectedCareer || null); + + const [currentSalary, setCurrentSalary] = useState(""); + const [additionalIncome, setAdditionalIncome] = useState(""); + const [monthlyExpenses, setMonthlyExpenses] = useState(""); + const [monthlyDebtPayments, setMonthlyDebtPayments] = useState(""); + const [retirementSavings, setRetirementSavings] = useState(""); + const [retirementContribution, setRetirementContribution] = useState(""); + const [emergencyFund, setEmergencyFund] = useState(""); + const [inCollege, setInCollege] = useState(false); + const [expectedGraduation, setExpectedGraduation] = useState(""); + const [partTimeIncome, setPartTimeIncome] = useState(""); + const [tuitionPaid, setTuitionPaid] = useState(""); + const [collegeLoanTotal, setCollegeLoanTotal] = useState(""); + const [existingCollegeDebt, setExistingCollegeDebt] = useState(""); + const [creditHoursPerYear, setCreditHoursPerYear] = useState(""); + const [programType, setProgramType] = useState(""); + const [isFullyOnline, setIsFullyOnline] = useState(false); + const [selectedSchool, setSelectedSchool] = useState(""); + const [selectedProgram, setSelectedProgram] = useState(""); + const [manualTuition, setManualTuition] = useState(""); + + const [schoolData, setSchoolData] = useState([]); + const [schoolSuggestions, setSchoolSuggestions] = useState([]); + const [programSuggestions, setProgramSuggestions] = useState([]); + const [availableProgramTypes, setAvailableProgramTypes] = useState([]); + const [calculatedTuition, setCalculatedTuition] = useState(0); useEffect(() => { - console.log("✅ selectedCareer in useEffect:", selectedCareer); - }, [selectedCareer]); - - // Fetch existing data on mount - useEffect(() => { - async function fetchFinancialProfile() { - try { - const res = await authFetch("/api/premium/financial-profile", { - method: "GET", - headers: { - "Authorization": `Bearer ${localStorage.getItem('token')}` - } - }); - - if (res.ok) { - const data = await res.json(); - if (data && Object.keys(data).length > 0) { - setFormData((prev) => ({ ...prev, ...data })); - } else { - console.log("No existing financial profile. Starting fresh."); + async function fetchSchoolData() { + const res = await fetch('/cip_institution_mapping_new.json'); + const text = await res.text(); + const lines = text.split('\n'); + const parsed = lines.map(line => { + try { + return JSON.parse(line); + } catch { + return null; } - } else { - console.warn("Response not OK when fetching financial profile:", res.status); - } - } catch (err) { - console.error("Failed to fetch financial profile", err); + }).filter(Boolean); + setSchoolData(parsed); } - } + fetchSchoolData(); + }, []); - fetchFinancialProfile(); -}, [userId]); + useEffect(() => { + async function fetchFinancialProfile() { + try { + const res = await authFetch("/api/premium/financial-profile", { + method: "GET", + headers: { "Authorization": `Bearer ${localStorage.getItem('token')}` } + }); + if (res.ok) { + const data = await res.json(); + if (data && Object.keys(data).length > 0) { + setCurrentSalary(data.current_salary || ""); + setAdditionalIncome(data.additional_income || ""); + setMonthlyExpenses(data.monthly_expenses || ""); + setMonthlyDebtPayments(data.monthly_debt_payments || ""); + setRetirementSavings(data.retirement_savings || ""); + setRetirementContribution(data.retirement_contribution || ""); + setEmergencyFund(data.emergency_fund || ""); + setInCollege(!!data.in_college); + setExpectedGraduation(data.expected_graduation || ""); + setPartTimeIncome(data.part_time_income || ""); + setTuitionPaid(data.tuition_paid || ""); + setCollegeLoanTotal(data.college_loan_total || ""); + setExistingCollegeDebt(data.existing_college_debt || ""); + setCreditHoursPerYear(data.credit_hours_per_year || ""); + setProgramType(data.program_type || ""); + setIsFullyOnline(!!data.is_fully_online); + setSelectedSchool(data.selected_school || ""); + setSelectedProgram(data.selected_program || ""); + } + } + } catch (err) { + console.error("Failed to fetch financial profile", err); + } + } - const handleChange = (e) => { - const { name, value, type, checked } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: type === "checkbox" ? checked : value - })); + fetchFinancialProfile(); + }, [userId]); + + useEffect(() => { + if (selectedSchool && schoolData.length > 0) { + // Filter programs for the selected school and display them as suggestions + const programs = schoolData + .filter(s => s.INSTNM.toLowerCase() === selectedSchool.toLowerCase()) + .map(s => s.CIPDESC); + + // Filter unique programs and show the first 10 + setProgramSuggestions([...new Set(programs)].slice(0, 10)); + } + }, [selectedSchool, schoolData]); + + useEffect(() => { + if (selectedProgram && selectedSchool && schoolData.length > 0) { + const types = schoolData + .filter(s => s.CIPDESC === selectedProgram && s.INSTNM.toLowerCase() === selectedSchool.toLowerCase()) + .map(s => s.CREDDESC); + setAvailableProgramTypes([...new Set(types)]); + } + }, [selectedProgram, selectedSchool, schoolData]); + + useEffect(() => { + if (selectedSchool && selectedProgram && programType && schoolData.length > 0) { + const match = schoolData.find(s => + s.INSTNM.toLowerCase() === selectedSchool.toLowerCase() && + s.CIPDESC === selectedProgram && + s.CREDDESC === programType + ); + const tuition = match ? parseFloat(match[isFullyOnline ? "Out State Graduate" : "In_state cost"] || 0) : 0; + setCalculatedTuition(tuition); + } + }, [selectedSchool, selectedProgram, programType, isFullyOnline, schoolData]); + + const handleSchoolChange = (e) => { + const value = e.target.value; + setSelectedSchool(value); + const filtered = schoolData.filter(s => s.INSTNM.toLowerCase().includes(value.toLowerCase())); + const unique = [...new Set(filtered.map(s => s.INSTNM))]; + setSchoolSuggestions(unique.slice(0, 10)); + setSelectedProgram(""); + setAvailableProgramTypes([]); + }; + + const handleSchoolSelect = (name) => { + setSelectedSchool(name); + setSchoolSuggestions([]); + setSelectedProgram(""); + setAvailableProgramTypes([]); + setProgramSuggestions([]); + }; + + const handleProgramChange = (e) => { + const value = e.target.value; + setSelectedProgram(value); + + if (!value) { + setProgramSuggestions([]); + return; + } + + const filtered = schoolData.filter(s => + s.INSTNM.toLowerCase() === selectedSchool.toLowerCase() && + s.CIPDESC.toLowerCase().includes(value.toLowerCase()) + ); + + const uniquePrograms = [...new Set(filtered.map(s => s.CIPDESC))]; + setProgramSuggestions(uniquePrograms); + + const filteredTypes = schoolData.filter(s => + s.INSTNM.toLowerCase() === selectedSchool.toLowerCase() && + s.CIPDESC === value + ).map(s => s.CREDDESC); + setAvailableProgramTypes([...new Set(filteredTypes)]); + }; + + const handleProgramTypeSelect = (e) => { + setProgramType(e.target.value); }; const handleSubmit = async (e) => { e.preventDefault(); + const formData = { + currentSalary, + additionalIncome, + monthlyExpenses, + monthlyDebtPayments, + retirementSavings, + retirementContribution, + emergencyFund, + inCollege, + expectedGraduation, + partTimeIncome, + tuitionPaid, + collegeLoanTotal, + existingCollegeDebt, + creditHoursPerYear, + programType, + isFullyOnline, + selectedSchool, + selectedProgram + }; + try { const res = await authFetch("/api/premium/financial-profile", { method: "POST", @@ -81,90 +205,126 @@ export default function FinancialProfileForm() { navigate('/milestone-tracker', { state: { selectedCareer } }); - } + } } catch (err) { console.error("Error submitting financial profile:", err); } }; + const handleInput = (setter) => (e) => setter(e.target.value); + return (

Your Financial Profile

-
- - -
+ + -
- - -
+ + -
- - -
+ + -
- - -
+ + -
- - -
+ + -
- - -
+ + -
- - -
+ +
- + setInCollege(e.target.checked)} />
- {formData.inCollege && ( + {inCollege && ( <> -
- - -
+ {/* Selected School input with suggestions */} + + + {schoolSuggestions.length > 0 && ( +
    + {schoolSuggestions.map((suggestion, idx) => ( +
  • handleSchoolSelect(suggestion)} + className="p-2 hover:bg-blue-100 cursor-pointer" + > + {suggestion} +
  • + ))} +
+ )} -
- - -
- -
- - -
- -
- - -
- - )} + {/* Program input with suggestions */} + + + {selectedProgram.length > 0 && programSuggestions.length > 0 && ( +
    + {programSuggestions.map((suggestion, idx) => ( +
  • { + setSelectedProgram(suggestion); + setProgramSuggestions([]); + const filteredTypes = schoolData.filter(s => + s.INSTNM.toLowerCase() === selectedSchool.toLowerCase() && + s.CIPDESC === suggestion + ).map(s => s.CREDDESC); + setAvailableProgramTypes([...new Set(filteredTypes)]); + }} + className="p-2 hover:bg-blue-100 cursor-pointer" + > + {suggestion} +
  • + ))} +
+ )} + + {/* Program Type input */} + + + + )} + <> + + +
diff --git a/src/components/MilestoneTimeline.js b/src/components/MilestoneTimeline.js index 5f6c150..e30ef11 100644 --- a/src/components/MilestoneTimeline.js +++ b/src/components/MilestoneTimeline.js @@ -179,6 +179,17 @@ const filteredMilestones = raw.filter( setNewMilestone({ ...newMilestone, description: e.target.value })} /> setNewMilestone({ ...newMilestone, date: e.target.value })} /> setNewMilestone({ ...newMilestone, progress: parseInt(e.target.value, 10) })} /> + {activeView === 'Financial' && ( +
+ setNewMilestone({ ...newMilestone, newSalary: parseFloat(e.target.value) })} + /> +

Enter the full new salary (not just the change) after the milestone has taken place.

+
+ )} )} diff --git a/src/components/MilestoneTracker.js b/src/components/MilestoneTracker.js index 0d31bff..2bce1f0 100644 --- a/src/components/MilestoneTracker.js +++ b/src/components/MilestoneTracker.js @@ -2,12 +2,22 @@ import React, { useState, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { v4 as uuidv4 } from 'uuid'; +import { Line } from 'react-chartjs-2'; +import { Chart as ChartJS, LineElement, CategoryScale, LinearScale, PointElement, Tooltip, Legend } from 'chart.js'; +import annotationPlugin from 'chartjs-plugin-annotation'; +import { Filler } from 'chart.js'; + import CareerSelectDropdown from './CareerSelectDropdown.js'; import CareerSearch from './CareerSearch.js'; import MilestoneTimeline from './MilestoneTimeline.js'; import AISuggestedMilestones from './AISuggestedMilestones.js'; import './MilestoneTracker.css'; import './MilestoneTimeline.css'; // Ensure this file contains styles for timeline-line and milestone-dot +import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; + +ChartJS.register( LineElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, Legend, annotationPlugin ); + + const MilestoneTracker = ({ selectedCareer: initialCareer }) => { const location = useLocation(); @@ -19,10 +29,15 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false); const [pendingCareerForModal, setPendingCareerForModal] = useState(null); const [activeView, setActiveView] = useState("Career"); + const [projectionData, setProjectionData] = useState(null); + const [financialProfile, setFinancialProfile] = useState(null); // Store the financial profile + const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); + const apiURL = process.env.REACT_APP_API_URL; + const authFetch = async (url, options = {}) => { const token = localStorage.getItem('token'); if (!token) { @@ -44,19 +59,19 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { const fetchCareerPaths = async () => { const res = await authFetch(`${apiURL}/premium/planned-path/all`); if (!res) return; - + const data = await res.json(); // Flatten nested array const flatPaths = data.careerPath.flat(); - + // Handle duplicates const uniquePaths = Array.from( new Set(flatPaths.map(cp => cp.career_name)) ).map(name => flatPaths.find(cp => cp.career_name === name)); - + setExistingCareerPaths(uniquePaths); - + const fromPopout = location.state?.selectedCareer; if (fromPopout) { setSelectedCareer(fromPopout); @@ -72,8 +87,41 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { } } }; + + const fetchFinancialProfile = async () => { + const res = await authFetch(`${apiURL}/premium/financial-profile`); + if (res && res.ok) { + const data = await res.json(); + setFinancialProfile(data); // Set the financial profile in state + } + }; + fetchCareerPaths(); - }, []); + fetchFinancialProfile(); + }, []); + + // Calculate financial projection based on the profile + useEffect(() => { + if (financialProfile && selectedCareer) { + const { projectionData, loanPaidOffMonth } = simulateFinancialProjection({ + currentSalary: financialProfile.current_salary, + monthlyExpenses: financialProfile.monthly_expenses, + studentLoanAmount: financialProfile.college_loan_total, + studentLoanAPR: 5.5, // Default rate + loanTermYears: 10, // Default term + emergencySavings: financialProfile.emergency_fund, + retirementSavings: financialProfile.retirement_savings, + monthlyRetirementContribution: financialProfile.retirement_contribution, + monthlyEmergencyContribution: 0, // Add emergency savings contribution if available + gradDate: financialProfile.expected_graduation, + fullTimeCollegeStudent: financialProfile.in_college, + partTimeIncome: financialProfile.part_time_income, + startDate: new Date(), + }); + setProjectionData(projectionData); + setLoanPayoffMonth(loanPaidOffMonth); // Set the projection data + } + }, [financialProfile, selectedCareer]); const handleCareerChange = (selected) => { if (selected && selected.id && selected.career_name) { @@ -84,6 +132,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { } }; + console.log("📊 projectionData sample:", projectionData?.slice(0, 5)); + const handleConfirmCareerSelection = async () => { const newId = uuidv4(); const body = { career_path_id: newId, career_name: pendingCareerForModal, start_date: new Date().toISOString().split('T')[0] }; @@ -116,9 +166,92 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { {console.log('Passing careerPathId to MilestoneTimeline:', careerPathId)} - + - +

Financial Projection

+ p.month), + datasets: [ + { + label: 'Net Savings', + data: projectionData.map(p => p.netSavings), + 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)', // loan debt + below: 'transparent' // don't show below 0 + } + }, + { + label: 'Retirement Savings', + data: projectionData.map(p => p.totalRetirementSavings), + 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: loanPayoffMonth + ? { + annotations: { + 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: { + style: 'bold', + size: 12 + }, + rotation: 0, + yAdjust: -10 + } + } + } + } + : undefined + }, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: (value) => `$${value.toLocaleString()}` + } + } + } + }} + /> + + )} + + setPendingCareerForModal(careerName)} setPendingCareerForModal={setPendingCareerForModal} authFetch={authFetch} diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js new file mode 100644 index 0000000..ab39d23 --- /dev/null +++ b/src/utils/FinancialProjectionService.js @@ -0,0 +1,110 @@ +import moment from 'moment'; + +// Function to simulate monthly financial projection +// src/utils/FinancialProjectionService.js + +export function simulateFinancialProjection(userProfile) { + const { + currentSalary, + monthlyExpenses, + studentLoanAmount, + studentLoanAPR, + loanTermYears, + milestones = [], + emergencySavings, + retirementSavings, + monthlyRetirementContribution, + monthlyEmergencyContribution, + gradDate, + fullTimeCollegeStudent, + partTimeIncome = 0, + startDate = new Date() + } = userProfile; + + const monthlyLoanPayment = calculateLoanPayment(studentLoanAmount, studentLoanAPR, loanTermYears); + + let totalEmergencySavings = emergencySavings; + let totalRetirementSavings = retirementSavings; + let loanBalance = studentLoanAmount; + let monthlyIncome = currentSalary; + let projectionData = []; + + const graduationDate = gradDate ? new Date(gradDate) : null; + let milestoneIndex = 0; + let loanPaidOffMonth = null; + + const date = new Date(startDate); + for (let month = 0; month < 240; month++) { + // ✅ Advance date forward by one month + date.setMonth(date.getMonth() + 1); + + if (loanBalance <= 0 && !loanPaidOffMonth) { + loanPaidOffMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + } + + // Check for milestone + if ( + milestoneIndex < milestones.length && + new Date(milestones[milestoneIndex].date) <= date + ) { + const milestone = milestones[milestoneIndex]; + if (milestone.type === 'salary') { + monthlyIncome = milestone.newSalary; + } + milestoneIndex++; + } + + // If in college before graduation, adjust income + if (graduationDate && date < graduationDate) { + monthlyIncome = fullTimeCollegeStudent ? 0 : partTimeIncome; + } + + let thisMonthLoanPayment = 0; + + if (loanBalance > 0) { + const interestForMonth = loanBalance * (studentLoanAPR / 100 / 12); + const principalForMonth = Math.min(loanBalance, monthlyLoanPayment - interestForMonth); + + loanBalance -= principalForMonth; + loanBalance = Math.max(loanBalance, 0); + + thisMonthLoanPayment = monthlyLoanPayment; + } + + let extraCash = monthlyLoanPayment - thisMonthLoanPayment; + + totalEmergencySavings += monthlyEmergencyContribution + (extraCash * 0.3); // 30% redirect + totalRetirementSavings += monthlyRetirementContribution + (extraCash * 0.7); // 70% redirect + totalRetirementSavings *= (1 + 0.07 / 12); // compound growth + + projectionData.push({ + month: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`, + salary: monthlyIncome, + monthlyIncome: monthlyIncome / 12, + expenses: monthlyExpenses, + loanPayment: monthlyLoanPayment, + retirementContribution: monthlyRetirementContribution, + emergencyContribution: monthlyEmergencyContribution, + netSavings: + monthlyIncome - + (monthlyExpenses + + thisMonthLoanPayment + + monthlyRetirementContribution + + monthlyEmergencyContribution), + + totalEmergencySavings, + totalRetirementSavings, + loanBalance + }); + + } + + return { projectionData, loanPaidOffMonth }; + +} + +function calculateLoanPayment(principal, annualRate, years) { + const monthlyRate = annualRate / 100 / 12; + const numPayments = years * 12; + return (principal * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numPayments)); +} diff --git a/src/utils/authFetch.js b/src/utils/authFetch.js index 1b2a658..ae08b25 100644 --- a/src/utils/authFetch.js +++ b/src/utils/authFetch.js @@ -12,11 +12,14 @@ const authFetch = async (url, options = {}) => { return null; } + const method = options.method?.toUpperCase() || 'GET'; + const shouldIncludeContentType = ['POST', 'PUT', 'PATCH'].includes(method); + const res = await fetch(url, { ...options, headers: { Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', + ...(shouldIncludeContentType && { 'Content-Type': 'application/json' }), ...options.headers, }, }); diff --git a/user_profile.db b/user_profile.db index 4e9e72c12ac7db69769c976360e4ee2ff8bef1c4..2a416957b39cb439791c5b494d4c92470a8de6e4 100644 GIT binary patch delta 388 zcmZoTz}#?vd4e=!^F$eE#^#L)Y4VoFrpAUQriR9b#>U2mriLblMy5t4#zw|QCO~Lt zVq#(lW|pn;S5*Q;#S zpLdB-lHY`Zi}wu!-vqu#eCfPz_;&K&;OFL#<+Ix?wcrGwrUr8;BQdt~Z{}YATY+T? z1OJrGf(l*yf?13Lp`uI>z^TZ@#5{TLeN7|b4u)TxM;Lyo3ouL*ZUs8bQrEyj*T9(Z X3+o+*zc1h}HC8Y*wlXrXGBE`JD>GwW delta 244 zcmZoTz}#?vd4e=!;Y1l{#=?yWY4Vn)riO+lMnGUmBBcMW4lSEaDdmViX7!WrP4uULY`@d}qIkoRP7y fg_8syzr38dk%5t^u7QcJfuVw-v6Zo*m4PJyo3}l7