diff --git a/backend/server3.js b/backend/server3.js
index 6509748..64e09f5 100644
--- a/backend/server3.js
+++ b/backend/server3.js
@@ -912,6 +912,246 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
}
});
+/************************************************************************
+ * MILESTONE IMPACTS ENDPOINTS
+ ************************************************************************/
+app.get('/api/premium/milestone-impacts', authenticatePremiumUser, async (req, res) => {
+ try {
+ // Example: GET /api/premium/milestone-impacts?milestone_id=12345
+ const { milestone_id } = req.query;
+ if (!milestone_id) {
+ return res.status(400).json({ error: 'milestone_id is required.' });
+ }
+
+ // Verify the milestone belongs to this user
+ const milestoneRow = await db.get(`
+ SELECT user_id
+ FROM milestones
+ WHERE id = ?
+ `, [milestone_id]);
+ if (!milestoneRow || milestoneRow.user_id !== req.userId) {
+ return res.status(404).json({ error: 'Milestone not found or not owned by this user.' });
+ }
+
+ // Fetch all impacts for that milestone
+ const impacts = await db.all(`
+ SELECT
+ id,
+ milestone_id,
+ impact_type,
+ direction,
+ amount,
+ start_date,
+ end_date,
+ created_at,
+ updated_at
+ FROM milestone_impacts
+ WHERE milestone_id = ?
+ ORDER BY created_at ASC
+ `, [milestone_id]);
+
+ res.json({ impacts });
+ } catch (err) {
+ console.error('Error fetching milestone impacts:', err);
+ res.status(500).json({ error: 'Failed to fetch milestone impacts.' });
+ }
+});
+
+app.post('/api/premium/milestone-impacts', authenticatePremiumUser, async (req, res) => {
+ try {
+ const {
+ milestone_id,
+ impact_type,
+ direction = 'subtract',
+ amount = 0,
+ start_date = null,
+ end_date = null,
+ created_at,
+ updated_at
+ } = req.body;
+
+ // Basic checks
+ if (!milestone_id || !impact_type) {
+ return res.status(400).json({
+ error: 'milestone_id and impact_type are required.'
+ });
+ }
+
+ // Confirm user owns the milestone
+ const milestoneRow = await db.get(`
+ SELECT user_id
+ FROM milestones
+ WHERE id = ?
+ `, [milestone_id]);
+ if (!milestoneRow || milestoneRow.user_id !== req.userId) {
+ return res.status(403).json({ error: 'Milestone not found or not owned by this user.' });
+ }
+
+ // Generate UUID for this new Impact
+ const newUUID = uuidv4();
+ const now = new Date().toISOString();
+ const finalCreated = created_at || now;
+ const finalUpdated = updated_at || now;
+
+ // Insert row WITH that UUID into the "id" column
+ await db.run(`
+ INSERT INTO milestone_impacts (
+ id,
+ milestone_id,
+ impact_type,
+ direction,
+ amount,
+ start_date,
+ end_date,
+ created_at,
+ updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, [
+ newUUID,
+ milestone_id,
+ impact_type,
+ direction,
+ amount,
+ start_date,
+ end_date,
+ finalCreated,
+ finalUpdated
+ ]);
+
+ // Fetch & return the inserted row
+ const insertedRow = await db.get(`
+ SELECT
+ id,
+ milestone_id,
+ impact_type,
+ direction,
+ amount,
+ start_date,
+ end_date,
+ created_at,
+ updated_at
+ FROM milestone_impacts
+ WHERE id = ?
+ `, [newUUID]);
+
+ return res.status(201).json(insertedRow);
+ } catch (err) {
+ console.error('Error creating milestone impact:', err);
+ return res.status(500).json({ error: 'Failed to create milestone impact.' });
+ }
+});
+
+/************************************************************************
+ * UPDATE an existing milestone impact (PUT)
+ ************************************************************************/
+app.put('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser, async (req, res) => {
+ try {
+ const { impactId } = req.params;
+ const {
+ milestone_id,
+ impact_type,
+ direction = 'subtract',
+ amount = 0,
+ start_date = null,
+ end_date = null
+ } = req.body;
+
+ // 1) Check this impact belongs to user
+ const existing = await db.get(`
+ SELECT mi.id, m.user_id
+ FROM milestone_impacts mi
+ JOIN milestones m ON mi.milestone_id = m.id
+ WHERE mi.id = ?
+ `, [impactId]);
+ if (!existing || existing.user_id !== req.userId) {
+ return res.status(404).json({ error: 'Impact not found or not owned by user.' });
+ }
+
+ const now = new Date().toISOString();
+
+ // 2) Update
+ await db.run(`
+ UPDATE milestone_impacts
+ SET
+ milestone_id = ?,
+ impact_type = ?,
+ direction = ?,
+ amount = ?,
+ start_date = ?,
+ end_date = ?,
+ updated_at = ?
+ WHERE id = ?
+ `, [
+ milestone_id,
+ impact_type,
+ direction,
+ amount,
+ start_date,
+ end_date,
+ now,
+ impactId
+ ]);
+
+ // 3) Return updated
+ const updatedRow = await db.get(`
+ SELECT
+ id,
+ milestone_id,
+ impact_type,
+ direction,
+ amount,
+ start_date,
+ end_date,
+ created_at,
+ updated_at
+ FROM milestone_impacts
+ WHERE id = ?
+ `, [impactId]);
+
+ res.json(updatedRow);
+ } catch (err) {
+ console.error('Error updating milestone impact:', err);
+ res.status(500).json({ error: 'Failed to update milestone impact.' });
+ }
+});
+
+/************************************************************************
+ * DELETE an existing milestone impact
+ ************************************************************************/
+app.delete('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser, async (req, res) => {
+ try {
+ const { impactId } = req.params;
+
+ // 1) check ownership
+ const existing = await db.get(`
+ SELECT mi.id, m.user_id
+ FROM milestone_impacts mi
+ JOIN milestones m ON mi.milestone_id = m.id
+ WHERE mi.id = ?
+ `, [impactId]);
+
+ if (!existing || existing.user_id !== req.userId) {
+ return res.status(404).json({ error: 'Impact not found or not owned by user.' });
+ }
+
+ // 2) Delete
+ await db.run(`
+ DELETE FROM milestone_impacts
+ WHERE id = ?
+ `, [impactId]);
+
+ res.json({ message: 'Impact deleted successfully.' });
+ } catch (err) {
+ console.error('Error deleting milestone impact:', err);
+ res.status(500).json({ error: 'Failed to delete milestone impact.' });
+ }
+});
+
+app.use((req, res) => {
+ console.warn(`No route matched for ${req.method} ${req.originalUrl}`);
+ res.status(404).json({ error: 'Not found' });
+});
app.listen(PORT, () => {
console.log(`Premium server running on http://localhost:${PORT}`);
diff --git a/src/components/MilestoneAddModal.js b/src/components/MilestoneAddModal.js
new file mode 100644
index 0000000..07b580b
--- /dev/null
+++ b/src/components/MilestoneAddModal.js
@@ -0,0 +1,265 @@
+// src/components/MilestoneAddModal.js
+import React, { useState, useEffect } from 'react';
+import authFetch from '../utils/authFetch.js';
+
+const MilestoneAddModal = ({
+ show,
+ onClose,
+ defaultScenarioId,
+ scenarioId, // which scenario this milestone applies to
+ editMilestone, // if editing an existing milestone, pass its data
+ apiURL
+}) => {
+ // Basic milestone fields
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+
+ // We'll store an array of impacts. Each impact is { impact_type, direction, amount, start_month, end_month }
+ const [impacts, setImpacts] = useState([]);
+
+ // On open, if editing, fill in existing fields
+ useEffect(() => {
+ if (!show) return; // if modal is hidden, do nothing
+
+ if (editMilestone) {
+ setTitle(editMilestone.title || '');
+ setDescription(editMilestone.description || '');
+ // If editing, you might fetch existing impacts from the server or they could be passed in
+ if (editMilestone.impacts) {
+ setImpacts(editMilestone.impacts);
+ } else {
+ // fetch from backend if needed
+ // e.g. GET /api/premium/milestones/:id/impacts
+ }
+ } else {
+ // Creating a new milestone
+ setTitle('');
+ setDescription('');
+ setImpacts([]);
+ }
+ }, [show, editMilestone]);
+
+ // Handler: add a new blank impact
+ const handleAddImpact = () => {
+ setImpacts((prev) => [
+ ...prev,
+ {
+ impact_type: 'ONE_TIME',
+ direction: 'subtract',
+ amount: 0,
+ start_month: 0,
+ end_month: null
+ }
+ ]);
+ };
+
+ // Handler: update a single impact in the array
+ const handleImpactChange = (index, field, value) => {
+ setImpacts((prev) => {
+ const updated = [...prev];
+ updated[index] = { ...updated[index], [field]: value };
+ return updated;
+ });
+ };
+
+ // Handler: remove an impact row
+ const handleRemoveImpact = (index) => {
+ setImpacts((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ // Handler: Save everything to the server
+ const handleSave = async () => {
+ try {
+ let milestoneId;
+ if (editMilestone) {
+ // 1) Update existing milestone
+ milestoneId = editMilestone.id;
+ await authFetch(`${apiURL}/premium/milestones/${milestoneId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title,
+ description,
+ scenario_id: scenarioId,
+ // Possibly other fields
+ })
+ });
+ // Then handle impacts below...
+ } else {
+ // 1) Create new milestone
+ const res = await authFetch(`${apiURL}/premium/milestones`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title,
+ description,
+ scenario_id: scenarioId
+ })
+ });
+ if (!res.ok) throw new Error('Failed to create milestone');
+ const created = await res.json();
+ milestoneId = created.id; // assuming the response returns { id: newMilestoneId }
+ }
+
+ // 2) For the impacts, we can do a batch approach or individual calls
+ // For simplicity, let's do multiple POST calls
+ for (const impact of impacts) {
+ // If editing, you might do a PUT if the impact already has an id
+ await authFetch(`${apiURL}/premium/milestone-impacts`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ milestone_id: milestoneId,
+ impact_type: impact.impact_type,
+ direction: impact.direction,
+ amount: parseFloat(impact.amount) || 0,
+ start_month: parseInt(impact.start_month, 10) || 0,
+ end_month: impact.end_month !== null
+ ? parseInt(impact.end_month, 10)
+ : null,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString()
+ })
+ });
+ }
+
+ // Done, close modal
+ onClose();
+ } catch (err) {
+ console.error('Failed to save milestone + impacts:', err);
+ // Show some UI error if needed
+ }
+ };
+
+ if (!show) return null;
+
+ return (
+
+
+
+ {editMilestone ? 'Edit Milestone' : 'Add Milestone'}
+
+
+
+
+ setTitle(e.target.value)}
+ className="border w-full px-2 py-1"
+ />
+
+
+
+
+
+
+ {/* Impacts Section */}
+
Financial Impacts
+ {impacts.map((impact, i) => (
+
+
+
Impact #{i + 1}
+
+
+
+ {/* Impact Type */}
+
+
+
+
+
+ {/* Direction */}
+
+
+
+
+
+ {/* Amount */}
+
+
+
+ handleImpactChange(i, 'amount', e.target.value)
+ }
+ className="border px-2 py-1 w-full"
+ />
+
+
+ {/* Start Month */}
+
+
+
+ handleImpactChange(i, 'start_month', e.target.value)
+ }
+ className="border px-2 py-1 w-full"
+ />
+
+
+ {/* End Month (for MONTHLY, can be null/blank if indefinite) */}
+ {impact.impact_type === 'MONTHLY' && (
+
+
+
+ handleImpactChange(i, 'end_month', e.target.value || null)
+ }
+ className="border px-2 py-1 w-full"
+ placeholder="Leave blank for indefinite"
+ />
+
+ )}
+
+ ))}
+
+
+
+ {/* Modal Actions */}
+
+
+
+
+
+
+ );
+};
+
+export default MilestoneAddModal;
diff --git a/src/components/MilestoneTimeline.js b/src/components/MilestoneTimeline.js
index caac548..2b15dcf 100644
--- a/src/components/MilestoneTimeline.js
+++ b/src/components/MilestoneTimeline.js
@@ -5,20 +5,30 @@ const today = new Date();
const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView }) => {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
+
+ // The "new or edit" milestone form state
const [newMilestone, setNewMilestone] = useState({
title: '',
description: '',
date: '',
- progress: '',
- newSalary: ''
+ progress: 0,
+ newSalary: '',
+ // Each impact can have: { id?, impact_type, direction, amount, start_date, end_date }
+ impacts: []
});
+
+ // We track which existing impacts the user removed, so we can DELETE them
+ const [impactsToDelete, setImpactsToDelete] = useState([]);
+
const [showForm, setShowForm] = useState(false);
const [editingMilestone, setEditingMilestone] = useState(null);
- const [showTaskForm, setShowTaskForm] = useState(null); // store milestoneId or null
+
+ // For tasks
+ const [showTaskForm, setShowTaskForm] = useState(null);
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
-
+
/**
- * Fetch all milestones (and their tasks) for this careerPathId
+ * Fetch all milestones (and their tasks) for this careerPathId.
* Then categorize them by milestone_type: 'Career' or 'Financial'.
*/
const fetchMilestones = useCallback(async () => {
@@ -35,10 +45,6 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
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]) {
@@ -54,19 +60,61 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
}
}, [careerPathId, authFetch]);
- // 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
+ * Async function to edit an existing milestone.
+ * Fetch its impacts, populate newMilestone, show the form.
+ */
+ const handleEditMilestone = async (m) => {
+ try {
+ // Reset impactsToDelete whenever we edit a new milestone
+ setImpactsToDelete([]);
+
+ // Fetch existing impacts for milestone "m"
+ const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
+ if (!res.ok) {
+ console.error('Failed to fetch milestone impacts, status:', res.status);
+ throw new Error(`HTTP ${res.status}`);
+ }
+ const data = await res.json();
+ const fetchedImpacts = data.impacts || [];
+
+ // Populate the newMilestone form
+ setNewMilestone({
+ title: m.title || '',
+ description: m.description || '',
+ date: m.date || '',
+ progress: m.progress || 0,
+ newSalary: m.new_salary || '',
+ impacts: fetchedImpacts.map((imp) => ({
+ // If the DB row has id, we'll store it for PUT or DELETE
+ id: imp.id,
+ impact_type: imp.impact_type || 'ONE_TIME',
+ direction: imp.direction || 'subtract',
+ amount: imp.amount || 0,
+ start_date: imp.start_date || '',
+ end_date: imp.end_date || ''
+ }))
+ });
+
+ setEditingMilestone(m);
+ setShowForm(true);
+
+ } catch (err) {
+ console.error('Error in handleEditMilestone:', err);
+ }
+ };
+
+ /**
+ * Create or update a milestone (plus handle impacts).
*/
const saveMilestone = async () => {
if (!activeView) return;
+ // If editing, we do PUT; otherwise POST
const url = editingMilestone
? `/api/premium/milestones/${editingMilestone.id}`
: `/api/premium/milestone`;
@@ -80,9 +128,10 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
career_path_id: careerPathId,
progress: newMilestone.progress,
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
- new_salary: activeView === 'Financial' && newMilestone.newSalary
- ? parseFloat(newMilestone.newSalary)
- : null
+ new_salary:
+ activeView === 'Financial' && newMilestone.newSalary
+ ? parseFloat(newMilestone.newSalary)
+ : null
};
try {
@@ -103,6 +152,83 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
const savedMilestone = await res.json();
console.log('Milestone saved/updated:', savedMilestone);
+ // If Financial, handle the "impacts"
+ if (activeView === 'Financial') {
+ // 1) Delete impacts that user removed
+ for (const impactId of impactsToDelete) {
+ if (impactId) {
+ console.log('Deleting old impact', impactId);
+ const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, {
+ method: 'DELETE'
+ });
+ if (!delRes.ok) {
+ console.error('Failed to delete old impact', impactId, await delRes.text());
+ }
+ }
+ }
+
+ // 2) For each current impact in newMilestone.impacts
+ // We'll track the index so we can store the newly created ID if needed
+ for (let i = 0; i < newMilestone.impacts.length; i++) {
+ const impact = newMilestone.impacts[i];
+ if (impact.id) {
+ // existing row => PUT
+ const putPayload = {
+ milestone_id: savedMilestone.id,
+ impact_type: impact.impact_type,
+ direction: impact.direction,
+ amount: parseFloat(impact.amount) || 0,
+ start_date: impact.start_date || null,
+ end_date: impact.end_date || null
+ };
+ console.log('Updating milestone impact:', impact.id, putPayload);
+ const impRes = await authFetch(`/api/premium/milestone-impacts/${impact.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(putPayload)
+ });
+ if (!impRes.ok) {
+ const errImp = await impRes.json();
+ console.error('Failed to update milestone impact:', errImp);
+ } else {
+ const updatedImpact = await impRes.json();
+ console.log('Updated Impact:', updatedImpact);
+ }
+ } else {
+ // [FIX HERE] If no id => POST to create new
+ const impactPayload = {
+ milestone_id: savedMilestone.id,
+ impact_type: impact.impact_type,
+ direction: impact.direction,
+ amount: parseFloat(impact.amount) || 0,
+ start_date: impact.start_date || null,
+ end_date: impact.end_date || null
+ };
+ console.log('Creating milestone impact:', impactPayload);
+ const impRes = await authFetch('/api/premium/milestone-impacts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(impactPayload)
+ });
+
+ if (!impRes.ok) {
+ const errImp = await impRes.json();
+ console.error('Failed to create milestone impact:', errImp);
+ } else {
+ const createdImpact = await impRes.json();
+ if (createdImpact && createdImpact.id) {
+ setNewMilestone(prev => {
+ const newImpacts = [...prev.impacts];
+ newImpacts[i] = { ...newImpacts[i], id: createdImpact.id };
+ return { ...prev, impacts: newImpacts };
+ });
+ }
+
+ }
+ }
+ }
+ }
+
// Update local state so we don't have to refetch everything
setMilestones((prev) => {
const updated = { ...prev };
@@ -119,18 +245,28 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
// Reset form
setShowForm(false);
setEditingMilestone(null);
+
+ // [FIX HERE] The next line ensures the updated or newly created impact IDs
+ // stay in the local state if the user tries to edit the milestone again
+ // in the same session.
setNewMilestone({
title: '',
description: '',
date: '',
progress: 0,
- newSalary: ''
+ newSalary: '',
+ impacts: []
});
+ setImpactsToDelete([]);
+
} catch (err) {
console.error('Error saving milestone:', err);
}
};
+ /**
+ * Add a new task to an existing milestone
+ */
const addTask = async (milestoneId) => {
try {
const taskPayload = {
@@ -157,15 +293,13 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
// Update the milestone's tasks in local state
setMilestones((prev) => {
- // We need to find which classification this milestone belongs to
const newState = { ...prev };
- // Could be Career or Financial
['Career', 'Financial'].forEach((category) => {
newState[category] = newState[category].map((m) => {
if (m.id === milestoneId) {
return {
...m,
- tasks: [...m.tasks, createdTask]
+ tasks: [...(m.tasks || []), createdTask]
};
}
return m;
@@ -174,17 +308,14 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
return newState;
});
- // Reset the addTask form
setNewTask({ title: '', description: '', due_date: '' });
- setShowTaskForm(null); // close the task form
+ setShowTaskForm(null);
} catch (err) {
console.error('Error adding task:', err);
}
};
- /**
- * Figure out the timeline's "end" date by scanning all milestones.
- */
+ // For timeline
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
const lastDate = allMilestonesCombined.reduce((latest, m) => {
const d = new Date(m.date);
@@ -195,12 +326,54 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
const start = today.getTime();
const end = lastDate.getTime();
const dateVal = new Date(dateString).getTime();
- if (end === start) return 0; // edge case if only one date
+ if (end === start) return 0;
const ratio = (dateVal - start) / (end - start);
return Math.min(Math.max(ratio * 100, 0), 100);
};
- // If activeView not set or the array is missing, show a loading or empty state
+ /**
+ * Add a new empty impact (no id => new)
+ */
+ const addNewImpact = () => {
+ setNewMilestone((prev) => ({
+ ...prev,
+ impacts: [
+ ...prev.impacts,
+ {
+ // no 'id' => brand new
+ impact_type: 'ONE_TIME',
+ direction: 'subtract',
+ amount: 0,
+ start_date: '',
+ end_date: ''
+ }
+ ]
+ }));
+ };
+
+ /**
+ * Remove an impact from the UI. If it had an `id`, track it in impactsToDelete for later DELETE call.
+ */
+ const removeImpact = (idx) => {
+ setNewMilestone((prev) => {
+ const newImpacts = [...prev.impacts];
+ const removed = newImpacts[idx];
+ if (removed.id) {
+ setImpactsToDelete((old) => [...old, removed.id]);
+ }
+ newImpacts.splice(idx, 1);
+ return { ...prev, impacts: newImpacts };
+ });
+ };
+
+ const updateImpact = (idx, field, value) => {
+ setNewMilestone((prev) => {
+ const newImpacts = [...prev.impacts];
+ newImpacts[idx] = { ...newImpacts[idx], [field]: value };
+ return { ...prev, impacts: newImpacts };
+ });
+ };
+
if (!activeView || !milestones[activeView]) {
return (
@@ -211,7 +384,6 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
return (
- {/* View selector */}
{['Career', 'Financial'].map((view) => (
)}
- {/* Timeline rendering */}
+
{milestones[activeView].map((m) => {
const leftPos = calcPosition(m.date);
return (
@@ -304,28 +553,17 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
>
{
- // 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);
- }}
+ onClick={() => handleEditMilestone(m)}
/>
{m.title}
{m.description &&
{m.description}
}
+
{m.date}
- {/* Existing tasks */}
{m.tasks && m.tasks.length > 0 && (
{m.tasks.map((t) => (
@@ -338,15 +576,15 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
)}
- {/* Button to show/hide Add Task form */}
-
{
- setShowTaskForm(showTaskForm === m.id ? null : m.id);
- setNewTask({ title: '', description: '', due_date: '' });
- }}>
+ {
+ setShowTaskForm(showTaskForm === m.id ? null : m.id);
+ setNewTask({ title: '', description: '', due_date: '' });
+ }}
+ >
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
- {/* Conditionally render the Add Task form for this milestone */}
{showTaskForm === m.id && (
{
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
+ const [showEditModal, setShowEditModal] = useState(false);
+
const apiURL = process.env.REACT_APP_API_URL;
// Possibly loaded from location.state
@@ -305,6 +308,19 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch}
/>
+
+ {/* SCENARIO EDIT MODAL */}
+ setShowEditModal(false)}
+ financialProfile={financialProfile}
+ setFinancialProfile={setFinancialProfile}
+ collegeProfile={collegeProfile}
+ setCollegeProfile={setCollegeProfile}
+ apiURL={apiURL}
+ authFetch={authFetch}
+ />
+
{pendingCareerForModal && (
{
// handleConfirmCareerSelection logic
diff --git a/src/components/ScenarioEditModal.js b/src/components/ScenarioEditModal.js
index 52dbf4c..880abda 100644
--- a/src/components/ScenarioEditModal.js
+++ b/src/components/ScenarioEditModal.js
@@ -1,6 +1,6 @@
// src/components/ScenarioEditModal.js
import React, { useState, useEffect } from 'react';
-import authFetch from '../utils/authFetch';
+import authFetch from '../utils/authFetch.js';
const ScenarioEditModal = ({
show,
diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js
index 3532458..923d924 100644
--- a/src/utils/FinancialProjectionService.js
+++ b/src/utils/FinancialProjectionService.js
@@ -2,7 +2,7 @@ import moment from 'moment';
/**
* Single-filer federal tax calculation (2023).
- * Includes standard deduction ($13,850).
+ * Includes standard deduction ($13,850).
*/
const APPROX_STATE_TAX_RATES = {
AL: 0.05,
@@ -58,10 +58,6 @@ const APPROX_STATE_TAX_RATES = {
DC: 0.05
};
-/**
- * 2) Single-filer federal tax calculation (2023).
- * Includes standard deduction ($13,850).
- */
function calculateAnnualFederalTaxSingle(annualIncome) {
const STANDARD_DEDUCTION_SINGLE = 13850;
const taxableIncome = Math.max(0, annualIncome - STANDARD_DEDUCTION_SINGLE);
@@ -92,20 +88,11 @@ function calculateAnnualFederalTaxSingle(annualIncome) {
return tax;
}
-/**
- * 3) Example approximate state tax calculation.
- * Retrieves a single "effective" tax rate from the dictionary
- * and returns a simple multiplication of annualIncome * rate.
- */
function calculateAnnualStateTax(annualIncome, stateCode) {
- // Default to 5% if not found in dictionary
const rate = APPROX_STATE_TAX_RATES[stateCode] ?? 0.05;
return annualIncome * rate;
}
-/**
- * Calculate the standard monthly loan payment for principal, annualRate (%) and term (years).
- */
function calculateLoanPayment(principal, annualRate, years) {
if (principal <= 0) return 0;
@@ -123,6 +110,16 @@ function calculateLoanPayment(principal, annualRate, years) {
/**
* Main projection function with bracket-based FEDERAL + optional STATE tax logic.
+ *
+ * milestoneImpacts: [
+ * {
+ * impact_type: 'ONE_TIME' | 'MONTHLY',
+ * direction: 'add' | 'subtract',
+ * amount: number,
+ * start_date: 'YYYY-MM-DD',
+ * end_date?: 'YYYY-MM-DD' | null
+ * }, ...
+ * ]
*/
export function simulateFinancialProjection(userProfile) {
const {
@@ -168,42 +165,44 @@ export function simulateFinancialProjection(userProfile) {
// Potential override
programLength,
- // NEW: user’s state code (e.g. 'CA', 'NY', 'TX', etc.)
- stateCode = 'TX', // default to TX (no state income tax)
+ // State code
+ stateCode = 'TX',
+
+ // Milestone impacts (with dates, add/subtract logic)
+ milestoneImpacts = []
} = userProfile;
+ // scenario start date
+ const scenarioStart = startDate ? new Date(startDate) : new Date();
+
// 1. Monthly loan payment if not deferring
let monthlyLoanPayment = loanDeferralUntilGraduation
? 0
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
- // 2. Determine how many credit hours remain
+ // 2. Determine credit hours
let requiredCreditHours = 120;
switch (programType) {
case "Associate's Degree":
requiredCreditHours = 60;
break;
- case "Bachelor's Degree":
- requiredCreditHours = 120;
- break;
case "Master's Degree":
requiredCreditHours = 30;
break;
case "Doctoral Degree":
requiredCreditHours = 60;
break;
- default:
- requiredCreditHours = 120;
+ // otherwise Bachelor's
}
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
const dynamicProgramLength = Math.ceil(remainingCreditHours / creditHoursPerYear);
const finalProgramLength = programLength || dynamicProgramLength;
- // 3. Net annual tuition after aid
+ // 3. Net annual tuition
const netAnnualTuition = Math.max(0, calculatedTuition - annualFinancialAid);
const totalTuitionCost = netAnnualTuition * finalProgramLength;
- // 4. Setup lumps per year
+ // 4. lumps
let lumpsPerYear, lumpsSchedule;
switch (academicCalendar) {
case 'semester':
@@ -228,8 +227,8 @@ export function simulateFinancialProjection(userProfile) {
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
// 5. Simulation loop
- const maxMonths = 240;
- let date = startDate ? new Date(startDate) : new Date();
+ const maxMonths = 240; // 20 years
+ let date = new Date(scenarioStart);
let loanBalance = Math.max(studentLoanAmount, 0);
let loanPaidOffMonth = null;
@@ -238,55 +237,47 @@ export function simulateFinancialProjection(userProfile) {
let projectionData = [];
let wasInDeferral = inCollege && loanDeferralUntilGraduation;
- const graduationDate = gradDate ? new Date(gradDate) : null;
+ const graduationDateObj = gradDate ? new Date(gradDate) : null;
- // YTD tracking for each year (federal + state)
- // e.g. taxStateByYear[2025] = { federalYtdGross, federalYtdTaxSoFar, stateYtdGross, stateYtdTaxSoFar }
+ // For YTD taxes
const taxStateByYear = {};
for (let month = 0; month < maxMonths; month++) {
date.setMonth(date.getMonth() + 1);
const currentYear = date.getFullYear();
- // Check if loan is fully paid
+ // elapsed months since scenario start
+ const elapsedMonths = moment(date).diff(moment(scenarioStart), 'months');
+
+ // if loan paid
if (loanBalance <= 0 && !loanPaidOffMonth) {
loanPaidOffMonth = `${currentYear}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
- // Are we still in college?
+ // are we in college?
let stillInCollege = false;
if (inCollege) {
- if (graduationDate) {
- stillInCollege = date < graduationDate;
+ if (graduationDateObj) {
+ stillInCollege = date < graduationDateObj;
} else {
- const simStart = startDate ? new Date(startDate) : new Date();
- const elapsedMonths =
- (date.getFullYear() - simStart.getFullYear()) * 12 +
- (date.getMonth() - simStart.getMonth());
stillInCollege = (elapsedMonths < totalAcademicMonths);
}
}
- // 6. Tuition lumps
+ // 6. tuition lumps
let tuitionCostThisMonth = 0;
if (stillInCollege && lumpsPerYear > 0) {
- const simStart = startDate ? new Date(startDate) : new Date();
- const elapsedMonths =
- (date.getFullYear() - simStart.getFullYear()) * 12 +
- (date.getMonth() - simStart.getMonth());
-
const academicYearIndex = Math.floor(elapsedMonths / 12);
const monthInYear = elapsedMonths % 12;
-
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) {
tuitionCostThisMonth = lumpAmount;
}
}
// 7. Exiting college?
- const nowExitingCollege = (wasInDeferral && !stillInCollege);
+ const nowExitingCollege = wasInDeferral && !stillInCollege;
- // 8. Deferral lumps get added to loan
+ // 8. deferral lumps
if (stillInCollege && loanDeferralUntilGraduation) {
if (tuitionCostThisMonth > 0) {
loanBalance += tuitionCostThisMonth;
@@ -294,15 +285,48 @@ export function simulateFinancialProjection(userProfile) {
}
}
- // 9. Gross monthly income
+ // 9. Base monthly income
let grossMonthlyIncome = 0;
- if (!inCollege || !stillInCollege) {
+ if (!stillInCollege) {
grossMonthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
} else {
grossMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12);
}
- // 10. Tax calculations
+ // Track extra subtracting impacts in a separate variable
+ let extraImpactsThisMonth = 0;
+
+ // 9b. Apply milestone impacts
+ milestoneImpacts.forEach((impact) => {
+ const startOffset = impact.start_date
+ ? moment(impact.start_date).diff(moment(scenarioStart), 'months')
+ : 0;
+ let endOffset = Infinity;
+ if (impact.end_date && impact.end_date.trim() !== '') {
+ endOffset = moment(impact.end_date).diff(moment(scenarioStart), 'months');
+ }
+
+ if (impact.impact_type === 'ONE_TIME') {
+ if (elapsedMonths === startOffset) {
+ if (impact.direction === 'add') {
+ grossMonthlyIncome += impact.amount;
+ } else {
+ extraImpactsThisMonth += impact.amount;
+ }
+ }
+ } else {
+ // 'MONTHLY'
+ if (elapsedMonths >= startOffset && elapsedMonths <= endOffset) {
+ if (impact.direction === 'add') {
+ grossMonthlyIncome += impact.amount;
+ } else {
+ extraImpactsThisMonth += impact.amount;
+ }
+ }
+ }
+ });
+
+ // 10. Taxes
if (!taxStateByYear[currentYear]) {
taxStateByYear[currentYear] = {
federalYtdGross: 0,
@@ -312,19 +336,18 @@ export function simulateFinancialProjection(userProfile) {
};
}
- // Update YTD gross for federal + state
+ // accumulate YTD gross
taxStateByYear[currentYear].federalYtdGross += grossMonthlyIncome;
taxStateByYear[currentYear].stateYtdGross += grossMonthlyIncome;
- // Compute total fed tax for the year so far
+ // fed tax
const newFedTaxTotal = calculateAnnualFederalTaxSingle(
taxStateByYear[currentYear].federalYtdGross
);
- // Monthly fed tax = difference
const monthlyFederalTax = newFedTaxTotal - taxStateByYear[currentYear].federalYtdTaxSoFar;
taxStateByYear[currentYear].federalYtdTaxSoFar = newFedTaxTotal;
- // Compute total state tax for the year so far
+ // state tax
const newStateTaxTotal = calculateAnnualStateTax(
taxStateByYear[currentYear].stateYtdGross,
stateCode
@@ -332,26 +355,20 @@ export function simulateFinancialProjection(userProfile) {
const monthlyStateTax = newStateTaxTotal - taxStateByYear[currentYear].stateYtdTaxSoFar;
taxStateByYear[currentYear].stateYtdTaxSoFar = newStateTaxTotal;
- // Combined monthly tax
const combinedTax = monthlyFederalTax + monthlyStateTax;
-
- // Net monthly income after taxes
const netMonthlyIncome = grossMonthlyIncome - combinedTax;
// 11. Expenses & loan
let thisMonthLoanPayment = 0;
- let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth;
+ // now include tuition lumps + any 'subtract' impacts
+ let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth;
- // Re-amortize if just exited college
+ // re-amortize after deferral ends
if (nowExitingCollege) {
- monthlyLoanPayment = calculateLoanPayment(
- loanBalance,
- interestRate,
- 10
- );
+ monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, 10);
}
- // If not deferring, we do normal payments
+ // if deferring
if (stillInCollege && loanDeferralUntilGraduation) {
const interestForMonth = loanBalance * (interestRate / 100 / 12);
loanBalance += interestForMonth;
@@ -370,11 +387,11 @@ export function simulateFinancialProjection(userProfile) {
}
}
- // 12. leftover after mandatory expenses
+ // leftover after mandatory expenses
let leftover = netMonthlyIncome - totalMonthlyExpenses;
if (leftover < 0) leftover = 0;
- // Baseline contributions
+ // baseline contributions
const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution;
let effectiveRetirementContribution = 0;
let effectiveEmergencyContribution = 0;
@@ -383,12 +400,9 @@ export function simulateFinancialProjection(userProfile) {
effectiveRetirementContribution = monthlyRetirementContribution;
effectiveEmergencyContribution = monthlyEmergencyContribution;
leftover -= baselineContributions;
- } else {
- effectiveRetirementContribution = 0;
- effectiveEmergencyContribution = 0;
}
- // Check shortfall
+ // shortfall check
const totalWantedContributions = effectiveRetirementContribution + effectiveEmergencyContribution;
const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
let shortfall = actualExpensesPaid - netMonthlyIncome;
@@ -397,7 +411,7 @@ export function simulateFinancialProjection(userProfile) {
currentEmergencySavings -= canCover;
shortfall -= canCover;
if (shortfall > 0) {
- // bankrupt scenario
+ // bankrupt scenario, end
break;
}
}
@@ -433,7 +447,7 @@ export function simulateFinancialProjection(userProfile) {
loanPaymentThisMonth: Math.round(thisMonthLoanPayment * 100) / 100
});
- wasInDeferral = (stillInCollege && loanDeferralUntilGraduation);
+ wasInDeferral = stillInCollege && loanDeferralUntilGraduation;
}
return {
diff --git a/upser_profile.db b/upser_profile.db
new file mode 100644
index 0000000..fcf6726
Binary files /dev/null and b/upser_profile.db differ
diff --git a/user_profile.db b/user_profile.db
index a3a7644..89df59e 100644
Binary files a/user_profile.db and b/user_profile.db differ