Updated impacts data flow to incorporate generated/saved UUIDs for each impact on creation.

This commit is contained in:
Josh 2025-04-21 13:08:56 +00:00
parent 126a17543c
commit c946144334
8 changed files with 904 additions and 131 deletions

View File

@ -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}`);

View File

@ -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 (
<div className="modal-backdrop">
<div className="modal-container">
<h2 className="text-xl font-bold mb-2">
{editMilestone ? 'Edit Milestone' : 'Add Milestone'}
</h2>
<div className="mb-3">
<label className="block font-semibold">Title</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="border w-full px-2 py-1"
/>
</div>
<div className="mb-3">
<label className="block font-semibold">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="border w-full px-2 py-1"
/>
</div>
{/* Impacts Section */}
<h3 className="text-lg font-semibold mt-4">Financial Impacts</h3>
{impacts.map((impact, i) => (
<div key={i} className="border rounded p-2 my-2">
<div className="flex items-center justify-between">
<p>Impact #{i + 1}</p>
<button
className="text-red-500"
onClick={() => handleRemoveImpact(i)}
>
Remove
</button>
</div>
{/* Impact Type */}
<div className="mt-2">
<label className="block font-semibold">Type</label>
<select
value={impact.impact_type}
onChange={(e) =>
handleImpactChange(i, 'impact_type', e.target.value)
}
>
<option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option>
</select>
</div>
{/* Direction */}
<div className="mt-2">
<label className="block font-semibold">Direction</label>
<select
value={impact.direction}
onChange={(e) =>
handleImpactChange(i, 'direction', e.target.value)
}
>
<option value="add">Add (Income)</option>
<option value="subtract">Subtract (Expense)</option>
</select>
</div>
{/* Amount */}
<div className="mt-2">
<label className="block font-semibold">Amount</label>
<input
type="number"
value={impact.amount}
onChange={(e) =>
handleImpactChange(i, 'amount', e.target.value)
}
className="border px-2 py-1 w-full"
/>
</div>
{/* Start Month */}
<div className="mt-2">
<label className="block font-semibold">Start Month</label>
<input
type="number"
value={impact.start_month}
onChange={(e) =>
handleImpactChange(i, 'start_month', e.target.value)
}
className="border px-2 py-1 w-full"
/>
</div>
{/* End Month (for MONTHLY, can be null/blank if indefinite) */}
{impact.impact_type === 'MONTHLY' && (
<div className="mt-2">
<label className="block font-semibold">End Month (optional)</label>
<input
type="number"
value={impact.end_month || ''}
onChange={(e) =>
handleImpactChange(i, 'end_month', e.target.value || null)
}
className="border px-2 py-1 w-full"
placeholder="Leave blank for indefinite"
/>
</div>
)}
</div>
))}
<button onClick={handleAddImpact} className="bg-gray-200 px-3 py-1 my-2">
+ Add Impact
</button>
{/* Modal Actions */}
<div className="flex justify-end mt-4">
<button className="mr-2" onClick={onClose}>
Cancel
</button>
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleSave}>
Save Milestone
</button>
</div>
</div>
</div>
);
};
export default MilestoneAddModal;

View File

@ -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 (
<div className="milestone-timeline">
@ -211,7 +384,6 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
return (
<div className="milestone-timeline">
{/* View selector */}
<div className="view-selector">
{['Career', 'Financial'].map((view) => (
<button
@ -224,15 +396,23 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
))}
</div>
{/* New Milestone button */}
<button
onClick={() => {
if (showForm) {
// Cancel form
setShowForm(false);
setEditingMilestone(null);
setNewMilestone({ title: '', description: '', date: '', progress: 0, newSalary: '' });
setNewMilestone({
title: '',
description: '',
date: '',
progress: 0,
newSalary: '',
impacts: []
});
setImpactsToDelete([]);
} else {
// Show form
setShowForm(true);
}
}}
@ -255,15 +435,10 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
/>
<input
type="text"
placeholder="mm/dd/yyyy"
type="date"
placeholder="Milestone Date"
value={newMilestone.date}
onChange={(e) =>
setNewMilestone((prev) => ({
...prev,
date: e.target.value
}))
}
onChange={(e) => setNewMilestone((prev) => ({ ...prev, date: e.target.value }))}
/>
<input
type="number"
@ -274,6 +449,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
setNewMilestone((prev) => ({ ...prev, progress: val }));
}}
/>
{activeView === 'Financial' && (
<div>
<input
@ -283,17 +459,90 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
/>
<p>Enter the full new salary (not just the increase) after the milestone occurs.</p>
<div className="impacts-section border p-2 mt-3">
<h4>Financial Impacts</h4>
{newMilestone.impacts.map((imp, idx) => (
<div key={idx} className="impact-item border p-2 my-2">
{imp.id && (
<p className="text-xs text-gray-500">Impact ID: {imp.id}</p>
)}
<div>
<label>Type: </label>
<select
value={imp.impact_type}
onChange={(e) => updateImpact(idx, 'impact_type', e.target.value)}
>
<option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option>
</select>
</div>
<div>
<label>Direction: </label>
<select
value={imp.direction}
onChange={(e) => updateImpact(idx, 'direction', e.target.value)}
>
<option value="add">Add (Income)</option>
<option value="subtract">Subtract (Expense)</option>
</select>
</div>
<div>
<label>Amount: </label>
<input
type="number"
value={imp.amount}
onChange={(e) => updateImpact(idx, 'amount', e.target.value)}
/>
</div>
<div>
<label>Start Date:</label>
<input
type="date"
value={imp.start_date || ''}
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
/>
</div>
{imp.impact_type === 'MONTHLY' && (
<div>
<label>End Date (blank if indefinite): </label>
<input
type="date"
value={imp.end_date || ''}
onChange={(e) => updateImpact(idx, 'end_date', e.target.value || '')}
/>
</div>
)}
<button
className="text-red-500 mt-2"
onClick={() => removeImpact(idx)}
>
Remove Impact
</button>
</div>
))}
<button onClick={addNewImpact} className="bg-gray-200 px-2 py-1 mt-2">
+ Add Impact
</button>
</div>
</div>
)}
<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) => {
const leftPos = calcPosition(m.date);
return (
@ -304,28 +553,17 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
>
<div
className="milestone-timeline-dot"
onClick={() => {
// 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)}
/>
<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>
{/* Existing tasks */}
{m.tasks && m.tasks.length > 0 && (
<ul>
{m.tasks.map((t) => (
@ -338,15 +576,15 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
</ul>
)}
{/* Button to show/hide Add Task form */}
<button onClick={() => {
setShowTaskForm(showTaskForm === m.id ? null : m.id);
setNewTask({ title: '', description: '', due_date: '' });
}}>
<button
onClick={() => {
setShowTaskForm(showTaskForm === m.id ? null : m.id);
setNewTask({ title: '', description: '', due_date: '' });
}}
>
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</button>
{/* Conditionally render the Add Task form for this milestone */}
{showTaskForm === m.id && (
<div className="task-form">
<input

View File

@ -14,6 +14,7 @@ import AISuggestedMilestones from './AISuggestedMilestones.js';
import './MilestoneTracker.css';
import './MilestoneTimeline.css';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import ScenarioEditModal from './ScenarioEditModal.js';
ChartJS.register(LineElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, Legend, annotationPlugin);
@ -35,6 +36,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
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 */}
<ScenarioEditModal
show={showEditModal}
onClose={() => setShowEditModal(false)}
financialProfile={financialProfile}
setFinancialProfile={setFinancialProfile}
collegeProfile={collegeProfile}
setCollegeProfile={setCollegeProfile}
apiURL={apiURL}
authFetch={authFetch}
/>
{pendingCareerForModal && (
<button onClick={() => {
// handleConfirmCareerSelection logic

View File

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

View File

@ -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: users 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 {

BIN
upser_profile.db Normal file

Binary file not shown.

Binary file not shown.