Added next-steps provided by AI to convert to milestones, fixed simulation. Where Am I Now should be finished.

This commit is contained in:
Josh 2025-05-27 18:47:44 +00:00
parent 0ea62392dd
commit 961d0e5fd4
5 changed files with 629 additions and 202 deletions

View File

@ -146,6 +146,10 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date,
college_enrollment_status,
currently_working,
// The new field:
career_goals,
// planned fields
planned_monthly_expenses,
planned_monthly_debt_payments,
planned_monthly_retirement_contribution,
@ -162,6 +166,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
try {
const newId = uuidv4();
// 1) Insert includes career_goals
const sql = `
INSERT INTO career_profiles (
id,
@ -173,6 +178,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date,
college_enrollment_status,
currently_working,
career_goals, -- ADD THIS
planned_monthly_expenses,
planned_monthly_debt_payments,
planned_monthly_retirement_contribution,
@ -181,13 +187,14 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
planned_surplus_retirement_pct,
planned_additional_income
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
start_date = VALUES(start_date),
projected_end_date = VALUES(projected_end_date),
college_enrollment_status = VALUES(college_enrollment_status),
currently_working = VALUES(currently_working),
career_goals = VALUES(career_goals), -- ADD THIS
planned_monthly_expenses = VALUES(planned_monthly_expenses),
planned_monthly_debt_payments = VALUES(planned_monthly_debt_payments),
planned_monthly_retirement_contribution = VALUES(planned_monthly_retirement_contribution),
@ -208,6 +215,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date || null,
college_enrollment_status || null,
currently_working || null,
career_goals || null, // pass career_goals here
planned_monthly_expenses ?? null,
planned_monthly_debt_payments ?? null,
planned_monthly_retirement_contribution ?? null,
@ -218,11 +226,12 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
]);
// re-fetch to confirm ID
const [rows] = await pool.query(`
SELECT id
FROM career_profiles
WHERE id = ?
`, [newId]);
const [rows] = await pool.query(
`SELECT id
FROM career_profiles
WHERE id = ?`,
[newId]
);
return res.status(200).json({
message: 'Career profile upserted.',
@ -234,6 +243,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
}
});
// DELETE a career profile (scenario) by ID
app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, async (req, res) => {
const { careerProfileId } = req.params;
@ -302,6 +312,269 @@ app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUs
}
});
/***************************************************
AI - NEXT STEPS ENDPOINT (with date constraints,
ignoring scenarioRow.start_date)
****************************************************/
app.post('/api/premium/ai/next-steps', authenticatePremiumUser, async (req, res) => {
try {
// 1) Gather user data from request
const {
userProfile = {},
scenarioRow = {},
financialProfile = {},
collegeProfile = {}
} = req.body;
// 2) Build a summary for ChatGPT
// (We'll ignore scenarioRow.start_date in the prompt)
const summaryText = buildUserSummary({
userProfile,
scenarioRow,
financialProfile,
collegeProfile
});
// 3) Dynamically compute "today's" date and future cutoffs
const now = new Date();
const isoToday = now.toISOString().slice(0, 10); // e.g. "2025-06-01"
// short-term = within 6 months
const shortTermLimit = new Date(now);
shortTermLimit.setMonth(shortTermLimit.getMonth() + 6);
const isoShortTermLimit = shortTermLimit.toISOString().slice(0, 10);
// long-term = 1-3 years
const oneYearFromNow = new Date(now);
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
const isoOneYearFromNow = oneYearFromNow.toISOString().slice(0, 10);
const threeYearsFromNow = new Date(now);
threeYearsFromNow.setFullYear(threeYearsFromNow.getFullYear() + 3);
const isoThreeYearsFromNow = threeYearsFromNow.toISOString().slice(0, 10);
// 4) Construct ChatGPT messages
const messages = [
{
role: 'system',
content: `
You are an expert career & financial coach.
Today's date: ${isoToday}.
Short-term means any date up to ${isoShortTermLimit} (within 6 months).
Long-term means a date between ${isoOneYearFromNow} and ${isoThreeYearsFromNow} (1-3 years).
All milestone dates must be strictly >= ${isoToday}. Titles must be <= 5 words.
Respond ONLY in the requested JSON format.`
},
{
role: 'user',
content: `
Here is the user's current situation:
${summaryText}
Please provide exactly 3 short-term (within 6 months) and 2 long-term (13 years) milestones.
Each milestone must have:
- "title" (up to 5 words)
- "date" in YYYY-MM-DD format (>= ${isoToday})
- "description" (1-2 sentences)
Return ONLY a JSON array, no extra text:
[
{
"title": "string",
"date": "YYYY-MM-DD",
"description": "string"
},
...
]`
}
];
// 5) Call OpenAI (ignoring scenarioRow.start_date for date logic)
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo', // or 'gpt-4'
messages,
temperature: 0.7,
max_tokens: 600
});
// 6) Extract raw text
const aiAdvice = completion?.choices?.[0]?.message?.content?.trim() || 'No response';
res.json({ recommendations: aiAdvice });
} catch (err) {
console.error('Error in /api/premium/ai/next-steps =>', err);
res.status(500).json({ error: 'Failed to get AI next steps.' });
}
});
/**
* Helper that converts user data into a concise text summary.
* This can still mention scenarioRow, but we do NOT feed
* scenarioRow.start_date to ChatGPT for future date calculations.
*/
function buildUserSummary({
userProfile = {},
scenarioRow = {},
financialProfile = {},
collegeProfile = {}
}) {
// Provide a short multiline string about the user's finances, goals, etc.
// but avoid referencing scenarioRow.start_date
// e.g.:
const location = `${userProfile.state || 'Unknown State'}, ${userProfile.area || 'N/A'}`;
const careerName = scenarioRow.career_name || 'Unknown';
const careerGoals = scenarioRow.career_goals || 'No goals specified';
const status = scenarioRow.status || 'planned';
const currentlyWorking = scenarioRow.currently_working || 'no';
const currentSalary = financialProfile.current_salary || 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;
// And similarly for collegeProfile if needed, ignoring start_date
return `
User Location: ${location}
Career Name: ${careerName}
Career Goals: ${careerGoals}
Career Status: ${status}
Currently Working: ${currentlyWorking}
Financial:
- Salary: \$${currentSalary}
- Monthly Expenses: \$${monthlyExpenses}
- Monthly Debt: \$${monthlyDebt}
- Retirement Savings: \$${retirementSavings}
- Emergency Fund: \$${emergencyFund}
`.trim();
}
/***************************************************
AI MILESTONE CONVERSION ENDPOINT
****************************************************/
app.post('/api/premium/milestone/convert-ai', authenticatePremiumUser, async (req, res) => {
try {
// The client passes us an array of milestones, e.g.:
// { milestones: [ { title, date, description, tasks, impacts }, ... ] }
const { milestones } = req.body;
const { careerProfileId } = req.query;
// or from body, if you prefer:
// const { careerProfileId } = req.body;
if (!careerProfileId) {
return res.status(400).json({ error: 'careerProfileId is required.' });
}
if (!Array.isArray(milestones)) {
return res.status(400).json({ error: 'Expected milestones array in body.' });
}
const newMilestones = [];
for (const m of milestones) {
// Required fields for your DB:
// title, date, career_profile_id
if (!m.title || !m.date) {
return res.status(400).json({
error: 'Missing required milestone fields (title/date).',
details: m
});
}
// create the milestone row
const id = uuidv4();
await pool.query(`
INSERT INTO milestones (
id,
user_id,
career_profile_id,
title,
description,
date,
progress,
status,
is_universal
) VALUES (?, ?, ?, ?, ?, ?, 0, 'planned', 0)
`, [
id,
req.id,
careerProfileId,
m.title,
m.description || '',
m.date
]);
// If the user also sent tasks in m.tasks:
if (Array.isArray(m.tasks)) {
for (const t of m.tasks) {
const taskId = uuidv4();
await pool.query(`
INSERT INTO tasks (
id,
milestone_id,
user_id,
title,
description,
due_date,
status
) VALUES (?, ?, ?, ?, ?, ?, 'not_started')
`, [
taskId,
id,
req.id,
t.title || 'Task',
t.description || '',
t.due_date || null
]);
}
}
// If the user also sent impacts in m.impacts:
if (Array.isArray(m.impacts)) {
for (const imp of m.impacts) {
const impactId = uuidv4();
await pool.query(`
INSERT INTO milestone_impacts (
id,
milestone_id,
impact_type,
direction,
amount,
start_date,
end_date
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
impactId,
id,
imp.impact_type || 'none',
imp.direction || 'add',
imp.amount || 0,
imp.start_date || null,
imp.end_date || null
]);
}
}
newMilestones.push({
id,
title: m.title,
description: m.description || '',
date: m.date,
tasks: m.tasks || [],
impacts: m.impacts || []
});
}
return res.json({ createdMilestones: newMilestones });
} catch (err) {
console.error('Error converting AI milestones:', err);
return res.status(500).json({ error: 'Failed to convert AI milestones.' });
}
});
/* ------------------------------------------------------------------
MILESTONE ENDPOINTS
------------------------------------------------------------------ */
@ -316,18 +589,16 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
const createdMilestones = [];
for (const m of body.milestones) {
const {
milestone_type,
title,
description,
date,
career_profile_id,
progress,
status,
new_salary,
is_universal
} = m;
if (!milestone_type || !title || !date || !career_profile_id) {
if (!title || !date || !career_profile_id) {
return res.status(400).json({
error: 'One or more milestones missing required fields',
details: m
@ -340,27 +611,23 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
id,
user_id,
career_profile_id,
milestone_type,
title,
description,
date,
progress,
status,
new_salary,
is_universal
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
req.id,
career_profile_id,
milestone_type,
title,
description || '',
date,
progress || 0,
status || 'planned',
new_salary || null,
is_universal ? 1 : 0
]);
@ -368,13 +635,11 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
id,
user_id: req.id,
career_profile_id,
milestone_type,
title,
description: description || '',
date,
progress: progress || 0,
status: status || 'planned',
new_salary: new_salary || null,
is_universal: is_universal ? 1 : 0,
tasks: []
});
@ -384,21 +649,19 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
// single milestone
const {
milestone_type,
title,
description,
date,
career_profile_id,
progress,
status,
new_salary,
is_universal
} = body;
if (!milestone_type || !title || !date || !career_profile_id) {
if ( !title || !date || !career_profile_id) {
return res.status(400).json({
error: 'Missing required fields',
details: { milestone_type, title, date, career_profile_id }
details: { title, date, career_profile_id }
});
}
@ -408,27 +671,23 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
id,
user_id,
career_profile_id,
milestone_type,
title,
description,
date,
progress,
status,
new_salary,
is_universal
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
req.id,
career_profile_id,
milestone_type,
title,
description || '',
date,
progress || 0,
status || 'planned',
new_salary || null,
is_universal ? 1 : 0
]);
@ -436,13 +695,11 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
id,
user_id: req.id,
career_profile_id,
milestone_type,
title,
description: description || '',
date,
progress: progress || 0,
status: status || 'planned',
new_salary: new_salary || null,
is_universal: is_universal ? 1 : 0,
tasks: []
};
@ -458,14 +715,12 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
try {
const { milestoneId } = req.params;
const {
milestone_type,
title,
description,
date,
career_profile_id,
progress,
status,
new_salary,
is_universal
} = req.body;
@ -481,40 +736,34 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
}
const row = existing[0];
const finalMilestoneType = milestone_type || row.milestone_type;
const finalTitle = title || row.title;
const finalDesc = description || row.description;
const finalDate = date || row.date;
const finalCareerProfileId = career_profile_id || row.career_profile_id;
const finalProgress = progress != null ? progress : row.progress;
const finalStatus = status || row.status;
const finalSalary = new_salary != null ? new_salary : row.new_salary;
const finalIsUniversal = is_universal != null ? (is_universal ? 1 : 0) : row.is_universal;
await pool.query(`
UPDATE milestones
SET
milestone_type = ?,
title = ?,
description = ?,
date = ?,
career_profile_id = ?,
progress = ?,
status = ?,
new_salary = ?,
is_universal = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND user_id = ?
`, [
finalMilestoneType,
finalTitle,
finalDesc,
finalDate,
finalCareerProfileId,
finalProgress,
finalStatus,
finalSalary,
finalIsUniversal,
milestoneId,
req.id
@ -683,28 +932,24 @@ app.post('/api/premium/milestone/copy', authenticatePremiumUser, async (req, res
id,
user_id,
career_profile_id,
milestone_type,
title,
description,
date,
progress,
status,
new_salary,
is_universal,
origin_milestone_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
newMilestoneId,
req.id,
scenarioId,
original.milestone_type,
original.title,
original.description,
original.date,
original.progress,
original.status,
original.new_salary,
1,
originId
]);
@ -1190,6 +1435,7 @@ Milestone Requirements:
1. Provide exactly 3 SHORT-TERM milestones (within next 1-2 years).
- Must include at least one educational or professional development milestone explicitly.
- Do NOT exclusively focus on financial aspects.
2. Provide exactly 2 LONG-TERM milestones (3+ years out).
- Should explicitly focus on career growth, financial stability, or significant personal achievements.

View File

@ -93,17 +93,6 @@ export default function MilestoneTimeline({
console.warn('No milestones in response:', data);
return;
}
// Separate them by type
const categorized = { Career: [], Financial: [] };
data.milestones.forEach((m) => {
if (categorized[m.milestone_type]) {
categorized[m.milestone_type].push(m);
} else {
// If there's a random type, log or store somewhere else
console.warn(`Unknown milestone type: ${m.milestone_type}`);
}
});
setMilestones(categorized);
} catch (err) {
console.error('Failed to fetch milestones:', err);
}
@ -150,7 +139,6 @@ export default function MilestoneTimeline({
description: m.description || '',
date: m.date || '',
progress: m.progress || 0,
newSalary: m.new_salary || '',
impacts: fetchedImpacts.map((imp) => ({
id: imp.id,
impact_type: imp.impact_type || 'ONE_TIME',
@ -188,10 +176,6 @@ export default function MilestoneTimeline({
career_profile_id: careerProfileId,
progress: newMilestone.progress,
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
new_salary:
activeView === 'Financial' && newMilestone.newSalary
? parseFloat(newMilestone.newSalary)
: null,
is_universal: newMilestone.isUniversal || 0
};

View File

@ -21,16 +21,15 @@ import { getFullStateName } from '../utils/stateUtils.js';
import { Button } from './ui/button.js';
import CareerSelectDropdown from './CareerSelectDropdown.js';
import CareerSearch from './CareerSearch.js';
import MilestoneTimeline from './MilestoneTimeline.js';
import ScenarioEditModal from './ScenarioEditModal.js';
// If you need AI suggestions in the future:
// import AISuggestedMilestones from './AISuggestedMilestones.js';
import './MilestoneTracker.css';
import './MilestoneTimeline.css';
// --------------
// Register ChartJS Plugins
// --------------
ChartJS.register(
LineElement,
BarElement,
@ -43,17 +42,14 @@ ChartJS.register(
annotationPlugin
);
// ----------------------
// 1) Remove decimals from SOC code
// ----------------------
// --------------
// Helper Functions
// --------------
function stripSocCode(fullSoc) {
if (!fullSoc) return '';
return fullSoc.split('.')[0];
}
// ----------------------
// 2) Salary Gauge
// ----------------------
function getRelativePosition(userSal, p10, p90) {
if (!p10 || !p90) return 0;
if (userSal < p10) return 0;
@ -61,6 +57,7 @@ function getRelativePosition(userSal, p10, p90) {
return (userSal - p10) / (p90 - p10);
}
// A simple gauge for the users salary vs. percentiles
function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
if (!percentileRow) return null;
@ -91,6 +88,7 @@ function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
marginBottom: '8px'
}}
>
{/* Median Marker */}
<div
style={{
position: 'absolute',
@ -120,6 +118,7 @@ function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
</div>
</div>
{/* User Salary Marker */}
<div
style={{
position: 'absolute',
@ -153,9 +152,6 @@ function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
);
}
// ----------------------
// 3) Economic Bar
// ----------------------
function EconomicProjectionsBar({ data }) {
if (!data) return null;
const {
@ -239,16 +235,42 @@ function getYearsInCareer(startDateString) {
return Math.floor(diffYears).toString();
}
// ----------------------
// 4) MilestoneTracker
// ----------------------
/**
* parseAiJson
* If ChatGPT returns a fenced code block like:
* ```json
* [ { ... }, ... ]
* ```
* we extract that JSON. Otherwise, we parse the raw string.
*/
function parseAiJson(rawText) {
const fencedRegex = /```json\s*([\s\S]*?)\s*```/i;
const match = rawText.match(fencedRegex);
if (match) {
const jsonStr = match[1].trim();
const arr = JSON.parse(jsonStr);
// Add an "id" for each milestone
arr.forEach((m) => {
m.id = crypto.randomUUID();
});
return arr;
} else {
// fallback if no fences
const arr = JSON.parse(rawText);
arr.forEach((m) => {
m.id = crypto.randomUUID();
});
return arr;
}
}
export default function MilestoneTracker({ selectedCareer: initialCareer }) {
const location = useLocation();
const apiURL = process.env.REACT_APP_API_URL;
// Basic states
const [userProfile, setUserProfile] = useState(null);
const [financialProfile, setFinancialProfile] = useState(null);
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
const [existingCareerProfiles, setExistingCareerProfiles] = useState([]);
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
@ -260,15 +282,22 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
const [salaryData, setSalaryData] = useState(null);
const [economicProjections, setEconomicProjections] = useState(null);
// Milestones & Projection
const [scenarioMilestones, setScenarioMilestones] = useState([]);
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
// Config
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
const [showEditModal, setShowEditModal] = useState(false);
// AI
const [aiLoading, setAiLoading] = useState(false);
const [recommendations, setRecommendations] = useState([]); // parsed array
const [selectedIds, setSelectedIds] = useState([]); // which rec IDs are checked
const {
projectionData: initProjData = [],
loanPayoffMonth: initLoanMonth = null
@ -276,22 +305,22 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
// 1) Fetch user + financial
useEffect(() => {
const fetchUser = async () => {
async function fetchUser() {
try {
const r = await authFetch('/api/user-profile');
if (r.ok) setUserProfile(await r.json());
} catch (err) {
console.error('Error user-profile =>', err);
}
};
const fetchFin = async () => {
}
async function fetchFin() {
try {
const r = await authFetch(`${apiURL}/premium/financial-profile`);
if (r.ok) setFinancialProfile(await r.json());
} catch (err) {
console.error('Error financial =>', err);
}
};
}
fetchUser();
fetchFin();
}, [apiURL]);
@ -313,7 +342,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
// 3) fetch users career-profiles
useEffect(() => {
const fetchProfiles = async () => {
async function fetchProfiles() {
const r = await authFetch(`${apiURL}/premium/career-profile/all`);
if (!r || !r.ok) return;
const d = await r.json();
@ -343,7 +372,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
}
}
}
};
}
fetchProfiles();
}, [apiURL, location.state]);
@ -357,19 +386,19 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
}
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
const fetchScenario = async () => {
async function fetchScenario() {
const s = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`);
if (s.ok) setScenarioRow(await s.json());
};
const fetchCollege = async () => {
}
async function fetchCollege() {
const c = await authFetch(`${apiURL}/premium/college-profile?careerProfileId=${careerProfileId}`);
if (c.ok) setCollegeProfile(await c.json());
};
}
fetchScenario();
fetchCollege();
}, [careerProfileId, apiURL]);
// 5) from scenarioRow.career_name => find the full SOC => strip
// 5) from scenarioRow => find the full SOC => strip
useEffect(() => {
if (!scenarioRow?.career_name || !masterCareerRatings.length) {
setStrippedSocCode(null);
@ -395,12 +424,8 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
}
(async () => {
try {
const qs = new URLSearchParams({
socCode: strippedSocCode,
area: userArea
}).toString();
const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea }).toString();
const url = `${apiURL}/salary?${qs}`;
console.log('[Salary fetch =>]', url);
const r = await fetch(url);
if (!r.ok) {
console.error('[Salary fetch non-200 =>]', r.status);
@ -408,7 +433,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
return;
}
const dd = await r.json();
console.log('[Salary success =>]', dd);
setSalaryData(dd);
} catch (err) {
console.error('[Salary fetch error]', err);
@ -426,7 +450,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
(async () => {
const qs = new URLSearchParams({ state: userState }).toString();
const econUrl = `${apiURL}/projections/${strippedSocCode}?${qs}`;
console.log('[Econ fetch =>]', econUrl);
try {
const r = await authFetch(econUrl);
if (!r.ok) {
@ -435,7 +458,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
return;
}
const econData = await r.json();
console.log('[Econ success =>]', econData);
setEconomicProjections(econData);
} catch (err) {
console.error('[Econ fetch error]', err);
@ -445,7 +467,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
}, [strippedSocCode, userState, apiURL]);
// 8) Build financial projection
const buildProjection = async () => {
async function buildProjection() {
try {
const milUrl = `${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`;
const mr = await authFetch(milUrl);
@ -457,16 +479,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
const allMilestones = md.milestones || [];
setScenarioMilestones(allMilestones);
function parseScenarioOverride(overrideVal, fallbackVal) {
// If the DB field is NULL => means user never entered anything
if (overrideVal === null) {
return fallbackVal;
}
// Otherwise user typed a number, even if it's "0"
return parseFloatOrZero(overrideVal, fallbackVal);
}
// fetch impacts
const imPromises = allMilestones.map((m) =>
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
.then((r) => (r.ok ? r.json() : null))
@ -495,38 +508,44 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
extraCashRetirementPct: parseFloatOrZero(f.extra_cash_retirement_pct, 50)
};
function parseScenarioOverride(overrideVal, fallbackVal) {
if (overrideVal === null) {
return fallbackVal;
}
return parseFloatOrZero(overrideVal, fallbackVal);
}
const s = scenarioRow;
const scenarioOverrides = {
monthlyExpenses: parseScenarioOverride(
s.planned_monthly_expenses,
financialBase.monthlyExpenses
),
monthlyDebtPayments: parseScenarioOverride(
s.planned_monthly_debt_payments,
financialBase.monthlyDebtPayments
),
monthlyRetirementContribution: parseScenarioOverride(
s.planned_monthly_retirement_contribution,
financialBase.retirementContribution
),
monthlyEmergencyContribution: parseScenarioOverride(
s.planned_monthly_emergency_contribution,
financialBase.emergencyContribution
),
surplusEmergencyAllocation: parseScenarioOverride(
s.planned_surplus_emergency_pct,
financialBase.extraCashEmergencyPct
),
surplusRetirementAllocation: parseScenarioOverride(
s.planned_surplus_retirement_pct,
financialBase.extraCashRetirementPct
),
additionalIncome: parseScenarioOverride(
s.planned_additional_income,
financialBase.additionalIncome
),
};
monthlyExpenses: parseScenarioOverride(
s.planned_monthly_expenses,
financialBase.monthlyExpenses
),
monthlyDebtPayments: parseScenarioOverride(
s.planned_monthly_debt_payments,
financialBase.monthlyDebtPayments
),
monthlyRetirementContribution: parseScenarioOverride(
s.planned_monthly_retirement_contribution,
financialBase.retirementContribution
),
monthlyEmergencyContribution: parseScenarioOverride(
s.planned_monthly_emergency_contribution,
financialBase.emergencyContribution
),
surplusEmergencyAllocation: parseScenarioOverride(
s.planned_surplus_emergency_pct,
financialBase.extraCashEmergencyPct
),
surplusRetirementAllocation: parseScenarioOverride(
s.planned_surplus_retirement_pct,
financialBase.extraCashRetirementPct
),
additionalIncome: parseScenarioOverride(
s.planned_additional_income,
financialBase.additionalIncome
)
};
const c = collegeProfile;
const collegeData = {
@ -587,7 +606,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
const { projectionData: pData, loanPaidOffMonth } =
simulateFinancialProjection(mergedProfile);
// Build "cumulativeNetSavings" ourselves, plus each row has .retirementSavings and .emergencySavings
let cumu = mergedProfile.emergencySavings || 0;
const finalData = pData.map((mo) => {
cumu += mo.netSavings || 0;
@ -599,27 +617,19 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
} catch (err) {
console.error('Error in scenario simulation =>', err);
}
};
}
useEffect(() => {
if (!financialProfile || !scenarioRow || !collegeProfile) return;
buildProjection();
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, apiURL, simulationYears]);
// Handlers
const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
const handleSimulationYearsBlur = () => {
if (!simulationYearsInput.trim()) setSimulationYearsInput('20');
};
// -- Annotations --
// 1) Milestone lines
// Build chart datasets / annotations
const milestoneAnnotationLines = {};
scenarioMilestones.forEach((m) => {
if (!m.date) return;
const d = new Date(m.date);
if (isNaN(d)) return;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const short = `${yyyy}-${mm}`;
@ -640,10 +650,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
};
});
// 2) Check if there's ever a positive loan balance
const hasStudentLoan = projectionData.some((p) => p.loanBalance > 0);
// 3) Conditionally add the loan payoff annotation
const annotationConfig = {};
if (loanPayoffMonth && hasStudentLoan) {
annotationConfig.loanPaidOffLine = {
@ -665,19 +672,16 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
}
};
}
const allAnnotations = { ...milestoneAnnotationLines, ...annotationConfig };
// Build the chart datasets:
const emergencyData = {
label: 'Emergency Savings',
data: projectionData.map((p) => p.emergencySavings),
borderColor: 'rgba(255, 159, 64, 1)', // orange
borderColor: 'rgba(255, 159, 64, 1)',
backgroundColor: 'rgba(255, 159, 64, 0.2)',
tension: 0.4,
fill: true
};
const retirementData = {
label: 'Retirement Savings',
data: projectionData.map((p) => p.retirementSavings),
@ -686,8 +690,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
tension: 0.4,
fill: true
};
// The total leftover each month (sum of any net gains so far).
const totalSavingsData = {
label: 'Total Savings',
data: projectionData.map((p) => p.totalSavings),
@ -696,8 +698,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
tension: 0.4,
fill: true
};
// We'll insert the Loan Balance dataset only if they actually have a loan
const loanBalanceData = {
label: 'Loan Balance',
data: projectionData.map((p) => p.loanBalance),
@ -711,20 +711,96 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
}
};
// The final dataset array:
// 1) Emergency
// 2) Retirement
// 3) Loan (conditional)
// 4) Total
const chartDatasets = [emergencyData, retirementData];
if (hasStudentLoan) {
// Insert loan after the first two lines, or wherever you prefer
chartDatasets.push(loanBalanceData);
}
if (hasStudentLoan) chartDatasets.push(loanBalanceData);
chartDatasets.push(totalSavingsData);
const yearsInCareer = getYearsInCareer(scenarioRow?.start_date);
// -- AI Handler --
async function handleAiClick() {
setAiLoading(true);
setRecommendations([]);
setSelectedIds([]);
try {
const payload = { userProfile, scenarioRow, financialProfile, collegeProfile };
const res = await authFetch('/api/premium/ai/next-steps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('AI request failed');
const data = await res.json();
const rawText = data.recommendations || '';
// Parse JSON
const arr = parseAiJson(rawText);
setRecommendations(arr);
} catch (err) {
console.error('Error fetching AI recommendations:', err);
} finally {
setAiLoading(false);
}
}
// Check/uncheck a recommendation
function handleToggle(recId) {
setSelectedIds((prev) => {
if (prev.includes(recId)) {
return prev.filter((x) => x !== recId);
} else {
return [...prev, recId];
}
});
}
async function handleCreateSelectedMilestones() {
if (!careerProfileId) return;
const confirm = window.confirm('Convert selected AI suggestions into milestones?');
if (!confirm) return;
// filter out those that are checked
const selectedRecs = recommendations.filter((r) => selectedIds.includes(r.id));
if (!selectedRecs.length) return;
const newMils = selectedRecs.map((rec) => ({
title: rec.title,
description: rec.description || '',
date: new Date().toISOString().slice(0, 10), // for demonstration
career_profile_id: careerProfileId
}));
try {
const r = await authFetch('/api/premium/milestone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ milestones: newMils })
});
if (!r.ok) throw new Error('Failed to create new milestones');
// re-run the projection to reflect newly inserted milestones
await buildProjection();
alert('Milestones successfully created! Check your timeline or projection.');
// optionally clear them
setSelectedIds([]);
} catch (err) {
console.error('Error saving new milestones:', err);
alert('Error saving AI milestones.');
}
}
function handleSimulationYearsChange(e) {
setSimulationYearsInput(e.target.value);
}
function handleSimulationYearsBlur() {
if (!simulationYearsInput.trim()) setSimulationYearsInput('20');
}
return (
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-6">
<h2 className="text-2xl font-bold mb-4">Where Am I Now?</h2>
@ -760,7 +836,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
{salaryData?.regional && (
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
<h4 className="font-medium mb-2">
Regional Data ({userArea || 'U.S.'})
Regional Salary Data ({userArea || 'U.S.'})
</h4>
<p>
10th percentile:{' '}
@ -768,14 +844,12 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
? `$${salaryData.regional.regional_PCT10.toLocaleString()}`
: 'N/A'}
</p>
<p>
Median:{' '}
{salaryData.regional.regional_MEDIAN
? `$${salaryData.regional.regional_MEDIAN.toLocaleString()}`
: 'N/A'}
</p>
<p>
90th percentile:{' '}
{salaryData.regional.regional_PCT90
@ -793,21 +867,19 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
{salaryData?.national && (
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
<h4 className="font-medium mb-2">National Data</h4>
<h4 className="font-medium mb-2">National Salary Data</h4>
<p>
10th percentile:{' '}
{salaryData.national.national_PCT10
? `$${salaryData.national.national_PCT10.toLocaleString()}`
: 'N/A'}
</p>
<p>
Median:{' '}
{salaryData.national.national_MEDIAN
? `$${salaryData.national.national_MEDIAN.toLocaleString()}`
: 'N/A'}
</p>
<p>
90th percentile:{' '}
{salaryData.national.national_PCT90
@ -839,15 +911,12 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
</div>
)}
{/* 4) Milestone Timeline */}
{/* 4) Career Goals */}
<div className="bg-white p-4 rounded shadow">
<h3 className="text-lg font-semibold mb-2">Your Milestones</h3>
<MilestoneTimeline
careerProfileId={careerProfileId}
authFetch={authFetch}
activeView="Career"
onMilestoneUpdated={() => {}}
/>
<h3 className="text-lg font-semibold mb-2">Your Career Goals</h3>
<p className="text-gray-700">
{scenarioRow?.career_goals || 'No career goals entered yet.'}
</p>
</div>
{/* 5) Financial Projection */}
@ -865,9 +934,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
plugins: {
legend: { position: 'bottom' },
tooltip: { mode: 'index', intersect: false },
annotation: {
annotations: allAnnotations
}
annotation: { annotations: allAnnotations }
},
scales: {
y: {
@ -920,6 +987,41 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
authFetch={authFetch}
/>
{/* 7) AI Next Steps */}
<div className="bg-white p-4 rounded shadow mt-4">
<Button onClick={handleAiClick}>
What Should I Do Next?
</Button>
{aiLoading && <p>Generating your next steps</p>}
{/* If we have structured recs, show checkboxes */}
{recommendations.length > 0 && (
<div className="mt-3">
<h3 className="font-semibold">Select the Advice You Want to Keep</h3>
<ul className="mt-2 space-y-2">
{recommendations.map((m) => (
<li key={m.id} className="flex items-start gap-2">
<input
type="checkbox"
checked={selectedIds.includes(m.id)}
onChange={() => handleToggle(m.id)}
/>
<div className="flex flex-col text-left">
<strong>{m.title}</strong>
<span>{m.date}</span>
<p className="text-sm">{m.description}</p>
</div>
</li>
))}
</ul>
{selectedIds.length > 0 && (
<Button className="mt-3" onClick={handleCreateSelectedMilestones}>
Create Milestones from Selected
</Button>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -152,7 +152,16 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
<option value="prospective_student">Planning to Enroll (Prospective)</option>
</select>
</div>
<div className="space-y-2">
<label className="block font-medium">Career Goals</label>
<textarea
name="career_goals"
value={data.career_goals || ''}
onChange={handleChange}
className="w-full border rounded p-2"
placeholder="Tell us about your goals, aspirations, or next steps..."
/>
</div>
<div className="flex justify-between pt-4">
<button
onClick={prevStep}

View File

@ -1,29 +1,115 @@
// ReviewPage.js
import React from 'react';
// Hypothetical Button component from your UI library
import { Button } from '../ui/button.js'; // Adjust path if needed
function ReviewPage({ careerData, financialData, collegeData, onSubmit, onBack }) {
console.log("REVIEW PAGE PROPS:", {
careerData,
financialData,
collegeData,
});
return (
<div>
<h2>Review Your Info</h2>
/**
* Helper to format numeric fields for display.
* If val is null/undefined or 0 and you want to hide it,
* you can adapt the logic below.
*/
function formatNum(val) {
// If val is null or undefined, show 'N/A'
if (val == null) return 'N/A';
// If you'd like to hide zero, you could do:
// if (val === 0) return 'N/A';
return val;
}
<h3>Career Info</h3>
<pre>{JSON.stringify(careerData, null, 2)}</pre>
function ReviewPage({
careerData = {},
financialData = {},
collegeData = {},
onSubmit,
onBack
}) {
// Decide if user is in or planning to be in college
const inOrPlanningCollege = (
careerData.college_enrollment_status === 'currently_enrolled'
|| careerData.college_enrollment_status === 'prospective_student'
);
<h3>Financial Info</h3>
<pre>{JSON.stringify(financialData, null, 2)}</pre>
<h3>College Info</h3>
<pre>{JSON.stringify(collegeData, null, 2)}</pre>
<button onClick={onBack}> Back</button>
<button onClick={onSubmit} style={{ marginLeft: '1rem' }}>
Submit All
</button>
return (
<div className="max-w-xl mx-auto p-6 space-y-6">
<h2 className="text-2xl font-semibold">Review Your Info</h2>
{/* --- CAREER SECTION --- */}
<div className="p-4 border rounded-md space-y-2">
<h3 className="text-xl font-semibold">Career Info</h3>
<div><strong>Career Name:</strong> {careerData.career_name || 'N/A'}</div>
<div><strong>Currently Working:</strong> {careerData.currently_working || 'N/A'}</div>
<div><strong>Enrollment Status:</strong> {careerData.college_enrollment_status || 'N/A'}</div>
<div><strong>Status:</strong> {careerData.status || 'N/A'}</div>
<div><strong>Start Date:</strong> {careerData.start_date || 'N/A'}</div>
<div><strong>Projected End Date:</strong> {careerData.projected_end_date || 'N/A'}</div>
<div><strong>Career Goals:</strong> {careerData.career_goals || 'N/A'}</div>
</div>
{/* --- FINANCIAL SECTION --- */}
<div className="p-4 border rounded-md space-y-2">
<h3 className="text-xl font-semibold">Financial Info</h3>
{/* Current/Additional Income */}
{careerData.currently_working === 'yes' ? (
<div className="space-y-1">
<div><strong>Current Annual Salary:</strong> {formatNum(financialData.current_salary)}</div>
<div><strong>Additional Annual Income:</strong> {formatNum(financialData.additional_income)}</div>
</div>
) : (
<div><strong>Currently Not Working</strong></div>
)}
{/* Monthly Expenses / Debt */}
<div className="mt-2 space-y-1">
<div><strong>Monthly Expenses:</strong> {formatNum(financialData.monthly_expenses)}</div>
<div><strong>Monthly Debt Payments:</strong> {formatNum(financialData.monthly_debt_payments)}</div>
</div>
{/* Retirement */}
<div className="mt-2 space-y-1">
<div><strong>Retirement Savings:</strong> {formatNum(financialData.retirement_savings)}</div>
<div><strong>Monthly Retirement Contribution:</strong> {formatNum(financialData.retirement_contribution)}</div>
</div>
{/* Emergency Fund */}
<div className="mt-2 space-y-1">
<div><strong>Emergency Fund Savings:</strong> {formatNum(financialData.emergency_fund)}</div>
<div><strong>Monthly Emergency Contribution:</strong> {formatNum(financialData.emergency_contribution)}</div>
</div>
{/* Extra Monthly Cash Allocation */}
<div className="mt-2 space-y-1">
<div><strong>Extra Monthly Cash to Emergency (%):</strong> {formatNum(financialData.extra_cash_emergency_pct)}</div>
<div><strong>Extra Monthly Cash to Retirement (%):</strong> {formatNum(financialData.extra_cash_retirement_pct)}</div>
</div>
</div>
{/* --- COLLEGE SECTION --- */}
{inOrPlanningCollege && (
<div className="p-4 border rounded-md space-y-2">
<h3 className="text-xl font-semibold">College Info</h3>
<div><strong>College Name:</strong> {collegeData.college_name || 'N/A'}</div>
<div><strong>Major:</strong> {collegeData.major || 'N/A'}</div>
{/* If you have these fields, show them if they're meaningful */}
{collegeData.tuition != null && (
<div><strong>Tuition (calculated):</strong> {formatNum(collegeData.tuition)}</div>
)}
{collegeData.program_length != null && (
<div><strong>Program Length (years):</strong> {formatNum(collegeData.program_length)}</div>
)}
</div>
)}
{/* --- ACTION BUTTONS --- */}
<div className="flex justify-between pt-4">
<Button variant="secondary" onClick={onBack}>
Back
</Button>
<Button variant="primary" onClick={onSubmit}>
Submit All
</Button>
</div>
</div>
);
}