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 { useLocation } from "react-router-dom";
import authFetch from "../utils/authFetch.js";
const isoToday = new Date().toISOString().slice(0,10); // top-level helper
async function ensureCoachThread() {
// try to list an existing thread
const r = await authFetch('/api/premium/coach/chat/threads');
@ -21,6 +25,19 @@ async function ensureCoachThread() {
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 = "") {
return `
You are an expert interviewer for the role **${careerName}**.
@ -136,6 +153,7 @@ export default function CareerCoach({
onMilestonesCreated,
onAiRiskFetched
}) {
const location = useLocation();
/* -------------- state ---------------- */
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
@ -153,6 +171,45 @@ export default function CareerCoach({
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
}, [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(() => {
let cancelled = false;
@ -170,17 +227,24 @@ useEffect(() => {
if (cancelled) return;
if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) {
const data = await r3.json();
const msgs = Array.isArray(data.messages) ? data.messages : [];
if (!cancelled) setMessages(msgs.length ? msgs : [generatePersonalizedIntro()]);
} else {
if (!cancelled) setMessages([generatePersonalizedIntro()]);
}
const data = await r3.json();
const msgs = (Array.isArray(data.messages) ? data.messages : []).filter(m => !isHiddenPrompt(m));
if (!cancelled) {
setMessages(msgs); // no intro here
historyLoadedRef.current = true;
}
} else {
if (!cancelled) {
setMessages([]); // no intro here
historyLoadedRef.current = true;
}
}
} catch (e) {
console.error("Coach thread preload failed:", e);
if (!cancelled) {
setThreadId(null);
setMessages([generatePersonalizedIntro()]);
setMessages([]);
historyLoadedRef.current = true;
}
}
})();
@ -191,9 +255,11 @@ useEffect(() => {
/* -------------- intro ---------------- */
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()]));
}, [scenarioRow?.id]);
}, [historyLoadedRef.current, scenarioRow?.career_name, userProfile]);
/* ---------- helpers you already had ---------- */
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() {
/* (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 riskLevel = scenarioRow?.riskLevel;
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"
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
>
{messages.map((m, i) => (
{messages.filter(m => !isHiddenPrompt(m)).map((m, i) => (
<div
key={i}
className={`rounded p-2 ${

View File

@ -4,46 +4,104 @@ import authFetch from '../utils/authFetch.js';
const CareerPrioritiesModal = ({ userProfile, onClose }) => {
const [responses, setResponses] = useState({});
useEffect(() => {
if (userProfile?.career_priorities) {
setResponses(JSON.parse(userProfile.career_priorities));
}
}, [userProfile]);
// Updated "interests" question:
const questions = [
{
id: 'interests',
const QUESTIONS = [
{ id: 'interests',
text: 'How important is it that your career aligns with your personal interests?',
options: ['Very important', 'Somewhat important', 'Not as important'],
},
{
id: 'meaning',
{ id: 'meaning',
text: 'Is it important your job helps others or makes a difference?',
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
},
{
id: 'stability',
{ id: 'stability',
text: 'How important is it that your career pays well?',
options: ['Very important', 'Somewhat important', 'Not as important'],
},
{
id: 'growth',
{ id: 'growth',
text: 'Do you want clear chances to advance and grow professionally?',
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?',
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?',
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 payload = {
firstName: userProfile.firstname,
@ -55,7 +113,7 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => {
careerSituation: userProfile.career_situation || null,
career_priorities: JSON.stringify(responses),
};
try {
await authFetch('/api/user-profile', {
method: 'POST',
@ -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 (
<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">
<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">
<label className="block mb-2 font-medium">{q.text}</label>
<select
value={responses[q.id] || ''}
onChange={(e) =>
setResponses({ ...responses, [q.id]: e.target.value })
}
onChange={(e) => setResponses({ ...responses, [q.id]: e.target.value })}
className="w-full border px-3 py-2 rounded"
>
<option value="" disabled>
Select an answer
</option>
<option value="" disabled>Select an answer</option>
{q.options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
@ -99,9 +151,7 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => {
<button
onClick={handleSave}
disabled={!allAnswered}
className={`px-4 py-2 rounded ${
allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'
}`}
className={`px-4 py-2 rounded ${allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'}`}
>
Save Answers
</button>

View File

@ -52,7 +52,7 @@ ChartJS.register(
PointElement,
Tooltip,
Legend,
zoomPlugin, // 👈 ←–––– only if you kept the zoom config
zoomPlugin,
annotationPlugin
);
@ -486,19 +486,30 @@ const zoomConfig = {
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 = {
x: {
type: 'time',
time: { unit: 'month' },
ticks: { maxRotation: 0, autoSkip: true }
},
y: {
beginAtZero: true,
ticks: {
callback: (val) => val.toLocaleString() // comma-format big numbers
}
}
};
x: {
type: 'time',
time: { unit: 'month' },
ticks: { maxRotation: 0, autoSkip: true }
},
y: {
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: {
callback: (val) => val.toLocaleString()
}
}
};
/*
* ONE-TIME MISSING FIELDS GUARD
@ -1261,7 +1272,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
const emergencyData = {
label: 'Emergency Savings',
data: projectionData.map((p) => p.emergencySavings),
data: projectionData.map((p) => Number(p.emergencySavings ?? 0)),
borderColor: 'rgba(255, 159, 64, 1)',
backgroundColor: 'rgba(255, 159, 64, 0.2)',
tension: 0.4,
@ -1269,7 +1280,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
};
const retirementData = {
label: 'Retirement Savings',
data: projectionData.map((p) => p.retirementSavings),
data: projectionData.map((p) => Number(p.retirementSavings ?? 0)),
borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4,
@ -1277,7 +1288,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
};
const totalSavingsData = {
label: 'Total Savings',
data: projectionData.map((p) => p.totalSavings),
data: projectionData.map((p) => Number(p.totalSavings ?? 0)),
borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: 'rgba(54, 162, 235, 0.2)',
tension: 0.4,
@ -1285,7 +1296,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
};
const loanBalanceData = {
label: 'Loan Balance',
data: projectionData.map((p) => p.loanBalance),
data: projectionData.map((p) => Number(p.loanBalance ?? 0)),
borderColor: 'rgba(255, 99, 132, 1)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
tension: 0.4,
@ -1510,7 +1521,7 @@ const handleMilestonesCreated = useCallback(
{showMissingBanner && (
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4">
<p className="text-sm text-gray-800">
To run your full projection, please add:
To improve your projection, please add:
</p>
{!!missingKeys.length && (
<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;
};
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) {
const bools = [
'is_in_state','is_in_district','is_online',
@ -122,15 +128,69 @@ const onProgramInput = (e) => {
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(() => {
if (id && id !== 'new') {
(async () => {
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]);
// 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));
}
}, [careerId, id]);
}
}, [form.tuition]);
async function handleSave(){
try{
@ -245,6 +305,8 @@ const chosenTuition = (() => {
*/
useEffect(() => {
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);
if (!chpy || chpy <= 0) return;
@ -266,7 +328,7 @@ const chpy = parseFloat(form.credit_hours_per_year);
if (creditsNeeded <= 0) return;
/* 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) {
setForm(prev => ({ ...prev, program_length: years }));
@ -522,6 +584,7 @@ return (
<label className="block font-medium">Program Length (years)</label>
<input
type="number"
step="0.01"
name="program_length"
value={form.program_length}
onChange={handleFieldChange}

View File

@ -5,6 +5,7 @@ import { ONET_DEFINITIONS } from './definitions.js';
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
import ChatCtx from '../contexts/ChatCtx.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
function normalizeKsaPayloadForCombine(payload, socCode) {
@ -71,6 +72,11 @@ function ensureHttp(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
function renderImportance(val) {
const max = 5;
@ -168,24 +174,55 @@ function normalizeCipList(arr) {
}
// Fixed handleSelectSchool (removed extra brace)
const handleSelectSchool = (school) => {
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?'
);
if (!proceed) return;
// normalize selectedCareer and carry it forward
const sel = selectedCareer
? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode }
: null;
// Replace your existing handleSelectSchool with this:
const handleSelectSchool = async (school) => {
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?'
);
if (!proceed) return;
navigate('/career-roadmap', {
state: {
premiumOnboardingState: {
selectedCareer: sel, // SOC-bearing career object
selectedSchool: school // school card just chosen
}
}
});
// normalize the currently selected career for handoff (optional)
const sel = selectedCareer
? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode }
: 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', {
state: {
premiumOnboardingState: {
selectedCareer: sel,
selectedSchool: {
INSTNM: school.INSTNM,
CIPDESC: selected_program,
CREDDESC: program_type,
UNITID: school.UNITID ?? null,
},
},
},
});
};
function getSearchLinks(ksaName, careerTitle) {
@ -738,7 +775,7 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
school['INSTNM'] || 'Unnamed School'
)}
</strong>
<p> Program: {school['CIPDESC'] || 'N/A'}</p>
<p>Program: {cleanCipDesc(school['CIPDESC'])}</p>
<p>Degree Type: {school['CREDDESC'] || '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>

View File

@ -173,30 +173,60 @@ export default function MilestoneEditModal({
});
async function saveNew(){
if(isSavingNew) return;
if(!newMilestone.title.trim()||!newMilestone.date.trim()){
alert('Need title & date'); return;
}
setIsSavingNew(true);
const hdr = { title:newMilestone.title, description:newMilestone.description,
date:toSqlDate(newMilestone.date), career_profile_id:careerProfileId,
progress:newMilestone.progress, status:newMilestone.progress>=100?'completed':'planned',
is_universal:newMilestone.isUniversal };
const res = await authFetch('/api/premium/milestone',{method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify(hdr)});
const created = Array.isArray(await res.json())? (await res.json())[0]:await res.json();
for(const imp of newMilestone.impacts){
const body = {
milestone_id:created.id, impact_type:imp.impact_type,
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
if (isSavingNew) return;
if (!newMilestone.title.trim() || !newMilestone.date.trim()) {
alert('Need title & date'); return;
}
setIsSavingNew(true);
const toDate = (v) => (v ? String(v).slice(0,10) : null);
try {
const hdr = {
title: newMilestone.title,
description: newMilestone.description,
date: toDate(newMilestone.date),
career_profile_id: careerProfileId,
progress: newMilestone.progress,
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
is_universal: newMilestone.isUniversal,
};
const res = await authFetch('/api/premium/milestone', {
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),
};
await authFetch('/api/premium/milestone-impacts',{
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
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();
setAddingNew(false);
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 (
<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 */}
<div className="flex items-center justify-between px-6 py-4 border-b">
<div>
@ -217,7 +247,7 @@ export default function MilestoneEditModal({
</div>
{/* 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 */}
{milestones.map(m=>{
const open = editingId===m.id;
@ -272,7 +302,7 @@ export default function MilestoneEditModal({
{/* impacts */}
<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>
<Button size="xs" onClick={()=>addImpactRow(m.id)}>+ Add impact</Button>
</div>
@ -282,7 +312,7 @@ export default function MilestoneEditModal({
<div className="space-y-3">
{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 */}
<div>
<label className="label-xs">Type</label>
@ -296,21 +326,21 @@ export default function MilestoneEditModal({
</select>
</div>
{/* direction hide for salary */}
{imp.impact_type!=='salary' && (
<div>
<label className="label-xs">Direction</label>
<select
className="input"
value={imp.direction}
onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
<option value="add">Add</option>
<option value="subtract">Subtract</option>
</select>
</div>
)}
{imp.impact_type !== 'salary' ? (
<div>
<label className="label-xs">Direction</label>
<select className="input" value={imp.direction} onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
<option value="add">Add</option>
<option value="subtract">Subtract</option>
</select>
</div>
) : (
// keep the grid column to prevent the next columns from collapsing
<div className="hidden md:block" />
)}
{/* amount */}
<div>
<label className="label-xs">Amount</label>
<div className="md:w-[100px]">
<label className="label-xs">Amount</label>
<input
type="number"
className="input"
@ -319,12 +349,12 @@ export default function MilestoneEditModal({
/>
</div>
{/* dates */}
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-2 gap-2 min-w-0">
<div>
<label className="label-xs">Start</label>
<input
type="date"
className="input"
className="input w-full min-w-[14ch] px-3"
value={imp.start_date}
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>
<input
type="date"
className="input"
className="input w-full min-w-[14ch] px-3"
value={imp.end_date}
onChange={e=>updateImpact(m.id,idx,'end_date',e.target.value)}
/>
@ -345,7 +375,7 @@ export default function MilestoneEditModal({
<Button
size="icon-xs"
variant="ghost"
className="text-red-600"
className="text-red-600 w-10 h-9 flex items-center justify-center"
onClick={()=>removeImpactRow(m.id,idx)}
>
@ -401,7 +431,7 @@ export default function MilestoneEditModal({
<label className="label-xs">Date</label>
<input
type="date"
className="input"
className="input w-full min-w-[12ch] px-3"
value={newMilestone.date}
onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))}
/>
@ -420,69 +450,93 @@ export default function MilestoneEditModal({
<div>
<div className="flex items-center justify-between">
<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 className="space-y-3 mt-2">
{newMilestone.impacts.map((imp,idx)=>(
<div key={idx} className="grid md:grid-cols-[150px_120px_1fr_auto] gap-2 items-end">
{newMilestone.impacts.map((imp, idx) => (
<div
key={idx}
className="grid gap-2 items-end min-w-0 grid-cols-[140px_110px_100px_minmax(220px,1fr)_44px]"
>
{/* Type */}
<div>
<label className="label-xs">Type</label>
<select
className="input"
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="ONE_TIME">Onetime</option>
<option value="ONE_TIME">One-time</option>
<option value="MONTHLY">Monthly</option>
</select>
</div>
{imp.impact_type!=='salary' && (
{/* Direction (spacer when salary) */}
{imp.impact_type !== 'salary' ? (
<div>
<label className="label-xs">Direction</label>
<select
className="input"
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="subtract">Subtract</option>
</select>
</div>
) : (
<div className="hidden md:block" />
)}
<div>
{/* Amount (fixed width) */}
<div className="md:w-[100px]">
<label className="label-xs">Amount</label>
<input
type="number"
className="input"
value={imp.amount}
onChange={e=>updateNewImpact(idx,'amount',e.target.value)}
onChange={(e) => updateNewImpact(idx, 'amount', e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="date"
className="input"
value={imp.start_date}
onChange={e=>updateNewImpact(idx,'start_date',e.target.value)}
/>
{imp.impact_type==='MONTHLY' && (
{/* Dates (flex) */}
<div className="grid grid-cols-2 gap-2 min-w-0">
<div>
<label className="label-xs">Start</label>
<input
type="date"
className="input"
value={imp.end_date}
onChange={e=>updateNewImpact(idx,'end_date',e.target.value)}
className="input w-full min-w-[14ch] px-3"
value={imp.start_date}
onChange={(e) => updateNewImpact(idx, 'start_date', e.target.value)}
/>
</div>
{imp.impact_type === 'MONTHLY' && (
<div>
<label className="label-xs">End</label>
<input
type="date"
className="input w-full min-w-[14ch] px-3"
value={imp.end_date}
onChange={(e) => updateNewImpact(idx, 'end_date', e.target.value)}
/>
</div>
)}
</div>
{/* Remove */}
<Button
size="icon-xs"
variant="ghost"
className="text-red-600"
onClick={()=>removeNewImpact(idx)}
className="text-red-600 w-11 h-9 flex items-center justify-center"
onClick={() => removeNewImpact(idx)}
aria-label="Remove impact"
>
</Button>
</div>
))}
</div>
</div>
{/* save row */}

View File

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

View File

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

View File

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

View File

@ -86,9 +86,16 @@ export default function OnboardingContainer() {
}
// 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) {
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);

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 { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import { Button } from './ui/button.js';
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
import InfoTooltip from "./ui/infoTooltip.js";
// Data paths
const CIP_URL = '/api/data/cip-institution-map';
const IPEDS_URL = '/api/data/ic2023';
const CAREER_CLUSTERS_URL = '/api/data/career_clusters.json';
import CareerSearch from './CareerSearch.js';
import api from '../auth/apiClient.js'
export default function ScenarioEditModal({
show,
@ -17,11 +12,6 @@ export default function ScenarioEditModal({
collegeProfile,
financialProfile
}) {
/*********************************************************
* 1) CIP / IPEDS data states
*********************************************************/
const [schoolData, setSchoolData] = useState([]);
const [icTuitionData, setIcTuitionData] = useState([]);
/*********************************************************
* 2) Suggestions & program types
@ -29,6 +19,7 @@ export default function ScenarioEditModal({
const [schoolSuggestions, setSchoolSuggestions] = useState([]);
const [programSuggestions, setProgramSuggestions] = useState([]);
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
const [selectedUnitId, setSelectedUnitId] = useState(null);
/*********************************************************
* 3) Manual vs auto for tuition & program length
@ -38,14 +29,6 @@ export default function ScenarioEditModal({
const [manualProgLength, setManualProgLength] = useState('');
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
*********************************************************/
@ -65,81 +48,52 @@ export default function ScenarioEditModal({
*********************************************************/
useEffect(() => {
if (!show) return;
setShowCollegeForm(
['currently_enrolled', 'prospective_student']
.includes(formData.college_enrollment_status)
);
const hasCollegeData =
!!formData.selected_school ||
!!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]);
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
*********************************************************/
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**
@ -153,6 +107,12 @@ export default function ScenarioEditModal({
const safe = 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({
// scenario portion
scenario_title : safe(s.scenario_title),
@ -182,7 +142,10 @@ export default function ScenarioEditModal({
is_in_state: safe(!!c.is_in_state),
is_in_district: safe(!!c.is_in_district),
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),
existing_college_debt : safe(c.existing_college_debt),
@ -219,89 +182,35 @@ export default function ScenarioEditModal({
setManualProgLength('');
}
setCareerSearchInput(s.career_name || '');
}, [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
*********************************************************/
useEffect(() => {
if (!show) return;
if (!formData.selected_school || !formData.selected_program) {
if (!show) return;
if (!formData.selected_school || !formData.selected_program) {
setAvailableProgramTypes([]);
return;
}
(async () => {
try {
const { data } = await api.get('/api/programs/types', {
params: {
school: formData.selected_school,
program: formData.selected_program
}
});
setAvailableProgramTypes(Array.isArray(data?.types) ? data.types : []);
} catch {
setAvailableProgramTypes([]);
return;
}
const filtered = schoolData.filter(
(row) =>
row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() &&
row.CIPDESC === formData.selected_program
);
const possibleTypes = [...new Set(filtered.map((r) => r.CREDDESC))];
setAvailableProgramTypes(possibleTypes);
})();
}, [
show,
formData.selected_school,
formData.selected_program,
schoolData
]);
/*********************************************************
@ -314,67 +223,72 @@ useEffect(() => {
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) {
const val = e.target.value;
setFormData((prev) => ({
...prev,
selected_school: val,
selected_program: '',
program_type: '',
credit_hours_required: ''
}));
if (!val) {
setFormData(prev => ({
...prev,
selected_school: val,
selected_program: '',
program_type: '',
credit_hours_required: ''
}));
if (!val.trim()) {
setSchoolSuggestions([]);
setProgramSuggestions([]);
setAvailableProgramTypes([]);
setSelectedUnitId(null);
return;
}
(async () => {
try {
const { data } = await api.get('/api/schools/suggest', {
params: { query: val, limit: 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([]);
setProgramSuggestions([]);
setAvailableProgramTypes([]);
return;
}
const filtered = schoolData.filter((s) =>
s.INSTNM.toLowerCase().includes(val.toLowerCase())
);
const unique = [...new Set(filtered.map((s) => s.INSTNM))];
setSchoolSuggestions(unique.slice(0, 10));
})();
}
function handleSchoolSelect(sch) {
setFormData((prev) => ({
...prev,
selected_school: sch,
selected_program: '',
program_type: '',
credit_hours_required: ''
}));
setSchoolSuggestions([]);
}
const name = sch?.name || sch || '';
const uid = sch?.unitId ?? null;
setSelectedUnitId(uid);
setFormData(prev => ({
...prev,
selected_school: name,
selected_program: '',
program_type: '',
credit_hours_required: ''
}));
setSchoolSuggestions([]);
setProgramSuggestions([]);
setAvailableProgramTypes([]);
}
function handleProgramChange(e) {
const val = e.target.value;
setFormData((prev) => ({ ...prev, selected_program: val }));
if (!val) {
if (!val || !formData.selected_school) {
setProgramSuggestions([]);
return;
}
(async () => {
try {
const { data } = await api.get('/api/programs/suggest', {
params: { school: formData.selected_school, query: val, limit: 10 }
});
const list = Array.isArray(data) ? data : []; // [{ program }]
setProgramSuggestions(list.map(p => p.program));
} catch {
setProgramSuggestions([]);
return;
}
const filtered = schoolData.filter(
(row) =>
row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() &&
row.CIPDESC.toLowerCase().includes(val.toLowerCase())
);
const unique = [...new Set(filtered.map((r) => r.CIPDESC))];
setProgramSuggestions(unique.slice(0, 10));
})();
}
function handleProgramSelect(prog) {
@ -422,10 +336,9 @@ useEffect(() => {
scenarioRow.planned_monthly_debt_payments ??
financialData.monthly_debt_payments ??
0,
partTimeIncome:
additionalIncome:
scenarioRow.planned_additional_income ??
financialData.additional_income ??
0,
financialData.additional_income ?? 0,
emergencySavings: financialData.emergency_fund ?? 0,
retirementSavings: financialData.retirement_savings ?? 0,
@ -618,40 +531,17 @@ if (formData.retirement_start_date) {
/>
</div>
{/* Career Search */}
<div className="mb-4 relative">
<label className="block text-sm font-medium text-gray-700 mb-1">
Career Search
</label>
<input
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">
<em>Current Career:</em> {formData.career_name || '(none)'}
</p>
</div>
{/* Career Search (shared component) */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Career Search</label>
<CareerSearch onCareerSelected={(found) => {
if (!found) return;
setFormData(prev => ({ ...prev, career_name: found.title || prev.career_name || '' }));
}}/>
<p className="text-sm text-gray-600 mt-1">
<em>Selected Career:</em> {formData.career_name || '(none)'}
</p>
</div>
{/* Status */}
<div className="mb-4">
@ -937,13 +827,10 @@ if (formData.retirement_start_date) {
max-h-48 overflow-auto mt-1
"
>
{schoolSuggestions.map((sch, i) => (
<li
key={i}
className="p-2 cursor-pointer hover:bg-gray-100"
onClick={() => handleSchoolSelect(sch)}
>
{sch}
{schoolSuggestions.map((sch, i) => (
<li key={i} className="p-2 cursor-pointer hover:bg-gray-100"
onClick={() => handleSchoolSelect(sch)}>
{sch.name}
</li>
))}
</ul>
@ -1216,11 +1103,6 @@ if (formData.retirement_start_date) {
<Button variant="primary" onClick={handleSave}>
Save
</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>
{/* 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 { Button } from './ui/button.js';
import SituationCard from './ui/SituationCard.js';
@ -54,11 +54,16 @@ function SignUp() {
const [zipcode, setZipcode] = useState('');
const [state, setState] = useState('');
const [area, setArea] = useState('');
const [areas, setAreas] = useState([]);
const [error, setError] = useState('');
const [loadingAreas, setLoadingAreas] = useState(false);
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 [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 (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
@ -341,35 +400,37 @@ return (
</select>
<div className="relative w-full">
<label>
<span
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
inline-flex h-4 w-4 items-center justify-center
rounded-full bg-blue-500 text-xs font-bold text-white cursor-help"
<label>
<span
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 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-xs font-bold text-white cursor-help"
>
i
</span>
</label>
<select
id="area"
className="w-full px-3 py-2 border rounded-md"
value={area}
onChange={(e) => setArea(e.target.value)}
disabled={loadingAreas}
>
i
</span>
</label>
<select
id="area"
className="w-full px-3 py-2 border rounded-md"
value={area}
onChange={(e) => setArea(e.target.value)}
disabled={loadingAreas}
>
<option value="">Select Area</option>
{areas.map((a, i) => (
<option key={i} value={a}>
{a}
<option value="">
{loadingAreas ? 'Loading Areas...' : 'Select Area (optional)'}
</option>
))}
</select>
{loadingAreas && (
<span className="absolute right-3 top-2.5 text-gray-400 text-sm animate-pulse">
Loading...
</span>
)}
{areas.map((a, i) => (
<option key={i} value={a}>{a}</option>
))}
</select>
{loadingAreas && (
<span className="absolute right-3 top-2.5 text-gray-400 text-sm animate-pulse">
Loading...
</span>
)}
{areasErr && (
<p className="mt-1 text-xs text-amber-600">{areasErr}</p>
)}
</div>
<Button type="submit" className="w-full">

View File

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

View File

@ -23,7 +23,8 @@ export const MISSING_LABELS = {
loan_term: 'Loan term',
expected_graduation: 'Expected graduation date',
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');
// ── 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)) {
missing.push('monthly_expenses');
@ -69,6 +71,12 @@ export default function getMissingFields(
// ── College only if theyre enrolled / prospective
if (requireCollegeData) {
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 =
(Number(college.existing_college_debt) || 0) > 0 ||

View File

@ -1,30 +1,60 @@
// 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 DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`;
const API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, '');
const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`;
export async function loadDraft() {
const res = await authFetch(DRAFT_URL);
if (!res) return null; // session expired
if (res.status === 404) return null;
if (!res.ok) throw new Error(`loadDraft ${res.status}`);
return res.json(); // null or { id, step, data }
}
export async function loadDraft() {
const res = await authFetch(DRAFT_URL);
if (!res) return null; // session expired
if (res.status === 404) return null;
if (!res.ok) throw new Error(`loadDraft ${res.status}`);
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, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, step, data }),
});
if (!res) return null;
if (!res.ok) throw new Error(`saveDraft ${res.status}`);
return res.json(); // { id, step }
return res.json(); // { id, step }
}
export async function clearDraft() {
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
if (!res) return false;
if (!res.ok) throw new Error(`clearDraft ${res.status}`);
return true; // server returns { ok: true }
}
export async function clearDraft() {
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
if (!res) return false;
if (!res.ok) throw new Error(`clearDraft ${res.status}`);
return true; // server returns { ok: true }
}