E2E bug fixes: 20
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Josh 2025-08-25 13:14:09 +00:00
parent fe8102385e
commit e6d567d839
16 changed files with 810 additions and 514 deletions

View File

@ -1 +1 @@
3eefb2cd6c785e5815d042d108f67a87c6819a4d-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b b632ad41cfb05900be9a667c396e66a4dfb26320-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -1,8 +1,12 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import authFetch from "../utils/authFetch.js"; import authFetch from "../utils/authFetch.js";
const isoToday = new Date().toISOString().slice(0,10); // top-level helper const isoToday = new Date().toISOString().slice(0,10); // top-level helper
async function ensureCoachThread() { async function ensureCoachThread() {
// try to list an existing thread // try to list an existing thread
const r = await authFetch('/api/premium/coach/chat/threads'); const r = await authFetch('/api/premium/coach/chat/threads');
@ -21,6 +25,19 @@ async function ensureCoachThread() {
return id; return id;
} }
const isHiddenPrompt = (m) => {
if (!m || !m.content) return false;
const c = String(m.content);
// Heuristics that match your hidden prompts / modes
return (
m.role === 'system' ||
c.startsWith('# ⛔️') ||
c.startsWith('MODE :') ||
c.startsWith('MODE:') ||
c.includes('"milestones"') && c.includes('"tasks"') && c.includes('"date"') && c.includes('"title"')
);
};
function buildInterviewPrompt(careerName, jobDescription = "") { function buildInterviewPrompt(careerName, jobDescription = "") {
return ` return `
You are an expert interviewer for the role **${careerName}**. You are an expert interviewer for the role **${careerName}**.
@ -136,6 +153,7 @@ export default function CareerCoach({
onMilestonesCreated, onMilestonesCreated,
onAiRiskFetched onAiRiskFetched
}) { }) {
const location = useLocation();
/* -------------- state ---------------- */ /* -------------- state ---------------- */
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@ -153,6 +171,45 @@ export default function CareerCoach({
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight; if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
}, [messages]); }, [messages]);
// If career_name is still missing, fetch the profile once
useEffect(() => {
if (scenarioRow?.career_name || !careerProfileId) return;
let cancelled = false;
(async () => {
try {
const r = await authFetch(`/api/premium/career-profile/${careerProfileId}`);
if (!r.ok || cancelled) return;
const row = await r.json();
if (!row?.career_name) return;
setScenarioRow(prev => ({
...prev,
career_name: prev?.career_name || row.career_name,
soc_code : prev?.soc_code || row.soc_code || ''
}));
} catch (_) {}
})();
return () => { cancelled = true; };
}, [careerProfileId, scenarioRow?.career_name, setScenarioRow]);
// Hydrate career_name/soc_code from nav or localStorage if missing
useEffect(() => {
if (scenarioRow?.career_name) return;
let sel = null;
const navSel =
location.state?.selectedCareer ??
location.state?.premiumOnboardingState?.selectedCareer ?? null;
if (navSel) sel = navSel;
else {
try { sel = JSON.parse(localStorage.getItem('selectedCareer') || 'null'); } catch {}
}
if (!sel) return;
setScenarioRow(prev => ({
...prev,
career_name: prev?.career_name || sel.title || 'this career',
soc_code : prev?.soc_code || sel.soc_code || sel.socCode || sel.code || ''
}));
}, [location.state, scenarioRow?.career_name, setScenarioRow]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -171,16 +228,23 @@ useEffect(() => {
if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) { if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) {
const data = await r3.json(); const data = await r3.json();
const msgs = Array.isArray(data.messages) ? data.messages : []; const msgs = (Array.isArray(data.messages) ? data.messages : []).filter(m => !isHiddenPrompt(m));
if (!cancelled) setMessages(msgs.length ? msgs : [generatePersonalizedIntro()]); if (!cancelled) {
setMessages(msgs); // no intro here
historyLoadedRef.current = true;
}
} else { } else {
if (!cancelled) setMessages([generatePersonalizedIntro()]); if (!cancelled) {
setMessages([]); // no intro here
historyLoadedRef.current = true;
}
} }
} catch (e) { } catch (e) {
console.error("Coach thread preload failed:", e); console.error("Coach thread preload failed:", e);
if (!cancelled) { if (!cancelled) {
setThreadId(null); setThreadId(null);
setMessages([generatePersonalizedIntro()]); setMessages([]);
historyLoadedRef.current = true;
} }
} }
})(); })();
@ -191,9 +255,11 @@ useEffect(() => {
/* -------------- intro ---------------- */ /* -------------- intro ---------------- */
useEffect(() => { useEffect(() => {
if (!scenarioRow || !historyLoadedRef.current) return; if (!historyLoadedRef.current) return;
if (!scenarioRow?.career_name) return;
if (!userProfile) return; // wait for profile (career_situation)
setMessages(prev => (prev.length ? prev : [generatePersonalizedIntro()])); setMessages(prev => (prev.length ? prev : [generatePersonalizedIntro()]));
}, [scenarioRow?.id]); }, [historyLoadedRef.current, scenarioRow?.career_name, userProfile]);
/* ---------- helpers you already had ---------- */ /* ---------- helpers you already had ---------- */
function buildStatusSituationMessage(status, situation, careerName) { function buildStatusSituationMessage(status, situation, careerName) {
@ -240,7 +306,9 @@ We can refine details anytime or just jump straight to what you're most interest
function generatePersonalizedIntro() { function generatePersonalizedIntro() {
/* (unchanged body) */ /* (unchanged body) */
const careerName = scenarioRow?.career_name || "this career"; const careerName =
scenarioRow?.career_name ||
(() => { try { return (JSON.parse(localStorage.getItem('selectedCareer')||'null')?.title) || 'this career'; } catch { return 'this career'; } })();
const goalsText = scenarioRow?.career_goals?.trim() || null; const goalsText = scenarioRow?.career_goals?.trim() || null;
const riskLevel = scenarioRow?.riskLevel; const riskLevel = scenarioRow?.riskLevel;
const riskReasoning = scenarioRow?.riskReasoning; const riskReasoning = scenarioRow?.riskReasoning;
@ -407,7 +475,7 @@ I'm here to support you with personalized coaching. What would you like to focus
className="overflow-y-auto border rounded mb-4 space-y-2" className="overflow-y-auto border rounded mb-4 space-y-2"
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }} style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
> >
{messages.map((m, i) => ( {messages.filter(m => !isHiddenPrompt(m)).map((m, i) => (
<div <div
key={i} key={i}
className={`rounded p-2 ${ className={`rounded p-2 ${

View File

@ -4,46 +4,104 @@ import authFetch from '../utils/authFetch.js';
const CareerPrioritiesModal = ({ userProfile, onClose }) => { const CareerPrioritiesModal = ({ userProfile, onClose }) => {
const [responses, setResponses] = useState({}); const [responses, setResponses] = useState({});
useEffect(() => { const QUESTIONS = [
if (userProfile?.career_priorities) { { id: 'interests',
setResponses(JSON.parse(userProfile.career_priorities));
}
}, [userProfile]);
// Updated "interests" question:
const questions = [
{
id: 'interests',
text: 'How important is it that your career aligns with your personal interests?', text: 'How important is it that your career aligns with your personal interests?',
options: ['Very important', 'Somewhat important', 'Not as important'], options: ['Very important', 'Somewhat important', 'Not as important'],
}, },
{ { id: 'meaning',
id: 'meaning',
text: 'Is it important your job helps others or makes a difference?', text: 'Is it important your job helps others or makes a difference?',
options: ['Yes, very important', 'Somewhat important', 'Not as important'], options: ['Yes, very important', 'Somewhat important', 'Not as important'],
}, },
{ { id: 'stability',
id: 'stability',
text: 'How important is it that your career pays well?', text: 'How important is it that your career pays well?',
options: ['Very important', 'Somewhat important', 'Not as important'], options: ['Very important', 'Somewhat important', 'Not as important'],
}, },
{ { id: 'growth',
id: 'growth',
text: 'Do you want clear chances to advance and grow professionally?', text: 'Do you want clear chances to advance and grow professionally?',
options: ['Yes, very important', 'Somewhat important', 'Not as important'], options: ['Yes, very important', 'Somewhat important', 'Not as important'],
}, },
{ { id: 'balance',
id: 'balance',
text: 'Do you prefer a job with flexible hours and time outside work?', text: 'Do you prefer a job with flexible hours and time outside work?',
options: ['Yes, very important', 'Somewhat important', 'Not as important'], options: ['Yes, very important', 'Somewhat important', 'Not as important'],
}, },
{ { id: 'recognition',
id: 'recognition',
text: 'How important is it to have a career that others admire?', text: 'How important is it to have a career that others admire?',
options: ['Very important', 'Somewhat important', 'Not as important'], options: ['Very important', 'Somewhat important', 'Not as important'],
}, },
]; ];
// Map legacy keys -> current ids
const KEY_MAP = {
interests: 'interests',
impact: 'meaning',
meaning: 'meaning',
salary: 'stability',
pay: 'stability',
compensation: 'stability',
stability: 'stability',
advancement: 'growth',
growth: 'growth',
work_life_balance: 'balance',
worklife: 'balance',
balance: 'balance',
prestige: 'recognition',
recognition: 'recognition',
};
// Map legacy numeric scales (15) to current option strings
const numToLabel = (n) => {
const v = Number(n);
if (Number.isNaN(v)) return null;
if (v >= 4) return 'Very important';
if (v === 3) return 'Somewhat important';
return 'Not as important'; // 12
};
const coerceToLabel = (val) => {
if (val == null) return null;
if (typeof val === 'number' || /^[0-9]+$/.test(String(val))) {
return numToLabel(val);
}
const s = String(val).trim();
// Normalize a few common textual variants
if (/^very/i.test(s)) return 'Very important';
if (/^some/i.test(s)) return 'Somewhat important';
if (/^not/i.test(s)) return 'Not as important';
if (/^yes/i.test(s)) return 'Yes, very important';
return s; // assume already one of the options
};
const normalizePriorities = (raw) => {
const out = {};
if (!raw || typeof raw !== 'object') return out;
for (const [k, v] of Object.entries(raw)) {
const id = KEY_MAP[k] || k;
const label = coerceToLabel(v);
if (label) out[id] = label;
}
return out;
};
useEffect(() => {
const cp = userProfile?.career_priorities;
if (!cp) return;
let parsed;
try {
parsed = typeof cp === 'string' ? JSON.parse(cp) : cp;
} catch {
parsed = null;
}
const normalized = normalizePriorities(parsed);
// Only keep keys we actually ask
const allowed = QUESTIONS.reduce((acc, q) => {
if (normalized[q.id]) acc[q.id] = normalized[q.id];
return acc;
}, {});
setResponses(allowed);
}, [userProfile]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSave = async () => { const handleSave = async () => {
const payload = { const payload = {
firstName: userProfile.firstname, firstName: userProfile.firstname,
@ -68,29 +126,23 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => {
} }
}; };
const allAnswered = questions.every(q => responses[q.id]); const allAnswered = QUESTIONS.every(q => responses[q.id]);
return ( return (
<div className="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50"> <div className="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg w-full max-w-2xl overflow-y-auto max-h-[90vh] z-60"> <div className="bg-white p-6 rounded-lg shadow-lg w-full max-w-2xl overflow-y-auto max-h-[90vh] z-60">
<h2 className="text-xl font-bold mb-4">Tell us what's important to you</h2> <h2 className="text-xl font-bold mb-4">Tell us what's important to you</h2>
{questions.map(q => ( {QUESTIONS.map(q => (
<div key={q.id} className="mb-4"> <div key={q.id} className="mb-4">
<label className="block mb-2 font-medium">{q.text}</label> <label className="block mb-2 font-medium">{q.text}</label>
<select <select
value={responses[q.id] || ''} value={responses[q.id] || ''}
onChange={(e) => onChange={(e) => setResponses({ ...responses, [q.id]: e.target.value })}
setResponses({ ...responses, [q.id]: e.target.value })
}
className="w-full border px-3 py-2 rounded" className="w-full border px-3 py-2 rounded"
> >
<option value="" disabled> <option value="" disabled>Select an answer</option>
Select an answer
</option>
{q.options.map((opt) => ( {q.options.map((opt) => (
<option key={opt} value={opt}> <option key={opt} value={opt}>{opt}</option>
{opt}
</option>
))} ))}
</select> </select>
</div> </div>
@ -99,9 +151,7 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => {
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!allAnswered} disabled={!allAnswered}
className={`px-4 py-2 rounded ${ className={`px-4 py-2 rounded ${allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'}`}
allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'
}`}
> >
Save Answers Save Answers
</button> </button>

View File

@ -52,7 +52,7 @@ ChartJS.register(
PointElement, PointElement,
Tooltip, Tooltip,
Legend, Legend,
zoomPlugin, // 👈 ←–––– only if you kept the zoom config zoomPlugin,
annotationPlugin annotationPlugin
); );
@ -486,6 +486,15 @@ const zoomConfig = {
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' } zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }
}; };
// Compute if any savings are negative to pick a sane baseline
const minSavings = projectionData.reduce((min, p) => {
const e = Number(p.emergencySavings ?? 0);
const r = Number(p.retirementSavings ?? 0);
const t = Number(p.totalSavings ?? 0);
return Math.min(min, e, r, t);
}, Infinity);
const hasNegSavings = Number.isFinite(minSavings) && minSavings < 0;
const xAndYScales = { const xAndYScales = {
x: { x: {
type: 'time', type: 'time',
@ -493,12 +502,14 @@ const xAndYScales = {
ticks: { maxRotation: 0, autoSkip: true } ticks: { maxRotation: 0, autoSkip: true }
}, },
y: { y: {
beginAtZero: true, beginAtZero: !hasNegSavings,
// give a little room below the smallest negative so the fill doesn't sit on the axis
min: hasNegSavings ? Math.floor(minSavings * 1.05) : undefined,
ticks: { ticks: {
callback: (val) => val.toLocaleString() // comma-format big numbers callback: (val) => val.toLocaleString()
} }
} }
}; };
/* /*
* ONE-TIME MISSING FIELDS GUARD * ONE-TIME MISSING FIELDS GUARD
@ -1261,7 +1272,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
const emergencyData = { const emergencyData = {
label: 'Emergency Savings', label: 'Emergency Savings',
data: projectionData.map((p) => p.emergencySavings), data: projectionData.map((p) => Number(p.emergencySavings ?? 0)),
borderColor: 'rgba(255, 159, 64, 1)', borderColor: 'rgba(255, 159, 64, 1)',
backgroundColor: 'rgba(255, 159, 64, 0.2)', backgroundColor: 'rgba(255, 159, 64, 0.2)',
tension: 0.4, tension: 0.4,
@ -1269,7 +1280,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
}; };
const retirementData = { const retirementData = {
label: 'Retirement Savings', label: 'Retirement Savings',
data: projectionData.map((p) => p.retirementSavings), data: projectionData.map((p) => Number(p.retirementSavings ?? 0)),
borderColor: 'rgba(75, 192, 192, 1)', borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)', backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4, tension: 0.4,
@ -1277,7 +1288,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
}; };
const totalSavingsData = { const totalSavingsData = {
label: 'Total Savings', label: 'Total Savings',
data: projectionData.map((p) => p.totalSavings), data: projectionData.map((p) => Number(p.totalSavings ?? 0)),
borderColor: 'rgba(54, 162, 235, 1)', borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: 'rgba(54, 162, 235, 0.2)', backgroundColor: 'rgba(54, 162, 235, 0.2)',
tension: 0.4, tension: 0.4,
@ -1285,7 +1296,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
}; };
const loanBalanceData = { const loanBalanceData = {
label: 'Loan Balance', label: 'Loan Balance',
data: projectionData.map((p) => p.loanBalance), data: projectionData.map((p) => Number(p.loanBalance ?? 0)),
borderColor: 'rgba(255, 99, 132, 1)', borderColor: 'rgba(255, 99, 132, 1)',
backgroundColor: 'rgba(255, 99, 132, 0.2)', backgroundColor: 'rgba(255, 99, 132, 0.2)',
tension: 0.4, tension: 0.4,
@ -1510,7 +1521,7 @@ const handleMilestonesCreated = useCallback(
{showMissingBanner && ( {showMissingBanner && (
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4"> <div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4">
<p className="text-sm text-gray-800"> <p className="text-sm text-gray-800">
To run your full projection, please add: To improve your projection, please add:
</p> </p>
{!!missingKeys.length && ( {!!missingKeys.length && (
<ul className="mt-2 ml-5 list-disc text-sm text-gray-800"> <ul className="mt-2 ml-5 list-disc text-sm text-gray-800">

View File

@ -14,6 +14,12 @@ const parseFloatOrNull = v => {
return Number.isFinite(n) ? n : null; return Number.isFinite(n) ? n : null;
}; };
const fromSqlDate = (v) => {
if (!v) return '';
// Accept "YYYY-MM-DD", "YYYY-MM-DD HH:MM:SS", or ISO; trim to date part
return String(v).slice(0, 10);
};
function normalisePayload(draft) { function normalisePayload(draft) {
const bools = [ const bools = [
'is_in_state','is_in_district','is_online', 'is_in_state','is_in_district','is_online',
@ -122,16 +128,70 @@ const onProgramInput = (e) => {
setProgSug([...new Set(sug)].slice(0, 10)); setProgSug([...new Set(sug)].slice(0, 10));
}; };
// Prefill school suggestions when form loads or school changes
useEffect(() => {
const v = (form.selected_school || '').toLowerCase().trim();
if (!v || !cipRows.length) {
setSchoolSug([]);
return;
}
const suggestions = cipRows
.filter(r => (r.INSTNM || '').toLowerCase().includes(v))
.map(r => r.INSTNM);
setSchoolSug([...new Set(suggestions)].slice(0, 10));
}, [form.selected_school, cipRows]);
// Prefill program suggestions when form loads or program/school changes
useEffect(() => {
const sch = (form.selected_school || '').toLowerCase().trim();
const q = (form.selected_program || '').toLowerCase().trim();
if (!sch || !q || !cipRows.length) {
setProgSug([]);
return;
}
const sug = cipRows
.filter(r =>
(r.INSTNM || '').toLowerCase() === sch &&
(r.CIPDESC || '').toLowerCase().includes(q)
)
.map(r => r.CIPDESC);
setProgSug([...new Set(sug)].slice(0, 10));
}, [form.selected_school, form.selected_program, cipRows]);
useEffect(() => { useEffect(() => {
if (id && id !== 'new') { if (id && id !== 'new') {
(async () => { (async () => {
const r = await authFetch(`/api/premium/college-profile?careerProfileId=${careerId}`); const r = await authFetch(`/api/premium/college-profile?careerProfileId=${careerId}`);
if (r.ok) setForm(await r.json()); if (r.ok) {
const raw = await r.json();
const normalized = {
...raw,
enrollment_date : fromSqlDate(raw.enrollment_date),
expected_graduation : fromSqlDate(raw.expected_graduation),
is_in_state : !!raw.is_in_state,
is_in_district : !!raw.is_in_district,
is_online : !!raw.is_online,
loan_deferral_until_graduation : !!raw.loan_deferral_until_graduation,
};
setForm(normalized);
if (normalized.tuition !== undefined && normalized.tuition !== null) {
setManualTuition(String(normalized.tuition));
}
}
})(); })();
} }
}, [careerId, id]); }, [careerId, id]);
// 2) keep manualTuition aligned if form.tuition is updated elsewhere
useEffect(() => {
if (form.tuition !== undefined && form.tuition !== null) {
if (manualTuition.trim() === '') {
setManualTuition(String(form.tuition));
}
}
}, [form.tuition]);
async function handleSave(){ async function handleSave(){
try{ try{
const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId }); const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId });
@ -245,6 +305,8 @@ const chosenTuition = (() => {
*/ */
useEffect(() => { useEffect(() => {
if (programLengthTouched) return; // user override if (programLengthTouched) return; // user override
// if a program_length already exists (e.g., from API), don't overwrite it
if (form.program_length !== '' && form.program_length != null) return; // user override
const chpy = parseFloat(form.credit_hours_per_year); const chpy = parseFloat(form.credit_hours_per_year);
if (!chpy || chpy <= 0) return; if (!chpy || chpy <= 0) return;
@ -266,7 +328,7 @@ const chpy = parseFloat(form.credit_hours_per_year);
if (creditsNeeded <= 0) return; if (creditsNeeded <= 0) return;
/* 2  years = credits / CHPY → one decimal place */ /* 2  years = credits / CHPY → one decimal place */
const years = Math.ceil((creditsNeeded / chpy) * 10) / 10; const years = Math.round((creditsNeeded / chpy) * 100) / 100;
if (years !== form.program_length) { if (years !== form.program_length) {
setForm(prev => ({ ...prev, program_length: years })); setForm(prev => ({ ...prev, program_length: years }));
@ -522,6 +584,7 @@ return (
<label className="block font-medium">Program Length (years)</label> <label className="block font-medium">Program Length (years)</label>
<input <input
type="number" type="number"
step="0.01"
name="program_length" name="program_length"
value={form.program_length} value={form.program_length}
onChange={handleFieldChange} onChange={handleFieldChange}

View File

@ -5,6 +5,7 @@ import { ONET_DEFINITIONS } from './definitions.js';
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js'; import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
import ChatCtx from '../contexts/ChatCtx.js'; import ChatCtx from '../contexts/ChatCtx.js';
import api from '../auth/apiClient.js'; import api from '../auth/apiClient.js';
import { loadDraft, saveDraft } from '../utils/onboardingDraftApi.js';
// Normalize DB/GPT KSA payloads into IM/LV rows for combineIMandLV // Normalize DB/GPT KSA payloads into IM/LV rows for combineIMandLV
function normalizeKsaPayloadForCombine(payload, socCode) { function normalizeKsaPayloadForCombine(payload, socCode) {
@ -71,6 +72,11 @@ function ensureHttp(urlString) {
return `https://${urlString}`; return `https://${urlString}`;
} }
function cleanCipDesc(s) {
if (!s) return 'N/A';
return String(s).trim().replace(/\.\s*$/, ''); // strip one trailing period + any spaces
}
// Convert numeric importance (15) to star or emoji compact representation // Convert numeric importance (15) to star or emoji compact representation
function renderImportance(val) { function renderImportance(val) {
const max = 5; const max = 5;
@ -168,23 +174,54 @@ function normalizeCipList(arr) {
} }
// Fixed handleSelectSchool (removed extra brace) // Fixed handleSelectSchool (removed extra brace)
const handleSelectSchool = (school) => { // Replace your existing handleSelectSchool with this:
const handleSelectSchool = async (school) => {
const proceed = window.confirm( const proceed = window.confirm(
'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?' 'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?'
); );
if (!proceed) return; if (!proceed) return;
// normalize selectedCareer and carry it forward
// normalize the currently selected career for handoff (optional)
const sel = selectedCareer const sel = selectedCareer
? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode } ? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode }
: null; : null;
// 1) normalize college fields
const selected_school = school?.INSTNM || '';
const selected_program = (school?.CIPDESC || '').replace(/\.\s*$/, '');
const program_type = school?.CREDDESC || '';
// 2) merge into the cookie-backed draft (dont clobber existing sections)
let draft = null;
try { draft = await loadDraft(); } catch (_) {}
const existing = draft?.data || {};
await saveDraft({
id: draft?.id || null,
step: draft?.step ?? 0,
careerData: existing.careerData || {},
financialData: existing.financialData || {},
collegeData: {
...(existing.collegeData || {}),
selected_school,
selected_program,
program_type,
},
});
// 3) navigate (state is optional now that draft persists)
navigate('/career-roadmap', { navigate('/career-roadmap', {
state: { state: {
premiumOnboardingState: { premiumOnboardingState: {
selectedCareer: sel, // SOC-bearing career object selectedCareer: sel,
selectedSchool: school // school card just chosen selectedSchool: {
} INSTNM: school.INSTNM,
} CIPDESC: selected_program,
CREDDESC: program_type,
UNITID: school.UNITID ?? null,
},
},
},
}); });
}; };
@ -738,7 +775,7 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
school['INSTNM'] || 'Unnamed School' school['INSTNM'] || 'Unnamed School'
)} )}
</strong> </strong>
<p> Program: {school['CIPDESC'] || 'N/A'}</p> <p>Program: {cleanCipDesc(school['CIPDESC'])}</p>
<p>Degree Type: {school['CREDDESC'] || 'N/A'}</p> <p>Degree Type: {school['CREDDESC'] || 'N/A'}</p>
<p>In-State Tuition: ${school['In_state cost'] || 'N/A'}</p> <p>In-State Tuition: ${school['In_state cost'] || 'N/A'}</p>
<p>Out-of-State Tuition: ${school['Out_state cost'] || 'N/A'}</p> <p>Out-of-State Tuition: ${school['Out_state cost'] || 'N/A'}</p>

View File

@ -173,30 +173,60 @@ export default function MilestoneEditModal({
}); });
async function saveNew(){ async function saveNew(){
if(isSavingNew) return; if (isSavingNew) return;
if(!newMilestone.title.trim()||!newMilestone.date.trim()){ if (!newMilestone.title.trim() || !newMilestone.date.trim()) {
alert('Need title & date'); return; alert('Need title & date'); return;
} }
setIsSavingNew(true); setIsSavingNew(true);
const hdr = { title:newMilestone.title, description:newMilestone.description, const toDate = (v) => (v ? String(v).slice(0,10) : null);
date:toSqlDate(newMilestone.date), career_profile_id:careerProfileId, try {
progress:newMilestone.progress, status:newMilestone.progress>=100?'completed':'planned', const hdr = {
is_universal:newMilestone.isUniversal }; title: newMilestone.title,
const res = await authFetch('/api/premium/milestone',{method:'POST', description: newMilestone.description,
headers:{'Content-Type':'application/json'},body:JSON.stringify(hdr)}); date: toDate(newMilestone.date),
const created = Array.isArray(await res.json())? (await res.json())[0]:await res.json(); career_profile_id: careerProfileId,
for(const imp of newMilestone.impacts){ progress: newMilestone.progress,
const body = { status: newMilestone.progress >= 100 ? 'completed' : 'planned',
milestone_id:created.id, impact_type:imp.impact_type, is_universal: newMilestone.isUniversal,
direction:imp.impact_type==='salary'?'add':imp.direction,
amount:parseFloat(imp.amount)||0, start_date:imp.start_date||null, end_date:imp.end_date||null
}; };
await authFetch('/api/premium/milestone-impacts',{ const res = await authFetch('/api/premium/milestone', {
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); method: 'POST',
headers: { 'Content-Type':'application/json' },
body: JSON.stringify(hdr)
});
if (!res.ok) throw new Error(await res.text());
const createdJson = await res.json();
const created = Array.isArray(createdJson) ? createdJson[0] : createdJson;
if (!created || !created.id) throw new Error('Milestone create failed — no id returned');
// Save any non-empty impact rows
for (const imp of newMilestone.impacts) {
if (!imp) continue;
const hasAnyField = (imp.amount || imp.start_date || imp.end_date || imp.impact_type);
if (!hasAnyField) continue;
const ibody = {
milestone_id: created.id,
impact_type : imp.impact_type,
direction : imp.impact_type === 'salary' ? 'add' : imp.direction,
amount : parseFloat(imp.amount) || 0,
start_date : toDate(imp.start_date),
end_date : toDate(imp.end_date),
};
const ir = await authFetch('/api/premium/milestone-impacts', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(ibody)
});
if (!ir.ok) throw new Error(await ir.text());
} }
await fetchMilestones(); await fetchMilestones();
setAddingNew(false); setAddingNew(false);
onClose(true); onClose(true);
} catch (err) {
console.error('saveNew:', err);
alert(err.message || 'Failed to save milestone');
} finally {
setIsSavingNew(false);
}
} }
/* ══════════════════════════════════════════════════════════════ */ /* ══════════════════════════════════════════════════════════════ */
@ -204,7 +234,7 @@ export default function MilestoneEditModal({
/* ══════════════════════════════════════════════════════════════ */ /* ══════════════════════════════════════════════════════════════ */
return ( return (
<div className="fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-black/40"> <div className="fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-black/40">
<div className="bg-white w-full max-w-3xl mx-4 my-10 rounded-md shadow-lg ring-1 ring-gray-300"> <div className="bg-white w-full max-w-3xl mx-4 my-10 rounded-md shadow-lg ring-1 ring-gray-300 overflow-hidden">
{/* header */} {/* header */}
<div className="flex items-center justify-between px-6 py-4 border-b"> <div className="flex items-center justify-between px-6 py-4 border-b">
<div> <div>
@ -217,7 +247,7 @@ export default function MilestoneEditModal({
</div> </div>
{/* body */} {/* body */}
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto"> <div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto overflow-x-hidden">
{/* EXISTING */} {/* EXISTING */}
{milestones.map(m=>{ {milestones.map(m=>{
const open = editingId===m.id; const open = editingId===m.id;
@ -272,7 +302,7 @@ export default function MilestoneEditModal({
{/* impacts */} {/* impacts */}
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2 min-w-0">
<h4 className="font-medium text-sm">Financial impacts</h4> <h4 className="font-medium text-sm">Financial impacts</h4>
<Button size="xs" onClick={()=>addImpactRow(m.id)}>+ Add impact</Button> <Button size="xs" onClick={()=>addImpactRow(m.id)}>+ Add impact</Button>
</div> </div>
@ -282,7 +312,7 @@ export default function MilestoneEditModal({
<div className="space-y-3"> <div className="space-y-3">
{d.impacts?.map((imp,idx)=>( {d.impacts?.map((imp,idx)=>(
<div key={idx} className="grid gap-2 md:grid-cols-[150px_120px_1fr_auto] items-end"> <div key={idx} className="grid gap-2 items-end min-w-0 md:grid-cols-[150px_110px_100px_minmax(240px,1fr)_40px]">
{/* type */} {/* type */}
<div> <div>
<label className="label-xs">Type</label> <label className="label-xs">Type</label>
@ -296,20 +326,20 @@ export default function MilestoneEditModal({
</select> </select>
</div> </div>
{/* direction hide for salary */} {/* direction hide for salary */}
{imp.impact_type!=='salary' && ( {imp.impact_type !== 'salary' ? (
<div> <div>
<label className="label-xs">Direction</label> <label className="label-xs">Direction</label>
<select <select className="input" value={imp.direction} onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
className="input"
value={imp.direction}
onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
<option value="add">Add</option> <option value="add">Add</option>
<option value="subtract">Subtract</option> <option value="subtract">Subtract</option>
</select> </select>
</div> </div>
) : (
// keep the grid column to prevent the next columns from collapsing
<div className="hidden md:block" />
)} )}
{/* amount */} {/* amount */}
<div> <div className="md:w-[100px]">
<label className="label-xs">Amount</label> <label className="label-xs">Amount</label>
<input <input
type="number" type="number"
@ -319,12 +349,12 @@ export default function MilestoneEditModal({
/> />
</div> </div>
{/* dates */} {/* dates */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2 min-w-0">
<div> <div>
<label className="label-xs">Start</label> <label className="label-xs">Start</label>
<input <input
type="date" type="date"
className="input" className="input w-full min-w-[14ch] px-3"
value={imp.start_date} value={imp.start_date}
onChange={e=>updateImpact(m.id,idx,'start_date',e.target.value)} onChange={e=>updateImpact(m.id,idx,'start_date',e.target.value)}
/> />
@ -334,7 +364,7 @@ export default function MilestoneEditModal({
<label className="label-xs">End</label> <label className="label-xs">End</label>
<input <input
type="date" type="date"
className="input" className="input w-full min-w-[14ch] px-3"
value={imp.end_date} value={imp.end_date}
onChange={e=>updateImpact(m.id,idx,'end_date',e.target.value)} onChange={e=>updateImpact(m.id,idx,'end_date',e.target.value)}
/> />
@ -345,7 +375,7 @@ export default function MilestoneEditModal({
<Button <Button
size="icon-xs" size="icon-xs"
variant="ghost" variant="ghost"
className="text-red-600" className="text-red-600 w-10 h-9 flex items-center justify-center"
onClick={()=>removeImpactRow(m.id,idx)} onClick={()=>removeImpactRow(m.id,idx)}
> >
@ -401,7 +431,7 @@ export default function MilestoneEditModal({
<label className="label-xs">Date</label> <label className="label-xs">Date</label>
<input <input
type="date" type="date"
className="input" className="input w-full min-w-[12ch] px-3"
value={newMilestone.date} value={newMilestone.date}
onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))} onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))}
/> />
@ -420,69 +450,93 @@ export default function MilestoneEditModal({
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="font-medium text-sm">Financial impacts</h4> <h4 className="font-medium text-sm">Financial impacts</h4>
<Button size="xs" onClick={addBlankImpactToNew}>+ Add impact</Button> <Button size="xs" className="shrink-0" onClick={addBlankImpactToNew}>+ Add impact</Button>
</div> </div>
<div className="space-y-3 mt-2"> <div className="space-y-3 mt-2">
{newMilestone.impacts.map((imp,idx)=>( {newMilestone.impacts.map((imp, idx) => (
<div key={idx} className="grid md:grid-cols-[150px_120px_1fr_auto] gap-2 items-end"> <div
key={idx}
className="grid gap-2 items-end min-w-0 grid-cols-[140px_110px_100px_minmax(220px,1fr)_44px]"
>
{/* Type */}
<div> <div>
<label className="label-xs">Type</label> <label className="label-xs">Type</label>
<select <select
className="input" className="input"
value={imp.impact_type} value={imp.impact_type}
onChange={e=>updateNewImpact(idx,'impact_type',e.target.value)}> onChange={(e) => updateNewImpact(idx, 'impact_type', e.target.value)}
>
<option value="salary">Salary (annual)</option> <option value="salary">Salary (annual)</option>
<option value="ONE_TIME">Onetime</option> <option value="ONE_TIME">One-time</option>
<option value="MONTHLY">Monthly</option> <option value="MONTHLY">Monthly</option>
</select> </select>
</div> </div>
{imp.impact_type!=='salary' && (
{/* Direction (spacer when salary) */}
{imp.impact_type !== 'salary' ? (
<div> <div>
<label className="label-xs">Direction</label> <label className="label-xs">Direction</label>
<select <select
className="input" className="input"
value={imp.direction} value={imp.direction}
onChange={e=>updateNewImpact(idx,'direction',e.target.value)}> onChange={(e) => updateNewImpact(idx, 'direction', e.target.value)}
>
<option value="add">Add</option> <option value="add">Add</option>
<option value="subtract">Subtract</option> <option value="subtract">Subtract</option>
</select> </select>
</div> </div>
) : (
<div className="hidden md:block" />
)} )}
<div>
{/* Amount (fixed width) */}
<div className="md:w-[100px]">
<label className="label-xs">Amount</label> <label className="label-xs">Amount</label>
<input <input
type="number" type="number"
className="input" className="input"
value={imp.amount} value={imp.amount}
onChange={e=>updateNewImpact(idx,'amount',e.target.value)} onChange={(e) => updateNewImpact(idx, 'amount', e.target.value)}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-2">
{/* Dates (flex) */}
<div className="grid grid-cols-2 gap-2 min-w-0">
<div>
<label className="label-xs">Start</label>
<input <input
type="date" type="date"
className="input" className="input w-full min-w-[14ch] px-3"
value={imp.start_date} value={imp.start_date}
onChange={e=>updateNewImpact(idx,'start_date',e.target.value)} onChange={(e) => updateNewImpact(idx, 'start_date', e.target.value)}
/> />
{imp.impact_type==='MONTHLY' && ( </div>
{imp.impact_type === 'MONTHLY' && (
<div>
<label className="label-xs">End</label>
<input <input
type="date" type="date"
className="input" className="input w-full min-w-[14ch] px-3"
value={imp.end_date} value={imp.end_date}
onChange={e=>updateNewImpact(idx,'end_date',e.target.value)} onChange={(e) => updateNewImpact(idx, 'end_date', e.target.value)}
/> />
</div>
)} )}
</div> </div>
{/* Remove */}
<Button <Button
size="icon-xs" size="icon-xs"
variant="ghost" variant="ghost"
className="text-red-600" className="text-red-600 w-11 h-9 flex items-center justify-center"
onClick={()=>removeNewImpact(idx)} onClick={() => removeNewImpact(idx)}
aria-label="Remove impact"
> >
</Button> </Button>
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* save row */} {/* save row */}

View File

@ -79,9 +79,7 @@ function handleSubmit() {
} }
} }
const nextLabel = skipFin const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Finish →');
? inCollege ? 'College →' : 'Finish →'
: inCollege ? 'College →' : 'Financial →';
return ( return (
<div className="max-w-md mx-auto p-6 space-y-4"> <div className="max-w-md mx-auto p-6 space-y-4">
@ -185,9 +183,9 @@ function handleSubmit() {
<button <button
className="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded mr-2" className="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded mr-2"
onClick={() => { onClick={() => {
/* open your ScenarioEditModal or a mini financial modal */ // user explicitly wants to enter financials now
setData(prev => ({ ...prev, skipFinancialStep: false }));
setShowFinPrompt(false); setShowFinPrompt(false);
/* maybe set a flag so CareerRoadmap opens the modal */
}} }}
> >
Add Financial Details Add Financial Details

View File

@ -20,18 +20,24 @@ const [selectedUnitId, setSelectedUnitId] = useState(null);
const [expectedGraduation, setExpectedGraduation] = useState(data.expected_graduation || ''); const [expectedGraduation, setExpectedGraduation] = useState(data.expected_graduation || '');
const [showAidWizard, setShowAidWizard] = useState(false); const [showAidWizard, setShowAidWizard] = useState(false);
const location = useLocation(); const location = useLocation();
const navSelectedSchoolRaw = location.state?.selectedSchool; const navSelectedSchoolObj =
const navSelectedSchool = toSchoolName(navSelectedSchoolRaw); location.state?.selectedSchool ??
location.state?.premiumOnboardingState?.selectedSchool;
function dehydrate(schObj) { const [selectedSchool, setSelectedSchool] = useState(() => {
if (!schObj || typeof schObj !== 'object') return null; if (navSelectedSchoolObj && typeof navSelectedSchoolObj === 'object') {
const { INSTNM, CIPDESC, CREDDESC, ...rest } = schObj; return { INSTNM: navSelectedSchoolObj.INSTNM,
return { INSTNM, CIPDESC, CREDDESC, ...rest }; CIPDESC: navSelectedSchoolObj.CIPDESC || '',
} CREDDESC: navSelectedSchoolObj.CREDDESC || '' };
}
if (data.selected_school) {
return { INSTNM: data.selected_school,
CIPDESC: data.selected_program || '',
CREDDESC: data.program_type || '' };
}
return null;
});
const [selectedSchool, setSelectedSchool] = useState(() =>
dehydrate(navSelectedSchool) || (data.selected_school ? { INSTNM: data.selected_school } : null)
);
function toSchoolName(objOrStr) { function toSchoolName(objOrStr) {
if (!objOrStr) return ''; if (!objOrStr) return '';

View File

@ -15,8 +15,8 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
retirement_contribution = 0, retirement_contribution = 0,
emergency_fund = 0, emergency_fund = 0,
emergency_contribution = 0, emergency_contribution = 0,
extra_cash_emergency_pct = "", extra_cash_emergency_pct = 50,
extra_cash_retirement_pct = "", extra_cash_retirement_pct = 50,
} = data; } = data;
const [showExpensesWizard, setShowExpensesWizard] = useState(false); const [showExpensesWizard, setShowExpensesWizard] = useState(false);

View File

@ -86,9 +86,16 @@ export default function OnboardingContainer() {
} }
// D) pick up any navigation state (e.g., selectedSchool) // D) pick up any navigation state (e.g., selectedSchool)
const navSchool = location.state?.selectedSchool; const navSchool =
location.state?.selectedSchool ??
location.state?.premiumOnboardingState?.selectedSchool;
if (navSchool) { if (navSchool) {
setCollegeData(cd => ({ ...cd, selected_school: navSchool.INSTNM || navSchool })); setCollegeData(cd => ({
...cd,
selected_school : typeof navSchool === 'string' ? navSchool : (navSchool.INSTNM || ''),
selected_program: typeof navSchool === 'object' ? (navSchool.CIPDESC || cd.selected_program || '') : cd.selected_program,
program_type : typeof navSchool === 'object' ? (navSchool.CREDDESC || cd.program_type || '') : cd.program_type,
}));
} }
setLoaded(true); setLoaded(true);

View File

@ -1,14 +1,9 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect } from 'react';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
import InfoTooltip from "./ui/infoTooltip.js"; import InfoTooltip from "./ui/infoTooltip.js";
import CareerSearch from './CareerSearch.js';
// Data paths import api from '../auth/apiClient.js'
const CIP_URL = '/api/data/cip-institution-map';
const IPEDS_URL = '/api/data/ic2023';
const CAREER_CLUSTERS_URL = '/api/data/career_clusters.json';
export default function ScenarioEditModal({ export default function ScenarioEditModal({
show, show,
@ -17,11 +12,6 @@ export default function ScenarioEditModal({
collegeProfile, collegeProfile,
financialProfile financialProfile
}) { }) {
/*********************************************************
* 1) CIP / IPEDS data states
*********************************************************/
const [schoolData, setSchoolData] = useState([]);
const [icTuitionData, setIcTuitionData] = useState([]);
/********************************************************* /*********************************************************
* 2) Suggestions & program types * 2) Suggestions & program types
@ -29,6 +19,7 @@ export default function ScenarioEditModal({
const [schoolSuggestions, setSchoolSuggestions] = useState([]); const [schoolSuggestions, setSchoolSuggestions] = useState([]);
const [programSuggestions, setProgramSuggestions] = useState([]); const [programSuggestions, setProgramSuggestions] = useState([]);
const [availableProgramTypes, setAvailableProgramTypes] = useState([]); const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
const [selectedUnitId, setSelectedUnitId] = useState(null);
/********************************************************* /*********************************************************
* 3) Manual vs auto for tuition & program length * 3) Manual vs auto for tuition & program length
@ -38,14 +29,6 @@ export default function ScenarioEditModal({
const [manualProgLength, setManualProgLength] = useState(''); const [manualProgLength, setManualProgLength] = useState('');
const [autoProgLength, setAutoProgLength] = useState('0.00'); const [autoProgLength, setAutoProgLength] = useState('0.00');
/*********************************************************
* 4) Career auto-suggest
*********************************************************/
const [allCareers, setAllCareers] = useState([]);
const [careerSearchInput, setCareerSearchInput] = useState('');
const [careerMatches, setCareerMatches] = useState([]);
const careerDropdownRef = useRef(null);
/********************************************************* /*********************************************************
* 5) Combined formData => scenario + college * 5) Combined formData => scenario + college
*********************************************************/ *********************************************************/
@ -65,81 +48,52 @@ export default function ScenarioEditModal({
*********************************************************/ *********************************************************/
useEffect(() => { useEffect(() => {
if (!show) return; if (!show) return;
setShowCollegeForm( const hasCollegeData =
['currently_enrolled', 'prospective_student'] !!formData.selected_school ||
.includes(formData.college_enrollment_status) !!formData.selected_program ||
); !!formData.program_type ||
!!formData.expected_graduation ||
!!formData.enrollment_date ||
Number(formData.tuition) > 0 ||
Number(formData.annual_financial_aid) > 0 ||
Number(formData.existing_college_debt) > 0;
const enrolledOrProspective = ['currently_enrolled','prospective_student']
.includes((formData.college_enrollment_status || '').toLowerCase());
setShowCollegeForm(enrolledOrProspective || hasCollegeData);
}, [show]); }, [show]);
useEffect(() => {
(async () => {
if (!selectedUnitId || !formData.program_type || !formData.credit_hours_per_year) return;
try {
const { data } = await api.get('/api/tuition/estimate', {
params: {
unitId: String(selectedUnitId),
programType: formData.program_type,
inState: formData.is_in_state ? 1 : 0,
inDistrict: formData.is_in_district ? 1 : 0,
creditHoursPerYear: Number(formData.credit_hours_per_year) || 0
}
});
setAutoTuition(Number.isFinite(data?.estimate) ? data.estimate : 0);
} catch {
setAutoTuition(0);
}
})();
}, [
selectedUnitId,
formData.program_type,
formData.credit_hours_per_year,
formData.is_in_state,
formData.is_in_district
]);
/********************************************************* /*********************************************************
* 6) On show => load CIP, IPEDS, CAREERS * 6) On show => load CIP, IPEDS, CAREERS
*********************************************************/ *********************************************************/
useEffect(() => {
if (!show) return;
const loadCIP = async () => {
try {
const res = await fetch(CIP_URL);
const text = await res.text();
const lines = text.split('\n');
const parsed = lines
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
setSchoolData(parsed);
} catch (err) {
console.error('Failed loading CIP data:', err);
}
};
const loadIPEDS = async () => {
try {
const res = await fetch(IPEDS_URL);
const text = await res.text();
const rows = text.split('\n').map((line) => line.split(','));
const headers = rows[0];
const dataRows = rows.slice(1).map((row) =>
Object.fromEntries(row.map((val, idx) => [headers[idx], val]))
);
setIcTuitionData(dataRows);
} catch (err) {
console.error('Failed loading IPEDS data:', err);
}
};
const loadCareers = async () => {
try {
const resp = await fetch(CAREER_CLUSTERS_URL);
if (!resp.ok) {
throw new Error(`Failed career_clusters fetch: ${resp.status}`);
}
const data = await resp.json();
const titlesSet = new Set();
for (const cluster of Object.keys(data)) {
for (const sub of Object.keys(data[cluster])) {
const arr = data[cluster][sub];
if (Array.isArray(arr)) {
arr.forEach((cObj) => {
if (cObj?.title) titlesSet.add(cObj.title);
});
}
}
}
setAllCareers([...titlesSet]);
} catch (err) {
console.error('Failed loading career_clusters:', err);
}
};
loadCIP();
loadIPEDS();
loadCareers();
}, [show]);
/********************************************************* /*********************************************************
* 7) Whenever the **modal is shown** *or* **scenario.id changes** * 7) Whenever the **modal is shown** *or* **scenario.id changes**
@ -153,6 +107,12 @@ export default function ScenarioEditModal({
const safe = v => const safe = v =>
v === null || v === undefined ? '' : v; v === null || v === undefined ? '' : v;
const hasCollegeData =
!!c.selected_school || !!c.selected_program || !!c.program_type ||
!!c.expected_graduation || !!c.enrollment_date ||
(Number(c.tuition) || 0) > 0 ||
(Number(c.annual_financial_aid) || 0) > 0 ||
(Number(c.existing_college_debt) || 0) > 0;
setFormData({ setFormData({
// scenario portion // scenario portion
scenario_title : safe(s.scenario_title), scenario_title : safe(s.scenario_title),
@ -182,7 +142,10 @@ export default function ScenarioEditModal({
is_in_state: safe(!!c.is_in_state), is_in_state: safe(!!c.is_in_state),
is_in_district: safe(!!c.is_in_district), is_in_district: safe(!!c.is_in_district),
is_online: safe(!!c.is_online), is_online: safe(!!c.is_online),
college_enrollment_status: safe(c.college_enrollment_status || 'not_enrolled'), college_enrollment_status: safe(
c.college_enrollment_status
|| (hasCollegeData ? 'prospective_student' : 'not_enrolled')
),
annual_financial_aid : safe(c.annual_financial_aid), annual_financial_aid : safe(c.annual_financial_aid),
existing_college_debt : safe(c.existing_college_debt), existing_college_debt : safe(c.existing_college_debt),
@ -219,67 +182,8 @@ export default function ScenarioEditModal({
setManualProgLength(''); setManualProgLength('');
} }
setCareerSearchInput(s.career_name || '');
}, [show, scenario?.id, collegeProfile]); }, [show, scenario?.id, collegeProfile]);
/*********************************************************
* 8) Auto-calc placeholders (stubbed out)
*********************************************************/
useEffect(() => {
if (!show) return;
// IPEDS-based logic or other auto-calculation
}, [
show,
formData.selected_school,
formData.program_type,
formData.credit_hours_per_year,
formData.is_in_district,
formData.is_in_state,
schoolData,
icTuitionData
]);
useEffect(() => {
if (!show) return;
// Possibly recalc program length
}, [
show,
formData.program_type,
formData.hours_completed,
formData.credit_hours_per_year,
formData.credit_hours_required
]);
/*********************************************************
* 9) Career auto-suggest
*********************************************************/
useEffect(() => {
if (!show) return;
// 1⃣ trim once, reuse everywhere
const typed = careerSearchInput.trim();
// Nothing typed → clear list
if (!typed) {
setCareerMatches([]);
return;
}
/* 2⃣ Exact match (case-insensitive) → suppress dropdown */
if (allCareers.some(t => t.toLowerCase() === typed.toLowerCase())) {
setCareerMatches([]);
return;
}
// 3⃣ Otherwise show up to 15 partial matches
const lower = typed.toLowerCase();
const partials = allCareers
.filter(title => title.toLowerCase().includes(lower))
.slice(0, 15);
setCareerMatches(partials);
}, [show, careerSearchInput, allCareers]);
/********************************************************* /*********************************************************
* 9.5) Program Type from CIP * 9.5) Program Type from CIP
@ -290,18 +194,23 @@ useEffect(() => {
setAvailableProgramTypes([]); setAvailableProgramTypes([]);
return; return;
} }
const filtered = schoolData.filter( (async () => {
(row) => try {
row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() && const { data } = await api.get('/api/programs/types', {
row.CIPDESC === formData.selected_program params: {
); school: formData.selected_school,
const possibleTypes = [...new Set(filtered.map((r) => r.CREDDESC))]; program: formData.selected_program
setAvailableProgramTypes(possibleTypes); }
});
setAvailableProgramTypes(Array.isArray(data?.types) ? data.types : []);
} catch {
setAvailableProgramTypes([]);
}
})();
}, [ }, [
show, show,
formData.selected_school, formData.selected_school,
formData.selected_program, formData.selected_program,
schoolData
]); ]);
/********************************************************* /*********************************************************
@ -314,67 +223,72 @@ useEffect(() => {
setFormData((prev) => ({ ...prev, [name]: val })); setFormData((prev) => ({ ...prev, [name]: val }));
} }
function handleCareerInputChange(e) {
const val = e.target.value;
setCareerSearchInput(val);
if (allCareers.includes(val)) {
setFormData((prev) => ({ ...prev, career_name: val }));
}
}
function handleSelectCareer(title) {
setCareerSearchInput(title);
setFormData((prev) => ({ ...prev, career_name: title }));
setCareerMatches([]);
}
function handleSchoolChange(e) { function handleSchoolChange(e) {
const val = e.target.value; const val = e.target.value;
setFormData((prev) => ({ setFormData(prev => ({
...prev, ...prev,
selected_school: val, selected_school: val,
selected_program: '', selected_program: '',
program_type: '', program_type: '',
credit_hours_required: '' credit_hours_required: ''
})); }));
if (!val) { if (!val.trim()) {
setSchoolSuggestions([]); setSchoolSuggestions([]);
setProgramSuggestions([]); setProgramSuggestions([]);
setAvailableProgramTypes([]); setAvailableProgramTypes([]);
setSelectedUnitId(null);
return; return;
} }
const filtered = schoolData.filter((s) => (async () => {
s.INSTNM.toLowerCase().includes(val.toLowerCase()) try {
); const { data } = await api.get('/api/schools/suggest', {
const unique = [...new Set(filtered.map((s) => s.INSTNM))]; params: { query: val, limit: 10 }
setSchoolSuggestions(unique.slice(0, 10)); });
const opts = Array.isArray(data) ? data : [];
setSchoolSuggestions(opts); // keep full objects: { name, unitId }
// if the user typed an exact school name, commit now (sets UNITID)
const exact = opts.find(o => (o.name || '').toLowerCase() === val.toLowerCase());
if (exact) handleSchoolSelect(exact);
} catch {
setSchoolSuggestions([]);
}
})();
} }
function handleSchoolSelect(sch) { function handleSchoolSelect(sch) {
setFormData((prev) => ({ const name = sch?.name || sch || '';
const uid = sch?.unitId ?? null;
setSelectedUnitId(uid);
setFormData(prev => ({
...prev, ...prev,
selected_school: sch, selected_school: name,
selected_program: '', selected_program: '',
program_type: '', program_type: '',
credit_hours_required: '' credit_hours_required: ''
})); }));
setSchoolSuggestions([]); setSchoolSuggestions([]);
} setProgramSuggestions([]);
setAvailableProgramTypes([]);
}
function handleProgramChange(e) { function handleProgramChange(e) {
const val = e.target.value; const val = e.target.value;
setFormData((prev) => ({ ...prev, selected_program: val })); setFormData((prev) => ({ ...prev, selected_program: val }));
if (!val) { if (!val || !formData.selected_school) {
setProgramSuggestions([]); setProgramSuggestions([]);
return; return;
} }
const filtered = schoolData.filter( (async () => {
(row) => try {
row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() && const { data } = await api.get('/api/programs/suggest', {
row.CIPDESC.toLowerCase().includes(val.toLowerCase()) params: { school: formData.selected_school, query: val, limit: 10 }
); });
const unique = [...new Set(filtered.map((r) => r.CIPDESC))]; const list = Array.isArray(data) ? data : []; // [{ program }]
setProgramSuggestions(unique.slice(0, 10)); setProgramSuggestions(list.map(p => p.program));
} catch {
setProgramSuggestions([]);
}
})();
} }
function handleProgramSelect(prog) { function handleProgramSelect(prog) {
@ -422,10 +336,9 @@ useEffect(() => {
scenarioRow.planned_monthly_debt_payments ?? scenarioRow.planned_monthly_debt_payments ??
financialData.monthly_debt_payments ?? financialData.monthly_debt_payments ??
0, 0,
partTimeIncome: additionalIncome:
scenarioRow.planned_additional_income ?? scenarioRow.planned_additional_income ??
financialData.additional_income ?? financialData.additional_income ?? 0,
0,
emergencySavings: financialData.emergency_fund ?? 0, emergencySavings: financialData.emergency_fund ?? 0,
retirementSavings: financialData.retirement_savings ?? 0, retirementSavings: financialData.retirement_savings ?? 0,
@ -618,38 +531,15 @@ if (formData.retirement_start_date) {
/> />
</div> </div>
{/* Career Search */} {/* Career Search (shared component) */}
<div className="mb-4 relative"> <div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Career Search</label>
Career Search <CareerSearch onCareerSelected={(found) => {
</label> if (!found) return;
<input setFormData(prev => ({ ...prev, career_name: found.title || prev.career_name || '' }));
type="text" }}/>
value={careerSearchInput}
onChange={handleCareerInputChange}
className="border border-gray-300 rounded p-2 w-full focus:outline-none focus:border-blue-500"
/>
{careerMatches.length > 0 && (
<ul
ref={careerDropdownRef}
className="
absolute top-full left-0 w-full border border-gray-200 bg-white z-10
max-h-48 overflow-auto mt-1
"
>
{careerMatches.map((c, idx) => (
<li
key={idx}
className="p-2 cursor-pointer hover:bg-gray-100"
onClick={() => handleSelectCareer(c)}
>
{c}
</li>
))}
</ul>
)}
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
<em>Current Career:</em> {formData.career_name || '(none)'} <em>Selected Career:</em> {formData.career_name || '(none)'}
</p> </p>
</div> </div>
@ -938,12 +828,9 @@ if (formData.retirement_start_date) {
" "
> >
{schoolSuggestions.map((sch, i) => ( {schoolSuggestions.map((sch, i) => (
<li <li key={i} className="p-2 cursor-pointer hover:bg-gray-100"
key={i} onClick={() => handleSchoolSelect(sch)}>
className="p-2 cursor-pointer hover:bg-gray-100" {sch.name}
onClick={() => handleSchoolSelect(sch)}
>
{sch}
</li> </li>
))} ))}
</ul> </ul>
@ -1216,11 +1103,6 @@ if (formData.retirement_start_date) {
<Button variant="primary" onClick={handleSave}> <Button variant="primary" onClick={handleSave}>
Save Save
</Button> </Button>
{!formData.retirement_start_date && (
<p className="mt-1 text-xs text-red-500">
Pick a Planned Retirement Date to run the simulation.
</p>
)}
</div> </div>
{/* Show a preview if we have simulation data */} {/* Show a preview if we have simulation data */}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import SituationCard from './ui/SituationCard.js'; import SituationCard from './ui/SituationCard.js';
@ -54,11 +54,16 @@ function SignUp() {
const [zipcode, setZipcode] = useState(''); const [zipcode, setZipcode] = useState('');
const [state, setState] = useState(''); const [state, setState] = useState('');
const [area, setArea] = useState(''); const [area, setArea] = useState('');
const [areas, setAreas] = useState([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loadingAreas, setLoadingAreas] = useState(false); const [loadingAreas, setLoadingAreas] = useState(false);
const [phone, setPhone] = useState('+1'); const [phone, setPhone] = useState('+1');
const [optIn, setOptIn] = useState(false); const [optIn, setOptIn] = useState(false);
const [areas, setAreas] = useState([]);
const [areasErr, setAreasErr] = useState('');
const areasCacheRef = useRef(new Map()); // cache: stateCode -> areas[]
const debounceRef = useRef(null); // debounce timer
const inflightRef = useRef(null); // AbortController for in-flight
const [showCareerSituations, setShowCareerSituations] = useState(false); const [showCareerSituations, setShowCareerSituations] = useState(false);
const [selectedSituation, setSelectedSituation] = useState(null); const [selectedSituation, setSelectedSituation] = useState(null);
@ -235,6 +240,60 @@ const handleSituationConfirm = async () => {
} }
}; };
useEffect(() => {
// reset UI
setAreasErr('');
if (!state) { setAreas([]); return; }
// cached? instant
if (areasCacheRef.current.has(state)) {
setAreas(areasCacheRef.current.get(state));
return;
}
// debounce to avoid rapid refetch on quick clicks
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
// cancel previous request if any
if (inflightRef.current) inflightRef.current.abort();
const controller = new AbortController();
inflightRef.current = controller;
setLoadingAreas(true);
try {
// client-side timeout race (6s)
const timeout = new Promise((_, rej) =>
setTimeout(() => rej(new Error('timeout')), 6000)
);
const res = await Promise.race([
fetch(`/api/areas?state=${encodeURIComponent(state)}`, {
signal: controller.signal,
}),
timeout,
]);
if (!res || !res.ok) throw new Error('bad_response');
const data = await res.json();
// normalize, uniq, sort for UX
const list = Array.from(new Set((data.areas || []).filter(Boolean))).sort();
areasCacheRef.current.set(state, list); // cache it
setAreas(list);
} catch (err) {
if (err.name === 'AbortError') return; // superseded by a newer request
setAreas([]);
setAreasErr('Could not load Areas. You can proceed without selecting one.');
} finally {
if (inflightRef.current === controller) inflightRef.current = null;
setLoadingAreas(false);
}
}, 250); // 250ms debounce
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [state]);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4"> <div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
@ -344,9 +403,7 @@ return (
<label> <label>
<span <span
title="Selecting an Area will allow us to provide regional salary data for your career choices." title="Selecting an Area will allow us to provide regional salary data for your career choices."
className="absolute top-2 right-2 translate-x-7 -translate-y-3 className="absolute top-2 right-2 translate-x-7 -translate-y-3 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-xs font-bold text-white cursor-help"
inline-flex h-4 w-4 items-center justify-center
rounded-full bg-blue-500 text-xs font-bold text-white cursor-help"
> >
i i
</span> </span>
@ -358,18 +415,22 @@ return (
onChange={(e) => setArea(e.target.value)} onChange={(e) => setArea(e.target.value)}
disabled={loadingAreas} disabled={loadingAreas}
> >
<option value="">Select Area</option> <option value="">
{areas.map((a, i) => ( {loadingAreas ? 'Loading Areas...' : 'Select Area (optional)'}
<option key={i} value={a}>
{a}
</option> </option>
{areas.map((a, i) => (
<option key={i} value={a}>{a}</option>
))} ))}
</select> </select>
{loadingAreas && ( {loadingAreas && (
<span className="absolute right-3 top-2.5 text-gray-400 text-sm animate-pulse"> <span className="absolute right-3 top-2.5 text-gray-400 text-sm animate-pulse">
Loading... Loading...
</span> </span>
)} )}
{areasErr && (
<p className="mt-1 text-xs text-amber-600">{areasErr}</p>
)}
</div> </div>
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">

View File

@ -16,6 +16,8 @@ const n = (val, fallback = 0) => {
const num = (v) => const num = (v) =>
v === null || v === undefined || v === '' || Number.isNaN(+v) ? 0 : +v; v === null || v === undefined || v === '' || Number.isNaN(+v) ? 0 : +v;
const toDateOnly = (v) => (v && typeof v === 'string' ? v.slice(0, 10) : v);
/*************************************************** /***************************************************
* HELPER: Approx State Tax Rates * HELPER: Approx State Tax Rates
***************************************************/ ***************************************************/
@ -278,22 +280,32 @@ function simulateDrawdown(opts){
// else Bachelor's = 120 // else Bachelor's = 120
} }
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted); const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
const dynamicProgramLength = Math.ceil( // If we must derive a length (years), round to the nearest month (not always up)
remainingCreditHours / (creditHoursPerYear || 30) const derivedYears = (creditHoursPerYear || 30) > 0
); ? Math.ceil((remainingCreditHours / (creditHoursPerYear || 30)) * 12) / 12
const finalProgramLength = programLength || dynamicProgramLength; : 0;
const finalProgramLength = programLength || derivedYears;
// Use the explicit enrollmentDate if provided; otherwise fall back to scenario.start_date
const enrollmentStart = enrollmentDate const enrollmentStart = enrollmentDate
? moment(enrollmentDate).startOf('month') ? moment(toDateOnly(enrollmentDate), 'YYYY-MM-DD', true).startOf('month')
: (startDate ? moment(startDate).startOf('month') : scenarioStartClamped.clone()); : (startDate ? moment(startDate).startOf('month') : null);
const creditsPerYear = creditHoursPerYear || 30; const creditsPerYear = creditHoursPerYear || 30;
const creditsRemaining = Math.max(0, requiredCreditHours - hoursCompleted); const creditsRemaining = Math.max(0, requiredCreditHours - hoursCompleted);
const monthsRemaining = Math.ceil((creditsRemaining / Math.max(1, creditsPerYear)) * 12); const monthsRemaining = Math.ceil((creditsRemaining / Math.max(1, creditsPerYear)) * 12);
// Graduation date: trust explicit, else derive from enrollment+credits,
// else (as a last resort) from enrollment + programLength (years)
const derivedGradFromCredits =
enrollmentStart ? enrollmentStart.clone().add(monthsRemaining, 'months') : null;
const derivedGradFromProgramLen =
enrollmentStart && finalProgramLength > 0
? enrollmentStart.clone().add(Math.ceil(finalProgramLength * 12), 'months')
: null;
const gradDateEffective = gradDate const gradDateEffective = gradDate
? moment(gradDate).startOf('month') ? moment(toDateOnly(gradDate), 'YYYY-MM-DD', true).startOf('month')
: enrollmentStart.clone().add(monthsRemaining, 'months'); : (derivedGradFromCredits || derivedGradFromProgramLen || null);
/*************************************************** /***************************************************
* 4) TUITION CALC * 4) TUITION CALC
@ -321,7 +333,7 @@ function simulateDrawdown(opts){
lumpsSchedule = [...Array(12).keys()]; lumpsSchedule = [...Array(12).keys()];
break; break;
} }
const totalAcademicMonths = finalProgramLength * 12;
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength); const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
/*************************************************** /***************************************************
@ -339,9 +351,11 @@ function simulateDrawdown(opts){
const maxMonths = simulationYears * 12; const maxMonths = simulationYears * 12;
let loanBalance = initialLoanPrincipal; let loanBalance = initialLoanPrincipal;
let loanPaidOffMonth = null; let loanPaidOffMonth = null;
let prevLoanBalance = loanBalance;
let currentEmergencySavings = emergencySavings; let currentEmergencySavings = emergencySavings;
let currentRetirementSavings = retirementSavings; let currentRetirementSavings = retirementSavings;
let cashBalance = 0;
let projectionData = []; let projectionData = [];
@ -354,14 +368,6 @@ function simulateDrawdown(opts){
// We'll keep track that we started in deferral if inCollege & deferral is true // We'll keep track that we started in deferral if inCollege & deferral is true
let wasInDeferral = inCollege && loanDeferralUntilGraduation; let wasInDeferral = inCollege && loanDeferralUntilGraduation;
// If there's a gradDate, let's see if we pass it:
const graduationDateObj = gradDate ? moment(gradDate).startOf('month') : null;
const enrollmentDateObj = enrollmentDate
? moment(enrollmentDate).startOf('month')
: scenarioStartClamped.clone(); // fallback
/*************************************************** /***************************************************
* 7) THE MONTHLY LOOP * 7) THE MONTHLY LOOP
***************************************************/ ***************************************************/
@ -369,6 +375,8 @@ function simulateDrawdown(opts){
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) { for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months'); const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
prevLoanBalance = loanBalance;
if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) { if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) {
reachedRetirement = true; reachedRetirement = true;
firstRetirementBalance = currentRetirementSavings; // capture once firstRetirementBalance = currentRetirementSavings; // capture once
@ -378,19 +386,24 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
retirementSpendMonthly > 0 && retirementSpendMonthly > 0 &&
currentSimDate.isSameOrAfter(retirementStartISO) currentSimDate.isSameOrAfter(retirementStartISO)
// figure out if we are in the college window // College window is inclusive of enrollment month and exclusive of the graduation month
const stillInCollege = const stillInCollege =
inCollege && inCollege &&
currentSimDate.isSameOrAfter(enrollmentStart) && !!enrollmentStart &&
currentSimDate.isBefore(gradDateEffective); (!!gradDateEffective
? currentSimDate.isSameOrAfter(enrollmentStart) &&
currentSimDate.isBefore(gradDateEffective)
: currentSimDate.isSameOrAfter(enrollmentStart));
const hasGraduated = currentSimDate.isSameOrAfter(gradDateEffective.clone().add(1, 'month')); // Flip to expected salary starting in the graduation month when we know it
const hasGraduated =
!!gradDateEffective && currentSimDate.isSameOrAfter(gradDateEffective);
/************************************************ /************************************************
* 7.1 TUITION lumps * 7.1 TUITION lumps
************************************************/ ************************************************/
let tuitionCostThisMonth = 0; let tuitionCostThisMonth = 0;
if (stillInCollege && lumpsPerYear > 0) { if (stillInCollege && lumpsPerYear > 0 && enrollmentStart && enrollmentStart.isValid()) {
const monthsSinceEnroll = Math.max(0, currentSimDate.diff(enrollmentStart, 'months')); const monthsSinceEnroll = Math.max(0, currentSimDate.diff(enrollmentStart, 'months'));
const academicYearIndex = Math.floor(monthsSinceEnroll / 12); const academicYearIndex = Math.floor(monthsSinceEnroll / 12);
const monthInAcadYear = monthsSinceEnroll % 12; const monthInAcadYear = monthsSinceEnroll % 12;
@ -416,8 +429,8 @@ if (stillInCollege && lumpsPerYear > 0) {
currentRetirementSavings -= withdrawal; currentRetirementSavings -= withdrawal;
baseMonthlyIncome += withdrawal; baseMonthlyIncome += withdrawal;
} else if (!stillInCollege) { } else if (!stillInCollege) {
const monthlyFromJob = (hasGraduated && expectedSalary > 0 ? expectedSalary : currentSalary) / 12; const annual = hasGraduated && expectedSalary > 0 ? expectedSalary : currentSalary;
baseMonthlyIncome = monthlyFromJob + (additionalIncome / 12); baseMonthlyIncome = (annual / 12) + (additionalIncome / 12);
} else { } else {
baseMonthlyIncome = (currentSalary / 12) + (additionalIncome / 12); baseMonthlyIncome = (currentSalary / 12) + (additionalIncome / 12);
} }
@ -516,9 +529,10 @@ if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
} }
} }
if (loanBalance <= 0 && !loanPaidOffMonth) { if (!loanPaidOffMonth && prevLoanBalance > 0 && loanBalance <= 0) {
loanPaidOffMonth = currentSimDate.format('YYYY-MM'); loanPaidOffMonth = currentSimDate.format('YYYY-MM');
} }
let leftover = netMonthlyIncome - totalMonthlyExpenses; let leftover = netMonthlyIncome - totalMonthlyExpenses;
@ -552,6 +566,12 @@ if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
shortfall -= canCover; shortfall -= canCover;
} }
// any remaining shortfall becomes negative cash (credit/overdraft)
if (shortfall > 0) {
cashBalance -= shortfall;
shortfall = 0;
}
// Surplus => leftover // Surplus => leftover
if (canSaveThisMonth && leftover > 0) { if (canSaveThisMonth && leftover > 0) {
const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation; const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation;
@ -598,7 +618,8 @@ if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
loanBalance: +loanBalance.toFixed(2), loanBalance: +loanBalance.toFixed(2),
loanPaymentThisMonth: +(monthlyLoanPayment + extraPayment).toFixed(2), loanPaymentThisMonth: +(monthlyLoanPayment + extraPayment).toFixed(2),
totalSavings: (currentEmergencySavings + currentRetirementSavings).toFixed(2), cashBalance: +cashBalance.toFixed(2),
totalSavings: +(currentEmergencySavings + currentRetirementSavings + cashBalance).toFixed(2),
fedYTDgross: +fedYTDgross.toFixed(2), fedYTDgross: +fedYTDgross.toFixed(2),
fedYTDtax: +fedYTDtax.toFixed(2), fedYTDtax: +fedYTDtax.toFixed(2),

View File

@ -23,7 +23,8 @@ export const MISSING_LABELS = {
loan_term: 'Loan term', loan_term: 'Loan term',
expected_graduation: 'Expected graduation date', expected_graduation: 'Expected graduation date',
program_type: 'Program type', program_type: 'Program type',
academic_calendar: 'Academic calendar' academic_calendar: 'Academic calendar',
expected_salary: 'Expected salary after graduation'
}; };
/** /**
@ -42,7 +43,8 @@ export default function getMissingFields(
if (!hasValue(scenario.retirement_start_date)) missing.push('retirement_start_date'); if (!hasValue(scenario.retirement_start_date)) missing.push('retirement_start_date');
// ── Financial base // ── Financial base
if (!hasValue(financial.current_salary)) missing.push('current_salary'); // Intentionally do NOT require current salary.
// Missing/0 income is allowed; we only require expected salary (below) for students.
if (!any(scenario.planned_monthly_expenses, financial.monthly_expenses)) { if (!any(scenario.planned_monthly_expenses, financial.monthly_expenses)) {
missing.push('monthly_expenses'); missing.push('monthly_expenses');
@ -69,6 +71,12 @@ export default function getMissingFields(
// ── College only if theyre enrolled / prospective // ── College only if theyre enrolled / prospective
if (requireCollegeData) { if (requireCollegeData) {
if (!hasValue(college.tuition)) missing.push('tuition'); if (!hasValue(college.tuition)) missing.push('tuition');
// If user has no income (0 or missing), require an expected post-grad salary
const incomeNum = Number(financial.current_salary);
const noIncome = !hasValue(financial.current_salary) || !Number.isFinite(incomeNum) || incomeNum <= 0;
if (noIncome && !hasValue(college.expected_salary)) {
missing.push('expected_salary');
}
const plansToBorrow = const plansToBorrow =
(Number(college.existing_college_debt) || 0) > 0 || (Number(college.existing_college_debt) || 0) > 0 ||

View File

@ -1,20 +1,50 @@
// src/utils/onboardingDraftApi.js import authFetch from './authFetch.js';
import authFetch from './authFetch.js';
const API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, ''); const API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, '');
const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`; const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`;
export async function loadDraft() { export async function loadDraft() {
const res = await authFetch(DRAFT_URL); const res = await authFetch(DRAFT_URL);
if (!res) return null; // session expired if (!res) return null; // session expired
if (res.status === 404) return null; if (res.status === 404) return null;
if (!res.ok) throw new Error(`loadDraft ${res.status}`); if (!res.ok) throw new Error(`loadDraft ${res.status}`);
return res.json(); // null or { id, step, data } return res.json(); // null or { id, step, data }
} }
/**
* saveDraft(input)
* Accepts either:
* - { id, step, data } // full envelope
* - { id, step, careerData?, financialData?, collegeData? } // partial sections
* Merges partials with the current server draft to avoid clobbering.
*/
export async function saveDraft(input = {}) {
// Normalize inputs
let { id = null, step = 0, data } = input;
// If caller passed sections (careerData / financialData / collegeData) instead of a 'data' envelope,
// merge them into the existing server draft so we don't drop other sections.
if (data == null) {
// Load existing draft (may be null/404 for first-time)
let existing = null;
try { existing = await loadDraft(); } catch (_) {}
const existingData = (existing && existing.data) || {};
const has = (k) => Object.prototype.hasOwnProperty.call(input, k);
const patch = {};
if (has('careerData')) patch.careerData = input.careerData;
if (has('financialData')) patch.financialData = input.financialData;
if (has('collegeData')) patch.collegeData = input.collegeData;
data = { ...existingData, ...patch };
// Prefer caller's id/step when provided; otherwise reuse existing
if (id == null && existing?.id != null) id = existing.id;
if (step == null && existing?.step != null) step = existing.step;
}
export async function saveDraft({ id = null, step = 0, data = {} } = {}) {
const res = await authFetch(DRAFT_URL, { const res = await authFetch(DRAFT_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, step, data }), body: JSON.stringify({ id, step, data }),
}); });
if (!res) return null; if (!res) return null;
@ -22,9 +52,9 @@ export async function saveDraft({ id = null, step = 0, data = {} } = {}) {
return res.json(); // { id, step } return res.json(); // { id, step }
} }
export async function clearDraft() { export async function clearDraft() {
const res = await authFetch(DRAFT_URL, { method: 'DELETE' }); const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
if (!res) return false; if (!res) return false;
if (!res.ok) throw new Error(`clearDraft ${res.status}`); if (!res.ok) throw new Error(`clearDraft ${res.status}`);
return true; // server returns { ok: true } return true; // server returns { ok: true }
} }