AI suggested Milestone functionality, clean up add/edit milestone database interaction

This commit is contained in:
Josh 2025-03-31 13:51:21 +00:00
parent b13a7f1299
commit 13b102bfe2
8 changed files with 417 additions and 117 deletions

View File

@ -0,0 +1,24 @@
import React, { useState } from 'react';
const MilestoneTimeline = () => {
const [showInputFields, setShowInputFields] = useState(false);
const toggleInputFields = () => {
setShowInputFields((prev) => !prev);
};
return (
<div>
<button onClick={toggleInputFields}>+ New Milestone</button>
{showInputFields && (
<div>
<input type="text" placeholder="Milestone Name" />
<input type="date" placeholder="Due Date" />
<button>Save</button>
</div>
)}
</div>
);
};
export default MilestoneTimeline;

View File

@ -127,41 +127,91 @@ app.post('/api/premium/planned-path', authenticatePremiumUser, async (req, res)
}
});
// Save a new milestone
app.post('/api/premium/milestones', authenticatePremiumUser, async (req, res) => {
const {
milestone_type,
title,
description,
date,
career_path_id,
salary_increase,
status = 'planned',
date_completed = null,
context_snapshot = null
} = req.body;
app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => {
const rawMilestones = Array.isArray(req.body.milestones) ? req.body.milestones : [req.body];
if (!milestone_type || !title || !description || !date) {
return res.status(400).json({ error: 'Missing required fields' });
const errors = [];
const validMilestones = [];
for (const [index, m] of rawMilestones.entries()) {
const {
milestone_type,
title,
description,
date,
career_path_id,
salary_increase,
status = 'planned',
date_completed = null,
context_snapshot = null,
progress = 0,
} = m;
// Validate required fields
if (!milestone_type || !title || !description || !date || !career_path_id) {
errors.push({
index,
error: 'Missing required fields',
title, // <-- Add the title for identification
date,
details: {
milestone_type: !milestone_type ? 'Required' : undefined,
title: !title ? 'Required' : undefined,
description: !description ? 'Required' : undefined,
date: !date ? 'Required' : undefined,
career_path_id: !career_path_id ? 'Required' : undefined,
}
});
continue;
}
validMilestones.push({
id: uuidv4(), // ✅ assign UUID for unique milestone ID
user_id: req.userId,
milestone_type,
title,
description,
date,
career_path_id,
salary_increase: salary_increase || null,
status,
date_completed,
context_snapshot,
progress
});
}
if (errors.length) {
console.warn('❗ Some milestones failed validation. Logging malformed records...');
console.warn(JSON.stringify(errors, null, 2));
return res.status(400).json({
error: 'Some milestones are invalid',
errors
});
}
try {
await db.run(
`INSERT INTO milestones (
user_id, milestone_type, title, description, date, career_path_id,
salary_increase, status, date_completed, context_snapshot, progress, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
[
req.userId, milestone_type, title, description, date, career_path_id,
salary_increase || null, status, date_completed, context_snapshot
]
const insertPromises = validMilestones.map(m =>
db.run(
`INSERT INTO milestones (
id, user_id, milestone_type, title, description, date, career_path_id,
salary_increase, status, date_completed, context_snapshot, progress, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
[
m.id, m.user_id, m.milestone_type, m.title, m.description, m.date, m.career_path_id,
m.salary_increase, m.status, m.date_completed, m.context_snapshot, m.progress
]
)
);
res.status(201).json({ message: 'Milestone saved successfully' });
await Promise.all(insertPromises);
res.status(201).json({ message: 'Milestones saved successfully', count: validMilestones.length });
} catch (error) {
console.error('Error saving milestone:', error);
res.status(500).json({ error: 'Failed to save milestone' });
console.error('Error saving milestones:', error);
res.status(500).json({ error: 'Failed to save milestones' });
}
});
@ -170,19 +220,16 @@ app.post('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
// Get all milestones
app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => {
try {
const milestones = await db.all(
`SELECT * FROM milestones WHERE user_id = ? ORDER BY date ASC`,
[req.userId]
);
const { careerPathId } = req.query;
const mapped = milestones.map(m => ({
title: m.title,
description: m.description,
date: m.date,
type: m.milestone_type,
progress: m.progress || 0,
career_path_id: m.career_path_id
}));
if (!careerPathId) {
return res.status(400).json({ error: 'careerPathId is required' });
}
const milestones = await db.all(
`SELECT * FROM milestones WHERE user_id = ? AND career_path_id = ? ORDER BY date ASC`,
[req.userId, careerPathId]
);
res.json({ milestones });
} catch (error) {
@ -191,6 +238,7 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
}
});
/// Update an existing milestone
app.put('/api/premium/milestones/:id', authenticatePremiumUser, async (req, res) => {
try {
@ -213,6 +261,21 @@ app.put('/api/premium/milestones/:id', authenticatePremiumUser, async (req, res)
context_snapshot,
} = req.body;
// Explicit required field validation
if (!milestone_type || !title || !description || !date || progress === undefined) {
return res.status(400).json({
error: 'Missing required fields',
details: {
milestone_type: !milestone_type ? 'Required' : undefined,
title: !title ? 'Required' : undefined,
description: !description ? 'Required' : undefined,
date: !date ? 'Required' : undefined,
progress: progress === undefined ? 'Required' : undefined,
}
});
}
console.log('Updating milestone with:', {
milestone_type,
title,

View File

@ -1,8 +1,11 @@
// src/components/AISuggestedMilestones.js
import React, { useEffect, useState } from 'react';
const AISuggestedMilestones = ({ career, careerPathId, authFetch }) => {
const AISuggestedMilestones = ({ userId, career, careerPathId, authFetch, activeView }) => {
const [suggestedMilestones, setSuggestedMilestones] = useState([]);
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!career) return;
@ -11,33 +14,69 @@ const AISuggestedMilestones = ({ career, careerPathId, authFetch }) => {
{ title: `Mid-Level ${career}`, date: '2027-01-01', progress: 0 },
{ title: `Senior-Level ${career}`, date: '2030-01-01', progress: 0 },
]);
setSelected([]);
}, [career]);
const confirmMilestones = async () => {
for (const milestone of suggestedMilestones) {
await authFetch(`/api/premium/milestones`, {
method: 'POST',
body: JSON.stringify({
milestone_type: 'Career',
title: milestone.title,
description: milestone.title,
date: milestone.date,
career_path_id: careerPathId,
progress: milestone.progress,
status: 'planned',
}),
});
}
setSuggestedMilestones([]);
const toggleSelect = (index) => {
setSelected(prev =>
prev.includes(index) ? prev.filter(i => i !== index) : [...prev, index]
);
};
const confirmSelectedMilestones = async () => {
const milestonesToSend = selected.map(index => {
const m = suggestedMilestones[index];
return {
title: m.title,
description: m.title,
date: m.date,
progress: m.progress,
milestone_type: activeView || 'Career',
career_path_id: careerPathId,
};
});
try {
setLoading(true);
const res = await authFetch(`/api/premium/milestone`, {
method: 'POST',
body: JSON.stringify({ milestones: milestonesToSend }),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error('Failed to save selected milestones');
const data = await res.json();
console.log('Confirmed milestones:', data);
setSelected([]); // Clear selection
window.location.reload();
} catch (error) {
console.error('Error saving selected milestones:', error);
} finally {
setLoading(false);
}
};
if (!suggestedMilestones.length) return null;
return (
<div className="suggested-milestones">
<h4>AI-Suggested Milestones</h4>
<ul>{suggestedMilestones.map((m, i) => <li key={i}>{m.title} - {m.date}</li>)}</ul>
<button onClick={confirmMilestones}>Confirm Milestones</button>
<ul>
{suggestedMilestones.map((m, i) => (
<li key={i}>
<input
type="checkbox"
checked={selected.includes(i)}
onChange={() => toggleSelect(i)}
/>
{m.title} {m.date}
</li>
))}
</ul>
<button onClick={confirmSelectedMilestones} disabled={loading || selected.length === 0}>
{loading ? 'Saving...' : 'Confirm Selected'}
</button>
</div>
);
};

View File

@ -1,7 +1,29 @@
// src/components/CareerSelectDropdown.js
import React from 'react';
import React, { useEffect } from 'react';
const CareerSelectDropdown = ({ existingCareerPaths, selectedCareer, onChange, loading, authFetch }) => {
const fetchMilestones = (careerPathId) => {
authFetch(`/api/premium/milestones?careerPathId=${careerPathId}`)
.then((response) => response.json())
.then((data) => {
console.log('Milestones:', data);
// Handle milestones data as needed
})
.catch((error) => {
console.error('Error fetching milestones:', error);
});
};
const handleChange = (selected) => {
onChange(selected); // selected is the full career object
if (selected?.id) {
fetchMilestones(selected.id); // 🔥 Correct: use the id from the object
} else {
console.warn('No career ID found for selected object:', selected);
}
};
const CareerSelectDropdown = ({ existingCareerPaths, selectedCareer, onChange, loading }) => {
return (
<div className="career-select-dropdown">
<label>Select Career Path:</label>
@ -9,20 +31,28 @@ const CareerSelectDropdown = ({ existingCareerPaths, selectedCareer, onChange, l
<p>Loading career paths...</p>
) : (
<select
value={selectedCareer || ''}
onChange={(e) => onChange(e.target.value)}
>
value={selectedCareer?.id || ''}
onChange={(e) => {
const selectedId = e.target.value;
const selected = existingCareerPaths.find(path => path.id === selectedId);
handleChange(selected); // ✅ Pass the full object
}}
>
<option value="" disabled>Select career path...</option>
{existingCareerPaths.map((path) => (
<option key={path.id} value={path.id}>
{path.career_name}
</option>
))}
</select>
<option value="" disabled>Select career path...</option>
{existingCareerPaths.map((path) => (
<option key={path.career_path_id} value={path.career_name}>
{path.career_name}
</option>
))}
</select>
)}
</div>
);
};
export default CareerSelectDropdown;

View File

@ -0,0 +1,54 @@
.milestone-timeline-container {
position: relative;
margin-top: 40px;
height: 120px;
}
.milestone-timeline-line {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 4px;
background-color: #ccc;
transform: translateY(-50%);
}
.milestone-timeline-post {
position: absolute;
transform: translateX(-50%);
cursor: pointer;
}
.milestone-timeline-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #007bff;
margin: 0 auto;
}
.milestone-content {
margin-top: 10px;
text-align: center;
width: 160px;
background: white;
border: 1px solid #ddd;
padding: 6px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.progress-bar {
height: 6px;
background-color: #e0e0e0;
border-radius: 3px;
margin-top: 5px;
overflow: hidden;
}
.progress {
height: 100%;
background-color: #28a745;
}

View File

@ -3,29 +3,54 @@ import React, { useEffect, useState, useCallback } from 'react';
const today = new Date();
const MilestoneTimeline = ({ careerPathId, authFetch }) => {
const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView }) => {
const [milestones, setMilestones] = useState({ Career: [], Financial: [], Retirement: [] });
const [activeView, setActiveView] = useState('Career');
const [newMilestone, setNewMilestone] = useState({ title: '', date: '', progress: 0 });
const [newMilestone, setNewMilestone] = useState({ title: '', date: '', description: '', progress: 0 });
const [showForm, setShowForm] = useState(false);
const [editingMilestone, setEditingMilestone] = useState(null);
const fetchMilestones = useCallback(async () => {
if (!careerPathId) return;
if (!careerPathId) {
console.warn('No careerPathId provided.');
return;
}
const res = await authFetch(`api/premium/milestones`);
if (!res) return;
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: [] };
data.milestones.forEach((m) => {
if (m.career_path_id === careerPathId && categorized[m.milestone_type]) {
categorized[m.milestone_type].push(m);
}
});
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
@ -34,24 +59,67 @@ const MilestoneTimeline = ({ careerPathId, authFetch }) => {
}, [fetchMilestones]);
const saveMilestone = async () => {
const url = editingMilestone ? `/api/premium/milestones/${editingMilestone.id}` : `/api/premium/milestones`;
const url = editingMilestone
? `/api/premium/milestones/${editingMilestone.id}`
: `/api/premium/milestone`;
const method = editingMilestone ? 'PUT' : 'POST';
const payload = {
milestone_type: activeView,
title: newMilestone.title,
description: newMilestone.title,
description: newMilestone.description,
date: newMilestone.date,
career_path_id: careerPathId,
progress: newMilestone.progress,
status: newMilestone.progress === 100 ? 'completed' : 'planned',
};
const res = await authFetch(url, { method, body: JSON.stringify(payload) });
if (res && res.ok) {
fetchMilestones();
try {
console.log('Sending request to:', url);
console.log('HTTP Method:', method);
console.log('Payload:', payload);
const res = await authFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
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
return;
}
const savedMilestone = await res.json();
// Update state locally instead of fetching all milestones
setMilestones((prevMilestones) => {
const updatedMilestones = { ...prevMilestones };
if (editingMilestone) {
// Update the existing milestone
updatedMilestones[activeView] = updatedMilestones[activeView].map((m) =>
m.id === editingMilestone.id ? savedMilestone : m
);
} else {
// Add the new milestone
updatedMilestones[activeView].push(savedMilestone);
}
return updatedMilestones;
});
setShowForm(false);
setEditingMilestone(null);
setNewMilestone({ title: '', date: '', progress: 0 });
setNewMilestone({ title: '', description: '', date: '', progress: 0 });
} catch (error) {
console.error('Error saving milestone:', error);
}
};
@ -69,47 +137,61 @@ const MilestoneTimeline = ({ careerPathId, authFetch }) => {
return Math.min(Math.max(position, 0), 100);
};
console.log('Rendering view:', activeView, milestones?.[activeView]);
if (!activeView || !milestones?.[activeView]) {
return (
<div className="milestone-timeline">
<p>Loading milestones...</p>
</div>
);
}
return (
<div className="milestone-timeline">
<div className="view-selector">
{['Career', 'Financial', 'Retirement'].map((view) => (
<button key={view} className={activeView === view ? 'active' : ''} onClick={() => setActiveView(view)}>
{view}
</button>
))}
<button
key={view}
className={activeView === view ? 'active' : ''}
onClick={() => setActiveView(view)}
>
{view}
</button>
))}
</div>
<div className="timeline">
{milestones[activeView]?.map((m) => (
<div key={m.id} className="milestone-entry">
<h4>{m.title}</h4>
<p>{m.description}</p>
<p>Date: {m.date}</p>
<p>Progress: {m.progress}%</p>
</div>
))}
</div>
<button onClick={() => setShowForm(true)}>+ New Milestone</button>
<button onClick={() => {
if (showForm) {
setShowForm(false);
setEditingMilestone(null);
setNewMilestone({ title: '', date: '', progress: 0 });
} else {
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) })} />
<button onClick={saveMilestone}>{editingMilestone ? 'Update' : 'Add'} Milestone</button>
</div>
)}
<div className="timeline-container">
<div className="timeline-line" />
<div className="milestone-timeline-container">
<div className="milestone-timeline-line" />
{milestones[activeView]?.map((m) => (
<div key={m.id} className="milestone-post" style={{ left: `${calcPosition(m.date)}%` }} onClick={() => {
<div key={m.id} className="milestone-timeline-post" style={{ left: `${calcPosition(m.date)}%` }} onClick={() => {
setEditingMilestone(m);
setNewMilestone({ title: m.title, date: m.date, progress: m.progress });
setShowForm(true);
}}>
<div className="milestone-dot" />
<div className="milestone-timeline-dot" />
<div className="milestone-content">
<div className="title">{m.title}</div>
<div className="progress-bar">

View File

@ -7,6 +7,7 @@ import CareerSearch from './CareerSearch.js';
import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js';
import './MilestoneTracker.css';
import './MilestoneTimeline.css'; // Ensure this file contains styles for timeline-line and milestone-dot
const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const location = useLocation();
@ -17,6 +18,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
const [activeView, setActiveView] = useState("Career");
const apiURL = process.env.REACT_APP_API_URL;
@ -72,11 +75,12 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
fetchCareerPaths();
}, []);
const handleCareerChange = (careerName) => {
const match = existingCareerPaths.find(p => p.career_name === careerName);
if (match) {
setSelectedCareer(match);
setCareerPathId(match.career_path_id);
const handleCareerChange = (selected) => {
if (selected && selected.id && selected.career_name) {
setSelectedCareer(selected);
setCareerPathId(selected.id);
} else {
console.warn('Invalid career object received in handleCareerChange:', selected);
}
};
@ -103,17 +107,21 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
<CareerSelectDropdown
existingCareerPaths={existingCareerPaths}
selectedCareer={selectedCareer?.career_name}
selectedCareer={selectedCareer}
onChange={handleCareerChange}
loading={!existingCareerPaths.length}
authFetch={authFetch}
/>
<MilestoneTimeline careerPathId={careerPathId} authFetch={authFetch} />
<MilestoneTimeline careerPathId={careerPathId} authFetch={authFetch} activeView={activeView} setActiveView={setActiveView} />
{console.log('Passing careerPathId to MilestoneTimeline:', careerPathId)}
<AISuggestedMilestones career={selectedCareer?.career_name} careerPathId={careerPathId} authFetch={authFetch} />
<AISuggestedMilestones career={selectedCareer?.career_name} careerPathId={careerPathId} authFetch={authFetch} activeView={activeView}/>
<CareerSearch
onSelectCareer={(careerName) => setPendingCareerForModal(careerName)}
setPendingCareerForModal={setPendingCareerForModal}
authFetch={authFetch}
/>
{pendingCareerForModal && (

Binary file not shown.