diff --git a/.build.hash b/.build.hash index 97c76bb..f5825b3 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -767a2e51259e707655c80d6449afa93abf982fec-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b +408b293acaaa053b934050f88b9c93db41ecb097-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/backend/config/env.js b/backend/config/env.js index 16b71c6..45e39b7 100644 --- a/backend/config/env.js +++ b/backend/config/env.js @@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename); // repo root = two levels up from /backend/config const repoRoot = path.resolve(__dirname, '..', '..'); -const env = (process.env.NODE_ENV || 'development').trim(); +const env = (process.env.ENV_NAME || 'prod').trim(); // Prefer .env.development / .env.production — fall back to plain .env const fileA = path.join(repoRoot, `.env.${env}`); diff --git a/backend/server1.js b/backend/server1.js index efacfdb..6eec2a6 100755 --- a/backend/server1.js +++ b/backend/server1.js @@ -27,7 +27,7 @@ const CANARY_SQL = ` const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootPath = path.resolve(__dirname, '..'); -const env = process.env.NODE_ENV?.trim() || 'development'; +const isProd = (process.env.ENV_NAME === 'prod'); const envPath = path.resolve(rootPath, `.env.${env}`); dotenv.config({ path: envPath, override: false }); @@ -778,7 +778,10 @@ app.post('/api/auth/verify/email/send', requireAuth, verifySendLimiter, async (r text, html: `
${text}
` }); - return res.status(200).json({ ok: true }); + // In non-production, include token to enable E2E to complete verification without email I/O. + const extra = (process.env.ENV_NAME === 'prod') ? {} : { test_token: token }; + return res.status(200).json({ ok: true, ...extra }); + } catch (e) { console.error('[verify/email/send]', e?.message || e); return res.status(500).json({ error: 'Failed to send verification email' }); @@ -1092,7 +1095,7 @@ app.post('/api/user-profile', requireAuth, async (req, res) => { career_list = ?, phone_e164 = ?, sms_opt_in = ?, - sms_reminders_opt_in = ? + sms_reminders_opt_in = ?, sms_reminders_opt_in_at = CASE WHEN ? = 1 AND (sms_reminders_opt_in IS NULL OR sms_reminders_opt_in = 0) @@ -1118,6 +1121,7 @@ app.post('/api/user-profile', requireAuth, async (req, res) => { phoneFinal, smsOptFinal, smsRemindersFinal, + smsRemindersFinal, profileId ]; @@ -1125,14 +1129,16 @@ app.post('/api/user-profile', requireAuth, async (req, res) => { return res.status(200).json({ message: 'User profile updated successfully' }); } else { // INSERT branch - const insertQuery = ` + const insertQuery = ` INSERT INTO user_profile (id, username, firstname, lastname, email, email_lookup, zipcode, state, area, - career_situation, interest_inventory_answers, riasec_scores, - career_priorities, career_list, phone_e164, sms_opt_in, sms_reminders_opt_in) + career_situation, interest_inventory_answers, riasec_scores, + career_priorities, career_list, phone_e164, sms_opt_in, sms_reminders_opt_in, + sms_reminders_opt_in_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, - ?, ?, ?, ?, ?) + ?, ?, ?, + ?, ?, ?, ?, ?, + CASE WHEN ? = 1 THEN UTC_TIMESTAMP() ELSE NULL END) `; const params = [ profileId, @@ -1151,7 +1157,8 @@ app.post('/api/user-profile', requireAuth, async (req, res) => { finalCareerList, phoneFinal, smsOptFinal, - smsRemindersFinal + smsRemindersFinal, + smsRemindersFinal, ]; diff --git a/backend/server2.js b/backend/server2.js index 3226149..1a02f84 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -32,7 +32,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootPath = path.resolve(__dirname, '..'); -const env = process.env.NODE_ENV?.trim() || 'development'; +const isProd = (process.env.ENV_NAME === 'prod'); const envPath = path.resolve(rootPath, `.env.${env}`); dotenv.config({ path: envPath, override: false }); // don't clobber compose-injected env @@ -46,7 +46,7 @@ const DB_POOL_SIZE = 6; const API_BASE = (process.env.APTIVA_INTERNAL_API || 'http://server1:5000').replace(/\/+$/, ''); const REQUIRED_FILES = [CIP_TO_SOC_PATH, INSTITUTION_DATA_PATH, SALARY_DB_PATH]; -if (process.env.NODE_ENV !== 'production') REQUIRED_FILES.push(USER_PROFILE_DB_PATH); +if (process.env.ENV_NAME !== 'prod') REQUIRED_FILES.push(USER_PROFILE_DB_PATH); for (const p of REQUIRED_FILES) { if (!fs.existsSync(p)) { console.error(`FATAL Required data file not found → ${p}`); diff --git a/backend/server3.js b/backend/server3.js index b879e0e..11677f5 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -34,7 +34,7 @@ import './jobs/reminderCron.js'; import { cacheSummary } from "./utils/ctxCache.js"; const rootPath = path.resolve(__dirname, '..'); -const env = (process.env.NODE_ENV || 'prod'); +const env = (process.env.ENV_NAME || 'prod'); const envPath = path.resolve(rootPath, `.env.${env}`); if (!process.env.FROM_SECRETS_MANAGER) { dotenv.config({ path: envPath, override: false }); @@ -602,7 +602,7 @@ app.post( return res.status(400).end(); } // Env guard: only handle events matching our env - const isProd = (process.env.NODE_ENV === 'prod'); + const isProd = (process.env.ENV_NAME === 'prod'); if (Boolean(event.livemode) !== isProd) { console.warn('[Stripe] Ignoring webhook due to livemode mismatch', { livemode: event.livemode, isProd }); return res.sendStatus(200); @@ -1455,14 +1455,17 @@ I'm here to support you with personalized coaching—what would you like to focu salaryAnalysis = null, economicProjections = null }) { + const _userProfile = userProfile || {}; + const _scenarioRow = scenarioRow || {}; + const _financialProfile = financialProfile || {}; + const _collegeProfile = collegeProfile || {}; // 1) USER PROFILE const firstName = userProfile.firstname || "N/A"; const lastName = userProfile.lastname || "N/A"; const fullName = `${firstName} ${lastName}`; - const username = userProfile.username || "N/A"; - const location = userProfile.area || userProfile.state || "Unknown Region"; - // userProfile.career_situation might be "enhancing", "preparing", etc. - const careerSituation = userProfile.career_situation || "Not provided"; + const username = _userProfile.username || "N/A"; + const location = _userProfile.area || _userProfile.state || "Unknown Region"; + const careerSituation = _userProfile.career_situation || "Not provided"; // RIASEC let riasecText = "None"; @@ -1485,10 +1488,10 @@ I'm here to support you with personalized coaching—what would you like to focu // Possibly parse "career_priorities" if you need them let careerPriorities = "Not provided"; - if (userProfile.career_priorities) { + if (_userProfile.career_priorities) { // e.g. "career_priorities": "{\"interests\":\"Somewhat important\",\"meaning\":\"Somewhat important\",\"stability\":\"Very important\", ...}" try { - const cP = JSON.parse(userProfile.career_priorities); + const cP = JSON.parse(_userProfile.career_priorities); // Build a bullet string careerPriorities = Object.entries(cP).map(([k,v]) => `- ${k}: ${v}`).join("\n"); } catch(e) { @@ -1499,30 +1502,29 @@ I'm here to support you with personalized coaching—what would you like to focu // 2) CAREER SCENARIO // scenarioRow might have career_name, job_description, tasks // but you said sometimes you store them in scenarioRow or pass them in a separate param - const careerName = scenarioRow.career_name || "No career selected"; - const socCode = scenarioRow.soc_code || "N/A"; - const jobDescription = scenarioRow.job_description || "No jobDescription info"; - // scenarioRow.tasks might be an array - const tasksList = Array.isArray(scenarioRow.tasks) && scenarioRow.tasks.length - ? scenarioRow.tasks.join(", ") + const careerName = _scenarioRow.career_name || "No career selected"; + const socCode = _scenarioRow.soc_code || "N/A"; + const jobDescription = _scenarioRow.job_description || "No jobDescription info"; + const tasksList = Array.isArray(_scenarioRow.tasks) && _scenarioRow.tasks.length + ? _scenarioRow.tasks.join(", ") : "No tasks info"; // 3) FINANCIAL PROFILE // your actual JSON uses e.g. "current_salary", "additional_income" - const currentSalary = financialProfile.current_salary || 0; - const additionalIncome = financialProfile.additional_income || 0; - const monthlyExpenses = financialProfile.monthly_expenses || 0; - const monthlyDebt = financialProfile.monthly_debt_payments || 0; - const retirementSavings = financialProfile.retirement_savings || 0; - const emergencyFund = financialProfile.emergency_fund || 0; + const currentSalary = _financialProfile.current_salary || 0; + const additionalIncome = _financialProfile.additional_income || 0; + const monthlyExpenses = _financialProfile.monthly_expenses || 0; + const monthlyDebt = _financialProfile.monthly_debt_payments || 0; + const retirementSavings = _financialProfile.retirement_savings || 0; + const emergencyFund = _financialProfile.emergency_fund || 0; // 4) COLLEGE PROFILE // from your JSON: - const selectedProgram = collegeProfile.selected_program || "N/A"; - const enrollmentStatus = collegeProfile.college_enrollment_status || "Not enrolled"; - const creditHoursCompleted = parseFloat(collegeProfile.hours_completed) || 0; - const programLength = parseFloat(collegeProfile.program_length) || 0; - const expectedGraduation = collegeProfile.expected_graduation || "Unknown"; + const selectedProgram = _collegeProfile?.selected_program ?? "N/A"; + const enrollmentStatus = _collegeProfile?.college_enrollment_status ?? "Not enrolled"; + const creditHoursCompleted = parseFloat(_collegeProfile?.hours_completed ?? 0) || 0; + const programLength = parseFloat(_collegeProfile?.program_length ?? 0) || 0; + const expectedGraduation = _collegeProfile?.expected_graduation ?? "Unknown"; // 5) AI RISK // from aiRisk object @@ -1678,7 +1680,7 @@ let summaryText = buildUserSummary({ scenarioRow, userProfile, financialProfile, - collegeProfile, + collegeProfile: collegeProfile || {}, aiRisk }); @@ -2502,6 +2504,26 @@ app.post('/api/premium/career-profile/clone', authenticatePremiumUser, async (re [newId, ...values] ); + // 2.5) copy ALL college_profiles tied to the source scenario + const [cprows] = await pool.query( + 'SELECT * FROM college_profiles WHERE career_profile_id=? AND user_id=?', + [sourceId, req.id] + ); + for (const cp of cprows) { + const newCpId = uuidv4(); + const cols = Object.keys(cp).filter(k => !['id','created_at','updated_at'].includes(k)); + const vals = cols.map(k => + k === 'career_profile_id' ? newId : + k === 'user_id' ? req.id : + cp[k] + ); + await pool.query( + `INSERT INTO college_profiles (id, ${cols.join(',')}) + VALUES (?, ${cols.map(() => '?').join(',')})`, + [newCpId, ...vals] + ); + } + // 3) copy milestones/tasks/impacts (optional – mirrors UI wizard) const [mils] = await pool.query( 'SELECT * FROM milestones WHERE career_profile_id=? AND user_id=?', @@ -3631,11 +3653,11 @@ app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res const { careerProfileId } = req.query; try { const [rows] = await pool.query( - `SELECT * + `SELECT * FROM college_profiles WHERE user_id = ? AND career_profile_id = ? - ORDER BY created_at DESC + ORDER BY updated_at DESC LIMIT 1`, [req.id, careerProfileId] ); diff --git a/backend/utils/seedFaq.js b/backend/utils/seedFaq.js index d560617..c2517c0 100644 --- a/backend/utils/seedFaq.js +++ b/backend/utils/seedFaq.js @@ -10,7 +10,7 @@ import path from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootPath = path.resolve(__dirname, "../.."); -const env = process.env.NODE_ENV?.trim() || "development"; +const env = process.env.ENV_NAME?.trim() || "prod"; dotenv.config({ path: path.resolve(rootPath, `.env.${env}`) }); const faqPath = path.resolve(rootPath, "backend", "data", "faqs.json"); diff --git a/blob-report/report-b0bf72e.zip b/blob-report/report-b0bf72e.zip new file mode 100644 index 0000000..d0afceb Binary files /dev/null and b/blob-report/report-b0bf72e.zip differ diff --git a/nginx.conf b/nginx.conf index 16b13b5..2cdcd55 100644 --- a/nginx.conf +++ b/nginx.conf @@ -88,6 +88,11 @@ http { # ───── React static assets ───── root /usr/share/nginx/html; index index.html; + + # Redirect only the bare root to /signin (avoid booting shell at '/') + location = / { + return 302 /signin$is_args$args; + } location / { try_files $uri $uri/ /index.html; } diff --git a/playwright-report/data/1f58ad1d6df01d9b3572bd88f698664ae5f7f8fa.md b/playwright-report/data/1f58ad1d6df01d9b3572bd88f698664ae5f7f8fa.md new file mode 100644 index 0000000..cac970b --- /dev/null +++ b/playwright-report/data/1f58ad1d6df01d9b3572bd88f698664ae5f7f8fa.md @@ -0,0 +1,58 @@ +# Page snapshot + +```yaml +- generic [ref=e3]: + - banner [ref=e4]: + - heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5] + - navigation [ref=e6]: + - button "Find Your Career" [ref=e8] [cursor=pointer] + - button "Preparing & UpSkilling for Your Career" [ref=e10] [cursor=pointer] + - button "Enhancing Your Career(Premium)" [ref=e12] [cursor=pointer]: + - text: Enhancing Your Career + - generic [ref=e13] [cursor=pointer]: (Premium) + - button "Retirement Planning (beta)(Premium)" [ref=e15] [cursor=pointer]: + - text: Retirement Planning (beta) + - generic [ref=e16] [cursor=pointer]: (Premium) + - button "Profile" [ref=e18] [cursor=pointer] + - generic [ref=e19]: + - button "Upgrade to Premium" [ref=e20] [cursor=pointer] + - button "Support" [ref=e21] [cursor=pointer] + - button "Logout" [ref=e22] [cursor=pointer] + - main [ref=e23]: + - generic [ref=e24]: + - heading "Verify your account" [level=1] [ref=e25] + - paragraph [ref=e26]: You must verify before using AptivaAI. + - generic [ref=e27]: + - heading "Email verification" [level=2] [ref=e28] + - generic [ref=e29]: + - button "Send email" [ref=e30] [cursor=pointer] + - textbox "Paste token" [ref=e31] + - button "Confirm" [ref=e32] [cursor=pointer] + - generic [ref=e33]: + - heading "Phone verification (optional)" [level=2] [ref=e34] + - generic [ref=e35]: + - checkbox "By requesting a code, you agree to receive one-time texts from AptivaAI for account verification and security alerts. Message frequency varies. Msg & data rates may apply. Reply STOP to opt out, HELP for help. Consent is not a condition of purchase or service. See the SMS Terms, Privacy Policy, and Terms." [ref=e36] + - generic [ref=e37]: + - text: By requesting a code, you agree to receive one-time texts from AptivaAI for account verification and security alerts. Message frequency varies. Msg & data rates may apply. Reply + - strong [ref=e38]: STOP + - text: to opt out, + - strong [ref=e39]: HELP + - text: for help. Consent is not a condition of purchase or service. See the + - link "SMS Terms" [ref=e40] [cursor=pointer]: + - /url: /sms + - text: "," + - link "Privacy Policy" [ref=e41] [cursor=pointer]: + - /url: /legal/privacy + - text: ", and" + - link "Terms" [ref=e42] [cursor=pointer]: + - /url: /legal/terms + - text: . + - generic [ref=e43]: + - textbox "+1XXXXXXXXXX" [ref=e44]: "1" + - button "Send code" [disabled] [ref=e45] + - generic [ref=e46]: + - textbox "6-digit code" [ref=e47] + - button "Confirm" [disabled] [ref=e48] + - button "Open chat" [ref=e49] [cursor=pointer]: + - img [ref=e50] [cursor=pointer] +``` \ No newline at end of file diff --git a/playwright-report/data/339593b3c0c996cb824cb405aeec283e95c3982f.webm b/playwright-report/data/339593b3c0c996cb824cb405aeec283e95c3982f.webm new file mode 100644 index 0000000..1c7239b Binary files /dev/null and b/playwright-report/data/339593b3c0c996cb824cb405aeec283e95c3982f.webm differ diff --git a/playwright-report/data/f2ac6496f99b830e4aee213d29a4c68f3c4452da.png b/playwright-report/data/f2ac6496f99b830e4aee213d29a4c68f3c4452da.png new file mode 100644 index 0000000..1a17993 Binary files /dev/null and b/playwright-report/data/f2ac6496f99b830e4aee213d29a4c68f3c4452da.png differ diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..d6c8562 --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,76 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.mjs b/playwright.config.mjs index 0742ebd..410ad6b 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -23,5 +23,7 @@ export default defineConfig({ use: { ...devices['Desktop Edge'] }, }, ], + // Perform a single real-UI login before the test run and write storage state. + globalSetup: '/home/jcoakley/aptiva-dev1-app/tests/e2e/global-setup.mjs', }); diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index fe85d3c..b934adb 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -337,7 +337,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) { const { careerId } = useParams(); const location = useLocation(); - const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'MONTE_CARLO' + const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'RANDOM' const [flatAnnualRate, setFlatAnnualRate] = useState(0.06); const [randomRangeMin, setRandomRangeMin] = useState(-0.02); const [randomRangeMax, setRandomRangeMax] = useState(0.02); @@ -1665,7 +1665,7 @@ const handleMilestonesCreated = useCallback( > - + {/* (E2) If FLAT => show the annual rate */} @@ -1682,8 +1682,8 @@ const handleMilestonesCreated = useCallback( )} - {/* (E3) If MONTE_CARLO => show the random range */} - {interestStrategy === 'MONTE_CARLO' && ( + {/* (E3) If RANDOM => show the random range */} + {interestStrategy === 'RANDOM' && (
s.id === chosenId); + // optimistic set so UI updates immediately setLocalScenario(found || null); + // hydrate with full scenario so KPIs/sim have all fields + if (chosenId) { + try { + const res = await authFetch(`/api/premium/career-profile/${chosenId}`); + if (res.ok) { + const full = await res.json(); + setLocalScenario((prev) => ({ ...(prev || {}), ...(full || {}) })); + } + } catch (err) { + console.error('hydrate scenario on select failed:', err); + } + } } // ------------------------------------------------------------- @@ -203,6 +216,16 @@ export default function ScenarioContainer({ fetchMilestones(); }, [fetchMilestones]); + + // Helper: find a "Retirement" milestone date (YYYY-MM-DD) if present + function retirementDateFromMilestone() { + if (!Array.isArray(milestones) || !milestones.length) return null; + const ms = milestones + .filter(m => m?.date && typeof m.title === 'string' && m.title.trim().toLowerCase() === 'retirement') + .sort((a, b) => new Date(a.date) - new Date(b.date)); + return ms[0]?.date || null; + } + // ------------------------------------------------------------- // 4) Simulation // ------------------------------------------------------------- @@ -231,18 +254,26 @@ export default function ScenarioContainer({ // Gather milestoneImpacts const allImpacts = Object.values(impactsByMilestone).flat(); // safe even if [] - const simYears = parseInt(simulationYearsInput, 10) || 20; + const simYears = parseInt(simulationYearsInput, 10) || 20; const simYearsUI = Math.max(1, parseInt(simulationYearsInput, 10) || 20); - const yearsUntilRet = localScenario.retirement_start_date - ? Math.ceil( - moment(localScenario.retirement_start_date) - .startOf('month') - .diff(moment().startOf('month'), 'months') / 12 - ) - : 0; + // Derive effective retirement start date: scenario field → milestone → projected_end_date+1mo + const mRet = retirementDateFromMilestone(); + const effectiveRetStart = + localScenario.retirement_start_date + || (mRet ? moment(mRet).startOf('month').format('YYYY-MM-DD') : null) + || (localScenario.projected_end_date + ? moment(localScenario.projected_end_date).startOf('month').add(1, 'month').format('YYYY-MM-DD') + : null); - const simYearsEngine = Math.max(simYearsUI, yearsUntilRet + 1); + const yearsUntilRet = effectiveRetStart + ? Math.ceil( + moment(effectiveRetStart).startOf('month') + .diff(moment().startOf('month'), 'months') / 12 + ) + : 0; + + const simYearsEngine = Math.max(simYearsUI, yearsUntilRet + 1); // scenario overrides const scenarioOverrides = { @@ -311,14 +342,7 @@ export default function ScenarioContainer({ surplusEmergencyAllocation: scenarioOverrides.surplusEmergencyAllocation, surplusRetirementAllocation: scenarioOverrides.surplusRetirementAllocation, additionalIncome: scenarioOverrides.additionalIncome, - retirement_start_date: - localScenario.retirement_start_date // user picked - || (localScenario.projected_end_date // often set for college scenarios - ? moment(localScenario.projected_end_date) - .startOf('month') - .add(1,'month') // start drawing a month later - .format('YYYY-MM-DD') - : null), + retirement_start_date: effectiveRetStart, desired_retirement_income_monthly: parseScenarioOverride( @@ -844,16 +868,53 @@ export default function ScenarioContainer({ // ------------------------------------------------------------- // 9) Scenario Edit // ------------------------------------------------------------- - function handleEditScenario() { + async function handleEditScenario() { + // Ensure modal gets a fully populated scenario (incl. desired_retirement_income_monthly) + if (localScenario?.id) { + try { + const res = await authFetch(`/api/premium/career-profile/${localScenario.id}`); + if (res.ok) { + const full = await res.json(); + setLocalScenario((prev) => ({ ...(prev || {}), ...(full || {}) })); + } + } catch (e) { + console.error('load scenario for edit', e); + // non-fatal: open modal with existing fields + } + } setShowScenarioModal(true); } - function handleScenarioSave(updated) { - console.log('TODO => Save scenario', updated); - setShowScenarioModal(false); + + async function handleScenarioSave(updated) { + if (!localScenario?.id) return; + try { + const res = await authFetch(`/api/premium/career-profile/${localScenario.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updated) + }); + if (!res.ok) { + const txt = await res.text(); + alert(txt || 'Failed to save scenario'); + return; + } + const saved = await res.json(); + // Keep local view in sync; prefer the user's edited values if API didn't echo them back + setLocalScenario((prev) => ({ ...(prev || {}), ...(saved || {}), ...(updated || {}) })); + // Refresh milestones if dates/flags shifted any annotations + await fetchMilestones(); + } catch (e) { + console.error('Scenario save error:', e); + alert('Error saving scenario'); + } finally { + setShowScenarioModal(false); + } } + function handleDeleteScenario() { if (localScenario) onRemove(localScenario.id); } + function handleCloneScenario() { if (localScenario) onClone(localScenario); } @@ -863,7 +924,7 @@ export default function ScenarioContainer({ // ------------------------------------------------------------- return (
onSelect(localScenario.id)} + onClick={() => { if (typeof onSelect === 'function' && localScenario?.id) onSelect(localScenario.id); }} className="w-full md:max-w-md border p-3 pb-4 rounded bg-white hover:shadow transition-shadow" > @@ -883,6 +944,13 @@ return ( {localScenario && ( <> + {/* snapshot note */} + {collegeProfile && ( +

+ Using your most recently updated college plan + {collegeProfile.updated_at ? ` (${moment(collegeProfile.updated_at).format('YYYY-MM')})` : ''}. +

+ )} {/* ───────────── Title ───────────── */}

- + {interestStrategy === 'FLAT' && ( @@ -929,7 +997,7 @@ return ( )} - {interestStrategy === 'MONTE_CARLO' && ( + {interestStrategy === 'RANDOM' && (

- - {(!localScenario?.retirement_start_date || + {(!(localScenario?.retirement_start_date || retirementDateFromMilestone()) || !localScenario?.desired_retirement_income_monthly) && (

@@ -982,8 +1049,16 @@ return (

{/* Nest-egg */}

Nest Egg

-

{usd(retireBalAtMilestone)}

- + {(() => { + // Prefer TOTAL savings at the retirement start month; fall back to retirement-only + const retStart = localScenario?.retirement_start_date + ? moment(localScenario.retirement_start_date).startOf('month').format('YYYY-MM') + : null; + const idx = retStart ? chartLabels.indexOf(retStart) : -1; + const totalAtRet = idx >= 0 ? projectionData[idx]?.totalSavings : null; + const nestEgg = (typeof totalAtRet === 'number' ? totalAtRet : retireBalAtMilestone) || 0; + return

{usd(nestEgg)}

; + })()} {/* Money lasts */}

Money Lasts

diff --git a/src/components/SignUp.js b/src/components/SignUp.js index 37b5df6..c86752c 100644 --- a/src/components/SignUp.js +++ b/src/components/SignUp.js @@ -90,28 +90,6 @@ function SignUp() { { name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' }, ]; - useEffect(() => { - const fetchAreas = async () => { - if (!state) { - setAreas([]); - return; - } - - setLoadingAreas(true); // Start loading - try { - const res = await fetch(`/api/areas?state=${state}`); - const data = await res.json(); - setAreas(data.areas || []); - } catch (err) { - console.error('Error fetching areas:', err); - setAreas([]); - } finally { - setLoadingAreas(false); // Done loading - } - }; - - fetchAreas(); - }, [state]); const validateFields = async () => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; @@ -241,59 +219,35 @@ const handleSituationConfirm = async () => { }; useEffect(() => { - // reset UI - setAreasErr(''); - if (!state) { setAreas([]); return; } + setAreasErr(''); + if (!state) { setAreas([]); setArea(''); return; } - // cached? instant - if (areasCacheRef.current.has(state)) { - setAreas(areasCacheRef.current.get(state)); - return; + // cancel previous request if any + if (inflightRef.current) inflightRef.current.abort(); + const controller = new AbortController(); + inflightRef.current = controller; + + setLoadingAreas(true); + (async () => { + try { + const res = await fetch(`/api/areas?state=${encodeURIComponent(state)}`, { signal: controller.signal }); + if (!res.ok) throw new Error('bad_response'); + const data = await res.json(); + const list = Array.isArray(data?.areas) ? data.areas : []; + setAreas(list); + if (area && !list.includes(area)) setArea(''); + } catch (err) { + if (controller.signal.aborted) return; // superseded by a newer request + setAreas([]); + setAreasErr('Could not load Areas. Please try again.'); + } finally { + if (inflightRef.current === controller) inflightRef.current = null; + setLoadingAreas(false); } + })(); - // 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 () => { controller.abort(); }; +}, [state]); return (

@@ -409,6 +363,7 @@ return (