From a04ab21d026292be98d2b4860fea7fc1ec57a808 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 14 Apr 2025 16:25:50 +0000 Subject: [PATCH] Added tasks concept, but no UI. Adjusted server3 for new milestones endpoints. --- backend/server3.js | 277 +++++++++++++++++--- src/components/MilestoneTimeline.js | 323 +++++++++++++++--------- src/utils/FinancialProjectionService.js | 4 +- user_profile.db | Bin 106496 -> 106496 bytes 4 files changed, 450 insertions(+), 154 deletions(-) diff --git a/backend/server3.js b/backend/server3.js index 36fbad6..4af2713 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -167,15 +167,203 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res }); -/* ------------------------------------------------------------------ - MILESTONES (same as before) - ------------------------------------------------------------------ */ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => { - // ... no changes, same logic ... + try { + const { + milestone_type, + title, + description, + date, + career_path_id, + progress, + status, + new_salary + } = req.body; + + if (!milestone_type || !title || !date || !career_path_id) { + return res.status(400).json({ + error: 'Missing required fields', + details: { milestone_type, title, date, career_path_id } + }); + } + + const id = uuidv4(); + const now = new Date().toISOString(); + + await db.run(` + INSERT INTO milestones ( + id, + user_id, + career_path_id, + milestone_type, + title, + description, + date, + progress, + status, + new_salary, -- store the full new salary if provided + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + id, + req.userId, + career_path_id, + milestone_type, + title, + description || '', + date, + progress || 0, + status || 'planned', + new_salary || null, + now, + now + ]); + + // Return the newly created milestone object + // (No tasks initially, so tasks = []) + const newMilestone = { + id, + user_id: req.userId, + career_path_id, + milestone_type, + title, + description: description || '', + date, + progress: progress || 0, + status: status || 'planned', + new_salary: new_salary || null, + tasks: [] + }; + + res.status(201).json(newMilestone); + } catch (err) { + console.error('Error creating milestone:', err); + res.status(500).json({ error: 'Failed to create milestone.' }); + } }); -// GET, PUT, DELETE milestones -// ... no changes ... +app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => { + try { + const { milestoneId } = req.params; + const { + milestone_type, + title, + description, + date, + career_path_id, + progress, + status, + new_salary + } = req.body; + + // Check if milestone exists and belongs to user + const existing = await db.get(` + SELECT * + FROM milestones + WHERE id = ? + AND user_id = ? + `, [milestoneId, req.userId]); + + if (!existing) { + return res.status(404).json({ error: 'Milestone not found or not yours.' }); + } + + // Update + const now = new Date().toISOString(); + await db.run(` + UPDATE milestones + SET + milestone_type = ?, + title = ?, + description = ?, + date = ?, + career_path_id = ?, + progress = ?, + status = ?, + new_salary = ?, + updated_at = ? + WHERE id = ? + `, [ + milestone_type || existing.milestone_type, + title || existing.title, + description || existing.description, + date || existing.date, + career_path_id || existing.career_path_id, + progress != null ? progress : existing.progress, + status || existing.status, + new_salary != null ? new_salary : existing.new_salary, + now, + milestoneId + ]); + + // Return the updated record with tasks + const updatedMilestoneRow = await db.get(` + SELECT * + FROM milestones + WHERE id = ? + `, [milestoneId]); + + // Fetch tasks for this milestone + const tasks = await db.all(` + SELECT * + FROM tasks + WHERE milestone_id = ? + `, [milestoneId]); + + const updatedMilestone = { + ...updatedMilestoneRow, + tasks: tasks || [] + }; + + res.json(updatedMilestone); + } catch (err) { + console.error('Error updating milestone:', err); + res.status(500).json({ error: 'Failed to update milestone.' }); + } +}); + +app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => { + const { careerPathId } = req.query; + + try { + // 1. Fetch the milestones for this user + path + const milestones = await db.all(` + SELECT * + FROM milestones + WHERE user_id = ? + AND career_path_id = ? + `, [req.userId, careerPathId]); + + // 2. For each milestone, fetch tasks + const milestoneIds = milestones.map(m => m.id); + let tasksByMilestone = {}; + if (milestoneIds.length > 0) { + const tasks = await db.all(` + SELECT * + FROM tasks + WHERE milestone_id IN (${milestoneIds.map(() => '?').join(',')}) + `, milestoneIds); + + tasksByMilestone = tasks.reduce((acc, t) => { + if (!acc[t.milestone_id]) acc[t.milestone_id] = []; + acc[t.milestone_id].push(t); + return acc; + }, {}); + } + + // 3. Attach tasks to each milestone object + const milestonesWithTasks = milestones.map(m => ({ + ...m, + tasks: tasksByMilestone[m.id] || [] + })); + + res.json({ milestones: milestonesWithTasks }); + } catch (err) { + console.error('Error fetching milestones with tasks:', err); + res.status(500).json({ error: 'Failed to fetch milestones.' }); + } +}); /* ------------------------------------------------------------------ FINANCIAL PROFILES (Renamed emergency_contribution) @@ -484,36 +672,67 @@ app.get('/api/premium/financial-projection/:careerPathId', authenticatePremiumUs } }); -/* ------------------------------------------------------------------ - ROI ANALYSIS (placeholder) - ------------------------------------------------------------------ */ -app.get('/api/premium/roi-analysis', authenticatePremiumUser, async (req, res) => { - try { - const userCareer = await db.get(` - SELECT * FROM career_paths - WHERE user_id = ? - ORDER BY start_date DESC - LIMIT 1 - `, [req.userId]); - if (!userCareer) { - return res.status(404).json({ error: 'No planned path found for user' }); +// POST create a new task +app.post('/api/premium/tasks', authenticatePremiumUser, async (req, res) => { + const { + milestone_id, // which milestone this belongs to + user_id, // might come from token or from body + title, + description, + due_date + } = req.body; + + // Insert into tasks table + // Return the new task in JSON +}); + +// GET tasks for a milestone +app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => { + const { careerPathId } = req.query; + + try { + // 1. Fetch the milestones for this user + path + const milestones = await db.all(` + SELECT * + FROM milestones + WHERE user_id = ? + AND career_path_id = ? + `, [req.userId, careerPathId]); + + // 2. For each milestone, fetch tasks (or do a single join—see note below) + // We'll do it in Node code for clarity: + const milestoneIds = milestones.map(m => m.id); + let tasksByMilestone = {}; + if (milestoneIds.length > 0) { + const tasks = await db.all(` + SELECT * + FROM tasks + WHERE milestone_id IN (${milestoneIds.map(() => '?').join(',')}) + `, milestoneIds); + + // Group tasks by milestone_id + tasksByMilestone = tasks.reduce((acc, t) => { + if (!acc[t.milestone_id]) acc[t.milestone_id] = []; + acc[t.milestone_id].push(t); + return acc; + }, {}); } - const roi = { - jobTitle: userCareer.career_name, - salary: 80000, - tuition: 50000, - netGain: 80000 - 50000 - }; + // 3. Attach tasks to each milestone object + const milestonesWithTasks = milestones.map(m => ({ + ...m, + tasks: tasksByMilestone[m.id] || [] + })); - res.json(roi); - } catch (error) { - console.error('Error calculating ROI:', error); - res.status(500).json({ error: 'Failed to calculate ROI' }); + res.json({ milestones: milestonesWithTasks }); + } catch (err) { + console.error('Error fetching milestones with tasks:', err); + res.status(500).json({ error: 'Failed to fetch milestones.' }); } }); + app.listen(PORT, () => { console.log(`Premium server running on http://localhost:${PORT}`); }); diff --git a/src/components/MilestoneTimeline.js b/src/components/MilestoneTimeline.js index e30ef11..60c92fd 100644 --- a/src/components/MilestoneTimeline.js +++ b/src/components/MilestoneTimeline.js @@ -4,65 +4,73 @@ import React, { useEffect, useState, useCallback } from 'react'; const today = new Date(); const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView }) => { - - const [milestones, setMilestones] = useState({ Career: [], Financial: [], Retirement: [] }); - const [newMilestone, setNewMilestone] = useState({ title: '', date: '', description: '', progress: 0 }); + const [milestones, setMilestones] = useState({ Career: [], Financial: [] }); + const [newMilestone, setNewMilestone] = useState({ + title: '', + description: '', + date: '', + progress: 0, + newSalary: '' + }); const [showForm, setShowForm] = useState(false); const [editingMilestone, setEditingMilestone] = useState(null); + /** + * Fetch all milestones (and their tasks) for this careerPathId + * Then categorize them by milestone_type: 'Career' or 'Financial'. + */ const fetchMilestones = useCallback(async () => { - if (!careerPathId) { - console.warn('No careerPathId provided.'); - return; + if (!careerPathId) return; + + try { + const res = await authFetch(`/api/premium/milestones?careerPathId=${careerPathId}`); + if (!res.ok) { + console.error('Failed to fetch milestones. Status:', res.status); + return; + } + const data = await res.json(); + if (!data.milestones) { + console.warn('No milestones field in response:', data); + return; + } + + // data.milestones = [ { id, milestone_type, date, ..., tasks: [ ... ] }, ... ] + console.log('Fetched milestones with tasks:', data.milestones); + + // Categorize by milestone_type + const categorized = { Career: [], Financial: [] }; + data.milestones.forEach((m) => { + if (categorized[m.milestone_type]) { + categorized[m.milestone_type].push(m); + } else { + console.warn(`Unknown milestone type: ${m.milestone_type}`); + } + }); + + setMilestones(categorized); + } catch (err) { + console.error('Failed to fetch milestones:', err); } - - const res = await authFetch(`/api/premium/milestones?careerPathId=${careerPathId}`); - if (!res) { - console.error('Failed to fetch milestones.'); - return; - } - - const data = await res.json(); - - const raw = Array.isArray(data.milestones[0]) - ? data.milestones.flat() - : data.milestones.milestones || data.milestones; - -const flatMilestones = Array.isArray(data.milestones[0]) -? data.milestones.flat() -: data.milestones; - -const filteredMilestones = raw.filter( - (m) => m.career_path_id === careerPathId -); - - const categorized = { Career: [], Financial: [], Retirement: [] }; - - filteredMilestones.forEach((m) => { - const type = m.milestone_type; - if (categorized[type]) { - categorized[type].push(m); - } else { - console.warn(`Unknown milestone type: ${type}`); - } -}); - - - setMilestones(categorized); - console.log('Milestones set for view:', categorized); - }, [careerPathId, authFetch]); - // ✅ useEffect simply calls the function + // Run fetchMilestones on mount or when careerPathId changes useEffect(() => { fetchMilestones(); }, [fetchMilestones]); + /** + * Create or update a milestone. + * If editingMilestone is set, we do PUT -> /api/premium/milestones/:id + * Else we do POST -> /api/premium/milestone + */ const saveMilestone = async () => { + if (!activeView) return; + const url = editingMilestone ? `/api/premium/milestones/${editingMilestone.id}` : `/api/premium/milestone`; const method = editingMilestone ? 'PUT' : 'POST'; + const payload = { milestone_type: activeView, title: newMilestone.title, @@ -70,148 +78,217 @@ const filteredMilestones = raw.filter( date: newMilestone.date, career_path_id: careerPathId, progress: newMilestone.progress, - status: newMilestone.progress === 100 ? 'completed' : 'planned', + status: newMilestone.progress >= 100 ? 'completed' : 'planned', + // Only include new_salary if it's a Financial milestone + new_salary: activeView === 'Financial' && newMilestone.newSalary + ? parseFloat(newMilestone.newSalary) + : null }; try { - console.log('Sending request to:', url); - console.log('HTTP Method:', method); - console.log('Payload:', payload); - + console.log('Sending request:', method, url, payload); const res = await authFetch(url, { method, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: JSON.stringify(payload) }); if (!res.ok) { const errorData = await res.json(); console.error('Failed to save milestone:', errorData); - - let message = 'An error occurred while saving the milestone.'; - if (errorData?.error === 'Missing required fields') { - message = 'Please complete all required fields before saving.'; - console.warn('Missing fields:', errorData.details); - } - - alert(message); // Replace with your preferred UI messaging + alert(errorData.error || 'Error saving milestone'); return; } - - const savedMilestone = await res.json(); - // Update state locally instead of fetching all milestones - setMilestones((prevMilestones) => { - const updatedMilestones = { ...prevMilestones }; + const savedMilestone = await res.json(); + console.log('Milestone saved/updated:', savedMilestone); + + // Update local state so we don't have to refetch everything + setMilestones((prev) => { + const updated = { ...prev }; + // If editing, replace existing; else push new if (editingMilestone) { - // Update the existing milestone - updatedMilestones[activeView] = updatedMilestones[activeView].map((m) => + updated[activeView] = updated[activeView].map((m) => m.id === editingMilestone.id ? savedMilestone : m ); } else { - // Add the new milestone - updatedMilestones[activeView].push(savedMilestone); + updated[activeView].push(savedMilestone); } - return updatedMilestones; + return updated; }); + // Reset form setShowForm(false); setEditingMilestone(null); - setNewMilestone({ title: '', description: '', date: '', progress: 0 }); - } catch (error) { - console.error('Error saving milestone:', error); + setNewMilestone({ + title: '', + description: '', + date: '', + progress: 0, + newSalary: '' + }); + } catch (err) { + console.error('Error saving milestone:', err); } }; - // Calculate last milestone date properly by combining all arrays - const allMilestones = [...milestones.Career, ...milestones.Financial, ...milestones.Retirement]; - const lastDate = allMilestones.reduce( - (latest, m) => (new Date(m.date) > latest ? new Date(m.date) : latest), - today - ); + /** + * Figure out the timeline's "end" date by scanning all milestones. + */ + const allMilestonesCombined = [...milestones.Career, ...milestones.Financial]; + const lastDate = allMilestonesCombined.reduce((latest, m) => { + const d = new Date(m.date); + return d > latest ? d : latest; + }, today); - const calcPosition = (date) => { + const calcPosition = (dateString) => { const start = today.getTime(); const end = lastDate.getTime(); - const position = ((new Date(date).getTime() - start) / (end - start)) * 100; - return Math.min(Math.max(position, 0), 100); + const dateVal = new Date(dateString).getTime(); + if (end === start) return 0; // edge case if only one date + const ratio = (dateVal - start) / (end - start); + return Math.min(Math.max(ratio * 100, 0), 100); }; - console.log('Rendering view:', activeView, milestones?.[activeView]); - - if (!activeView || !milestones?.[activeView]) { + // If activeView not set or the array is missing, show a loading or empty state + if (!activeView || !milestones[activeView]) { return (
-

Loading milestones...

+

Loading or no milestones in this view...

); } return (
+ {/* View selector buttons */}
- {['Career', 'Financial', 'Retirement'].map((view) => ( - -))} + {['Career', 'Financial'].map((view) => ( + + ))}
- {showForm && (
- setNewMilestone({ ...newMilestone, title: e.target.value })} /> - setNewMilestone({ ...newMilestone, description: e.target.value })} /> - setNewMilestone({ ...newMilestone, date: e.target.value })} /> - setNewMilestone({ ...newMilestone, progress: parseInt(e.target.value, 10) })} /> + setNewMilestone({ ...newMilestone, title: e.target.value })} + /> + setNewMilestone({ ...newMilestone, description: e.target.value })} + /> + setNewMilestone({ ...newMilestone, date: e.target.value })} + /> + + setNewMilestone({ + ...newMilestone, + progress: parseInt(e.target.value, 10) + }) + } + /> {activeView === 'Financial' && (
setNewMilestone({ ...newMilestone, newSalary: parseFloat(e.target.value) })} + value={newMilestone.newSalary} + onChange={(e) => + setNewMilestone({ ...newMilestone, newSalary: e.target.value }) + } /> -

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

+

Enter the full new salary (not just the increase) after the milestone occurs.

)} - +
)} + {/* Timeline rendering */}
- {milestones[activeView]?.map((m) => ( -
{ - setEditingMilestone(m); - setNewMilestone({ title: m.title, date: m.date, progress: m.progress }); - setShowForm(true); - }}> -
-
-
{m.title}
-
-
+ {milestones[activeView].map((m) => { + const leftPos = calcPosition(m.date); + return ( +
{ + // Clicking a milestone => edit it + setEditingMilestone(m); + setNewMilestone({ + title: m.title, + description: m.description, + date: m.date, + progress: m.progress, + newSalary: m.new_salary || '' + }); + setShowForm(true); + }} + > +
+
+
{m.title}
+ {m.description &&

{m.description}

} +
+
+
+
{m.date}
+ + {/* If the milestone has tasks */} + {m.tasks && m.tasks.length > 0 && ( +
    + {m.tasks.map((t) => ( +
  • {t.title}
  • + ))} +
+ )}
-
{m.date}
-
- ))} + ); + })}
); diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js index 229e810..bff7ef0 100644 --- a/src/utils/FinancialProjectionService.js +++ b/src/utils/FinancialProjectionService.js @@ -26,8 +26,8 @@ export function simulateFinancialProjection(userProfile) { inCollege = false, programType, hoursCompleted = 0, - creditHoursPerYear = 30, - calculatedTuition = 10000, // e.g. annual tuition + creditHoursPerYear, + calculatedTuition, // e.g. annual tuition gradDate, // known graduation date, or null startDate, // when sim starts academicCalendar = 'monthly', // new diff --git a/user_profile.db b/user_profile.db index 9acdf7d93fa1bc84064bc0b6c0caff13fb3156cd..bf117846b82aa6ec1e8fbfaa1ad5f719fb96e5b7 100644 GIT binary patch delta 554 zcmZ9J&ubGw6vub6+s&G4lUmzNlb{bQgkl<6J$Ul4nypmQALs^qTQ<9sFeKRtyOTpf zaEpgtt(Q4g=wDC+=2r4A^yaaGH~#?-3cjvr!GX^VeBbxYdv7M0>d91pU!Gr46s3gB zB06Fn*B;HRD0QEl1*a#AGbk02MPvcFgDj6LAHU^4U227~HvO3=xiLx9!l`z-Ud(C@ zMQ`h?xnKE-v7VtRfds)*o$CCT#)Dec3*wYCPd-mQZNt=3i}2QXlL8Vep$mj&S1`wr~vBCTe#hDm7e@QAW* z2t9fLf?xaNIt%2N45`SIGBFIjkP8S?NzM-65PcPN^NA^Uv!pnm{(`%a9&`C0iZOXnB%hCd1Jll) A4*&oF delta 158 zcmZoTz}9epZGyC*G6MsH6cBR*F$WMkOw=)ERc6pDTC_1`@qAHspb!rbivqDQ5Q_jY z-)5NwPxzM$C@=~vIv}u|5lC;^z#_1i<%5C*E1M<*TN0Zl*I)ka9DX1O)WE