Added tasks concept, but no UI. Adjusted server3 for new milestones endpoints.

This commit is contained in:
Josh 2025-04-14 16:25:50 +00:00
parent b98f93d442
commit a04ab21d02
4 changed files with 450 additions and 154 deletions

View File

@ -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) => { 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 app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => {
// ... no changes ... 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) 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) { // POST create a new task
return res.status(404).json({ error: 'No planned path found for user' }); 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 = { // 3. Attach tasks to each milestone object
jobTitle: userCareer.career_name, const milestonesWithTasks = milestones.map(m => ({
salary: 80000, ...m,
tuition: 50000, tasks: tasksByMilestone[m.id] || []
netGain: 80000 - 50000 }));
};
res.json(roi); res.json({ milestones: milestonesWithTasks });
} catch (error) { } catch (err) {
console.error('Error calculating ROI:', error); console.error('Error fetching milestones with tasks:', err);
res.status(500).json({ error: 'Failed to calculate ROI' }); res.status(500).json({ error: 'Failed to fetch milestones.' });
} }
}); });
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Premium server running on http://localhost:${PORT}`); console.log(`Premium server running on http://localhost:${PORT}`);
}); });

View File

@ -4,65 +4,73 @@ import React, { useEffect, useState, useCallback } from 'react';
const today = new Date(); const today = new Date();
const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView }) => { const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView }) => {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
const [milestones, setMilestones] = useState({ Career: [], Financial: [], Retirement: [] }); const [newMilestone, setNewMilestone] = useState({
const [newMilestone, setNewMilestone] = useState({ title: '', date: '', description: '', progress: 0 }); title: '',
description: '',
date: '',
progress: 0,
newSalary: ''
});
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingMilestone, setEditingMilestone] = useState(null); 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 () => { const fetchMilestones = useCallback(async () => {
if (!careerPathId) { if (!careerPathId) return;
console.warn('No careerPathId provided.');
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]); }, [careerPathId, authFetch]);
// ✅ useEffect simply calls the function // Run fetchMilestones on mount or when careerPathId changes
useEffect(() => { useEffect(() => {
fetchMilestones(); fetchMilestones();
}, [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 () => { const saveMilestone = async () => {
if (!activeView) return;
const url = editingMilestone const url = editingMilestone
? `/api/premium/milestones/${editingMilestone.id}` ? `/api/premium/milestones/${editingMilestone.id}`
: `/api/premium/milestone`; : `/api/premium/milestone`;
const method = editingMilestone ? 'PUT' : 'POST'; const method = editingMilestone ? 'PUT' : 'POST';
const payload = { const payload = {
milestone_type: activeView, milestone_type: activeView,
title: newMilestone.title, title: newMilestone.title,
@ -70,148 +78,217 @@ const filteredMilestones = raw.filter(
date: newMilestone.date, date: newMilestone.date,
career_path_id: careerPathId, career_path_id: careerPathId,
progress: newMilestone.progress, 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 { try {
console.log('Sending request to:', url); console.log('Sending request:', method, url, payload);
console.log('HTTP Method:', method);
console.log('Payload:', payload);
const res = await authFetch(url, { const res = await authFetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload)
}); });
if (!res.ok) { if (!res.ok) {
const errorData = await res.json(); const errorData = await res.json();
console.error('Failed to save milestone:', errorData); console.error('Failed to save milestone:', errorData);
alert(errorData.error || 'Error saving milestone');
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
return; return;
} }
const savedMilestone = await res.json();
// Update state locally instead of fetching all milestones const savedMilestone = await res.json();
setMilestones((prevMilestones) => { console.log('Milestone saved/updated:', savedMilestone);
const updatedMilestones = { ...prevMilestones };
// 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) { if (editingMilestone) {
// Update the existing milestone updated[activeView] = updated[activeView].map((m) =>
updatedMilestones[activeView] = updatedMilestones[activeView].map((m) =>
m.id === editingMilestone.id ? savedMilestone : m m.id === editingMilestone.id ? savedMilestone : m
); );
} else { } else {
// Add the new milestone updated[activeView].push(savedMilestone);
updatedMilestones[activeView].push(savedMilestone);
} }
return updatedMilestones; return updated;
}); });
// Reset form
setShowForm(false); setShowForm(false);
setEditingMilestone(null); setEditingMilestone(null);
setNewMilestone({ title: '', description: '', date: '', progress: 0 }); setNewMilestone({
} catch (error) { title: '',
console.error('Error saving milestone:', error); 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]; * Figure out the timeline's "end" date by scanning all milestones.
const lastDate = allMilestones.reduce( */
(latest, m) => (new Date(m.date) > latest ? new Date(m.date) : latest), const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
today 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 start = today.getTime();
const end = lastDate.getTime(); const end = lastDate.getTime();
const position = ((new Date(date).getTime() - start) / (end - start)) * 100; const dateVal = new Date(dateString).getTime();
return Math.min(Math.max(position, 0), 100); 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 not set or the array is missing, show a loading or empty state
if (!activeView || !milestones[activeView]) {
if (!activeView || !milestones?.[activeView]) {
return ( return (
<div className="milestone-timeline"> <div className="milestone-timeline">
<p>Loading milestones...</p> <p>Loading or no milestones in this view...</p>
</div> </div>
); );
} }
return ( return (
<div className="milestone-timeline"> <div className="milestone-timeline">
{/* View selector buttons */}
<div className="view-selector"> <div className="view-selector">
{['Career', 'Financial', 'Retirement'].map((view) => ( {['Career', 'Financial'].map((view) => (
<button <button
key={view} key={view}
className={activeView === view ? 'active' : ''} className={activeView === view ? 'active' : ''}
onClick={() => setActiveView(view)} onClick={() => setActiveView(view)}
> >
{view} {view}
</button> </button>
))} ))}
</div> </div>
<button onClick={() => { <button
if (showForm) { onClick={() => {
setShowForm(false); if (showForm) {
setEditingMilestone(null); // Cancel form
setNewMilestone({ title: '', date: '', progress: 0 }); setShowForm(false);
} else { setEditingMilestone(null);
setShowForm(true); setNewMilestone({
} title: '',
}}> description: '',
date: '',
progress: 0,
newSalary: ''
});
} else {
// Show form
setShowForm(true);
}
}}
>
{showForm ? 'Cancel' : '+ New Milestone'} {showForm ? 'Cancel' : '+ New Milestone'}
</button> </button>
{showForm && ( {showForm && (
<div className="form"> <div className="form">
<input type="text" placeholder="Title" value={newMilestone.title} onChange={(e) => setNewMilestone({ ...newMilestone, title: e.target.value })} /> <input
<input type="text" placeholder="Description" value={newMilestone.description} onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })} /> type="text"
<input type="date" value={newMilestone.date} onChange={(e) => setNewMilestone({ ...newMilestone, date: e.target.value })} /> placeholder="Title"
<input type="number" placeholder="Progress (%)" value={newMilestone.progress} onChange={(e) => setNewMilestone({ ...newMilestone, progress: parseInt(e.target.value, 10) })} /> value={newMilestone.title}
onChange={(e) => setNewMilestone({ ...newMilestone, title: e.target.value })}
/>
<input
type="text"
placeholder="Description"
value={newMilestone.description}
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
/>
<input
type="date"
value={newMilestone.date}
onChange={(e) => setNewMilestone({ ...newMilestone, date: e.target.value })}
/>
<input
type="number"
placeholder="Progress (%)"
value={newMilestone.progress}
onChange={(e) =>
setNewMilestone({
...newMilestone,
progress: parseInt(e.target.value, 10)
})
}
/>
{activeView === 'Financial' && ( {activeView === 'Financial' && (
<div> <div>
<input <input
type="number" type="number"
placeholder="Full New Salary (e.g., 70000)" placeholder="Full New Salary (e.g., 70000)"
value={newMilestone.newSalary || ''} value={newMilestone.newSalary}
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: parseFloat(e.target.value) })} onChange={(e) =>
setNewMilestone({ ...newMilestone, newSalary: e.target.value })
}
/> />
<p>Enter the full new salary (not just the change) after the milestone has taken place.</p> <p>Enter the full new salary (not just the increase) after the milestone occurs.</p>
</div> </div>
)} )}
<button onClick={saveMilestone}>{editingMilestone ? 'Update' : 'Add'} Milestone</button> <button onClick={saveMilestone}>
{editingMilestone ? 'Update' : 'Add'} Milestone
</button>
</div> </div>
)} )}
{/* Timeline rendering */}
<div className="milestone-timeline-container"> <div className="milestone-timeline-container">
<div className="milestone-timeline-line" /> <div className="milestone-timeline-line" />
{milestones[activeView]?.map((m) => ( {milestones[activeView].map((m) => {
<div key={m.id} className="milestone-timeline-post" style={{ left: `${calcPosition(m.date)}%` }} onClick={() => { const leftPos = calcPosition(m.date);
setEditingMilestone(m); return (
setNewMilestone({ title: m.title, date: m.date, progress: m.progress }); <div
setShowForm(true); key={m.id}
}}> className="milestone-timeline-post"
<div className="milestone-timeline-dot" /> style={{ left: `${leftPos}%` }}
<div className="milestone-content"> onClick={() => {
<div className="title">{m.title}</div> // Clicking a milestone => edit it
<div className="progress-bar"> setEditingMilestone(m);
<div className="progress" style={{ width: `${m.progress}%` }} /> setNewMilestone({
title: m.title,
description: m.description,
date: m.date,
progress: m.progress,
newSalary: m.new_salary || ''
});
setShowForm(true);
}}
>
<div className="milestone-timeline-dot" />
<div className="milestone-content">
<div className="title">{m.title}</div>
{m.description && <p>{m.description}</p>}
<div className="progress-bar">
<div className="progress" style={{ width: `${m.progress}%` }} />
</div>
<div className="date">{m.date}</div>
{/* If the milestone has tasks */}
{m.tasks && m.tasks.length > 0 && (
<ul>
{m.tasks.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
)}
</div> </div>
<div className="date">{m.date}</div>
</div> </div>
</div> );
))} })}
</div> </div>
</div> </div>
); );

View File

@ -26,8 +26,8 @@ export function simulateFinancialProjection(userProfile) {
inCollege = false, inCollege = false,
programType, programType,
hoursCompleted = 0, hoursCompleted = 0,
creditHoursPerYear = 30, creditHoursPerYear,
calculatedTuition = 10000, // e.g. annual tuition calculatedTuition, // e.g. annual tuition
gradDate, // known graduation date, or null gradDate, // known graduation date, or null
startDate, // when sim starts startDate, // when sim starts
academicCalendar = 'monthly', // new academicCalendar = 'monthly', // new

Binary file not shown.