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) => {
// ... 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}`);
});

View File

@ -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) {
console.error('Failed to fetch milestones.');
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;
}
const data = await res.json();
// data.milestones = [ { id, milestone_type, date, ..., tasks: [ ... ] }, ... ]
console.log('Fetched milestones with tasks:', data.milestones);
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);
// 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: ${type}`);
console.warn(`Unknown milestone type: ${m.milestone_type}`);
}
});
setMilestones(categorized);
console.log('Milestones set for view:', categorized);
} catch (err) {
console.error('Failed to fetch milestones:', err);
}
}, [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,87 +78,92 @@ 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();
console.log('Milestone saved/updated:', savedMilestone);
// Update state locally instead of fetching all milestones
setMilestones((prevMilestones) => {
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) {
// 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 (
<div className="milestone-timeline">
<p>Loading milestones...</p>
<p>Loading or no milestones in this view...</p>
</div>
);
}
return (
<div className="milestone-timeline">
{/* View selector buttons */}
<div className="view-selector">
{['Career', 'Financial', 'Retirement'].map((view) => (
{['Career', 'Financial'].map((view) => (
<button
key={view}
className={activeView === view ? 'active' : ''}
@ -161,57 +174,121 @@ const filteredMilestones = raw.filter(
))}
</div>
<button onClick={() => {
<button
onClick={() => {
if (showForm) {
// Cancel form
setShowForm(false);
setEditingMilestone(null);
setNewMilestone({ title: '', date: '', progress: 0 });
setNewMilestone({
title: '',
description: '',
date: '',
progress: 0,
newSalary: ''
});
} else {
// Show form
setShowForm(true);
}
}}>
}}
>
{showForm ? 'Cancel' : '+ New Milestone'}
</button>
{showForm && (
<div className="form">
<input type="text" placeholder="Title" 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) })} />
<input
type="text"
placeholder="Title"
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' && (
<div>
<input
type="number"
placeholder="Full New Salary (e.g., 70000)"
value={newMilestone.newSalary || ''}
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: parseFloat(e.target.value) })}
value={newMilestone.newSalary}
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>
)}
<button onClick={saveMilestone}>{editingMilestone ? 'Update' : 'Add'} Milestone</button>
<button onClick={saveMilestone}>
{editingMilestone ? 'Update' : 'Add'} Milestone
</button>
</div>
)}
{/* Timeline rendering */}
<div className="milestone-timeline-container">
<div className="milestone-timeline-line" />
{milestones[activeView]?.map((m) => (
<div key={m.id} className="milestone-timeline-post" style={{ left: `${calcPosition(m.date)}%` }} onClick={() => {
{milestones[activeView].map((m) => {
const leftPos = calcPosition(m.date);
return (
<div
key={m.id}
className="milestone-timeline-post"
style={{ left: `${leftPos}%` }}
onClick={() => {
// Clicking a milestone => edit it
setEditingMilestone(m);
setNewMilestone({ title: m.title, date: m.date, progress: 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>
</div>
</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>
</div>
);

View File

@ -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

Binary file not shown.