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, () => { app.listen(PORT, () => {
console.log(`Premium server running on http://localhost:${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 MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView }) => {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] }); const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
// The "new or edit" milestone form state
const [newMilestone, setNewMilestone] = useState({ const [newMilestone, setNewMilestone] = useState({
title: '', title: '',
description: '', description: '',
date: '', date: '',
progress: '', progress: 0,
newSalary: '' 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 [showForm, setShowForm] = useState(false);
const [editingMilestone, setEditingMilestone] = useState(null); 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: '' }); 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'. * Then categorize them by milestone_type: 'Career' or 'Financial'.
*/ */
const fetchMilestones = useCallback(async () => { const fetchMilestones = useCallback(async () => {
@ -35,10 +45,6 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
return; return;
} }
// data.milestones = [ { id, milestone_type, date, ..., tasks: [ ... ] }, ... ]
console.log('Fetched milestones with tasks:', data.milestones);
// Categorize by milestone_type
const categorized = { Career: [], Financial: [] }; const categorized = { Career: [], Financial: [] };
data.milestones.forEach((m) => { data.milestones.forEach((m) => {
if (categorized[m.milestone_type]) { if (categorized[m.milestone_type]) {
@ -54,19 +60,61 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
} }
}, [careerPathId, authFetch]); }, [careerPathId, authFetch]);
// Run fetchMilestones on mount or when careerPathId changes
useEffect(() => { useEffect(() => {
fetchMilestones(); fetchMilestones();
}, [fetchMilestones]); }, [fetchMilestones]);
/** /**
* Create or update a milestone. * Async function to edit an existing milestone.
* If editingMilestone is set, we do PUT -> /api/premium/milestones/:id * Fetch its impacts, populate newMilestone, show the form.
* Else we do POST -> /api/premium/milestone */
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 () => { const saveMilestone = async () => {
if (!activeView) return; if (!activeView) return;
// If editing, we do PUT; otherwise POST
const url = editingMilestone const url = editingMilestone
? `/api/premium/milestones/${editingMilestone.id}` ? `/api/premium/milestones/${editingMilestone.id}`
: `/api/premium/milestone`; : `/api/premium/milestone`;
@ -80,7 +128,8 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
career_path_id: careerPathId, career_path_id: careerPathId,
progress: newMilestone.progress, progress: newMilestone.progress,
status: newMilestone.progress >= 100 ? 'completed' : 'planned', status: newMilestone.progress >= 100 ? 'completed' : 'planned',
new_salary: activeView === 'Financial' && newMilestone.newSalary new_salary:
activeView === 'Financial' && newMilestone.newSalary
? parseFloat(newMilestone.newSalary) ? parseFloat(newMilestone.newSalary)
: null : null
}; };
@ -103,6 +152,83 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
const savedMilestone = await res.json(); const savedMilestone = await res.json();
console.log('Milestone saved/updated:', savedMilestone); 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 // Update local state so we don't have to refetch everything
setMilestones((prev) => { setMilestones((prev) => {
const updated = { ...prev }; const updated = { ...prev };
@ -119,18 +245,28 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
// Reset form // Reset form
setShowForm(false); setShowForm(false);
setEditingMilestone(null); 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({ setNewMilestone({
title: '', title: '',
description: '', description: '',
date: '', date: '',
progress: 0, progress: 0,
newSalary: '' newSalary: '',
impacts: []
}); });
setImpactsToDelete([]);
} catch (err) { } catch (err) {
console.error('Error saving milestone:', err); console.error('Error saving milestone:', err);
} }
}; };
/**
* Add a new task to an existing milestone
*/
const addTask = async (milestoneId) => { const addTask = async (milestoneId) => {
try { try {
const taskPayload = { const taskPayload = {
@ -157,15 +293,13 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
// Update the milestone's tasks in local state // Update the milestone's tasks in local state
setMilestones((prev) => { setMilestones((prev) => {
// We need to find which classification this milestone belongs to
const newState = { ...prev }; const newState = { ...prev };
// Could be Career or Financial
['Career', 'Financial'].forEach((category) => { ['Career', 'Financial'].forEach((category) => {
newState[category] = newState[category].map((m) => { newState[category] = newState[category].map((m) => {
if (m.id === milestoneId) { if (m.id === milestoneId) {
return { return {
...m, ...m,
tasks: [...m.tasks, createdTask] tasks: [...(m.tasks || []), createdTask]
}; };
} }
return m; return m;
@ -174,17 +308,14 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
return newState; return newState;
}); });
// Reset the addTask form
setNewTask({ title: '', description: '', due_date: '' }); setNewTask({ title: '', description: '', due_date: '' });
setShowTaskForm(null); // close the task form setShowTaskForm(null);
} catch (err) { } catch (err) {
console.error('Error adding task:', err); console.error('Error adding task:', err);
} }
}; };
/** // For timeline
* Figure out the timeline's "end" date by scanning all milestones.
*/
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial]; const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
const lastDate = allMilestonesCombined.reduce((latest, m) => { const lastDate = allMilestonesCombined.reduce((latest, m) => {
const d = new Date(m.date); const d = new Date(m.date);
@ -195,12 +326,54 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
const start = today.getTime(); const start = today.getTime();
const end = lastDate.getTime(); const end = lastDate.getTime();
const dateVal = new Date(dateString).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); const ratio = (dateVal - start) / (end - start);
return Math.min(Math.max(ratio * 100, 0), 100); 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]) { if (!activeView || !milestones[activeView]) {
return ( return (
<div className="milestone-timeline"> <div className="milestone-timeline">
@ -211,7 +384,6 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
return ( return (
<div className="milestone-timeline"> <div className="milestone-timeline">
{/* View selector */}
<div className="view-selector"> <div className="view-selector">
{['Career', 'Financial'].map((view) => ( {['Career', 'Financial'].map((view) => (
<button <button
@ -224,15 +396,23 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
))} ))}
</div> </div>
{/* New Milestone button */}
<button <button
onClick={() => { onClick={() => {
if (showForm) { if (showForm) {
// Cancel form // Cancel form
setShowForm(false); setShowForm(false);
setEditingMilestone(null); setEditingMilestone(null);
setNewMilestone({ title: '', description: '', date: '', progress: 0, newSalary: '' }); setNewMilestone({
title: '',
description: '',
date: '',
progress: 0,
newSalary: '',
impacts: []
});
setImpactsToDelete([]);
} else { } else {
// Show form
setShowForm(true); setShowForm(true);
} }
}} }}
@ -255,15 +435,10 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })} onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
/> />
<input <input
type="text" type="date"
placeholder="mm/dd/yyyy" placeholder="Milestone Date"
value={newMilestone.date} value={newMilestone.date}
onChange={(e) => onChange={(e) => setNewMilestone((prev) => ({ ...prev, date: e.target.value }))}
setNewMilestone((prev) => ({
...prev,
date: e.target.value
}))
}
/> />
<input <input
type="number" type="number"
@ -274,6 +449,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
setNewMilestone((prev) => ({ ...prev, progress: val })); setNewMilestone((prev) => ({ ...prev, progress: val }));
}} }}
/> />
{activeView === 'Financial' && ( {activeView === 'Financial' && (
<div> <div>
<input <input
@ -283,17 +459,90 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })} onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
/> />
<p>Enter the full new salary (not just the increase) after the milestone occurs.</p> <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> </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}> <button onClick={saveMilestone}>
{editingMilestone ? 'Update' : 'Add'} Milestone {editingMilestone ? 'Update' : 'Add'} Milestone
</button> </button>
</div> </div>
)} )}
{/* Timeline rendering */}
<div className="milestone-timeline-container"> <div className="milestone-timeline-container">
<div className="milestone-timeline-line" /> <div className="milestone-timeline-line" />
{milestones[activeView].map((m) => { {milestones[activeView].map((m) => {
const leftPos = calcPosition(m.date); const leftPos = calcPosition(m.date);
return ( return (
@ -304,28 +553,17 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
> >
<div <div
className="milestone-timeline-dot" className="milestone-timeline-dot"
onClick={() => { onClick={() => handleEditMilestone(m)}
// 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);
}}
/> />
<div className="milestone-content"> <div className="milestone-content">
<div className="title">{m.title}</div> <div className="title">{m.title}</div>
{m.description && <p>{m.description}</p>} {m.description && <p>{m.description}</p>}
<div className="progress-bar"> <div className="progress-bar">
<div className="progress" style={{ width: `${m.progress}%` }} /> <div className="progress" style={{ width: `${m.progress}%` }} />
</div> </div>
<div className="date">{m.date}</div> <div className="date">{m.date}</div>
{/* Existing tasks */}
{m.tasks && m.tasks.length > 0 && ( {m.tasks && m.tasks.length > 0 && (
<ul> <ul>
{m.tasks.map((t) => ( {m.tasks.map((t) => (
@ -338,15 +576,15 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
</ul> </ul>
)} )}
{/* Button to show/hide Add Task form */} <button
<button onClick={() => { onClick={() => {
setShowTaskForm(showTaskForm === m.id ? null : m.id); setShowTaskForm(showTaskForm === m.id ? null : m.id);
setNewTask({ title: '', description: '', due_date: '' }); setNewTask({ title: '', description: '', due_date: '' });
}}> }}
>
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'} {showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</button> </button>
{/* Conditionally render the Add Task form for this milestone */}
{showTaskForm === m.id && ( {showTaskForm === m.id && (
<div className="task-form"> <div className="task-form">
<input <input

View File

@ -14,6 +14,7 @@ import AISuggestedMilestones from './AISuggestedMilestones.js';
import './MilestoneTracker.css'; import './MilestoneTracker.css';
import './MilestoneTimeline.css'; import './MilestoneTimeline.css';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import ScenarioEditModal from './ScenarioEditModal.js';
ChartJS.register(LineElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, Legend, annotationPlugin); ChartJS.register(LineElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, Legend, annotationPlugin);
@ -35,6 +36,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const [projectionData, setProjectionData] = useState([]); const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
const [showEditModal, setShowEditModal] = useState(false);
const apiURL = process.env.REACT_APP_API_URL; const apiURL = process.env.REACT_APP_API_URL;
// Possibly loaded from location.state // Possibly loaded from location.state
@ -305,6 +308,19 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch} authFetch={authFetch}
/> />
{/* SCENARIO EDIT MODAL */}
<ScenarioEditModal
show={showEditModal}
onClose={() => setShowEditModal(false)}
financialProfile={financialProfile}
setFinancialProfile={setFinancialProfile}
collegeProfile={collegeProfile}
setCollegeProfile={setCollegeProfile}
apiURL={apiURL}
authFetch={authFetch}
/>
{pendingCareerForModal && ( {pendingCareerForModal && (
<button onClick={() => { <button onClick={() => {
// handleConfirmCareerSelection logic // handleConfirmCareerSelection logic

View File

@ -1,6 +1,6 @@
// src/components/ScenarioEditModal.js // src/components/ScenarioEditModal.js
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import authFetch from '../utils/authFetch'; import authFetch from '../utils/authFetch.js';
const ScenarioEditModal = ({ const ScenarioEditModal = ({
show, show,

View File

@ -58,10 +58,6 @@ const APPROX_STATE_TAX_RATES = {
DC: 0.05 DC: 0.05
}; };
/**
* 2) Single-filer federal tax calculation (2023).
* Includes standard deduction ($13,850).
*/
function calculateAnnualFederalTaxSingle(annualIncome) { function calculateAnnualFederalTaxSingle(annualIncome) {
const STANDARD_DEDUCTION_SINGLE = 13850; const STANDARD_DEDUCTION_SINGLE = 13850;
const taxableIncome = Math.max(0, annualIncome - STANDARD_DEDUCTION_SINGLE); const taxableIncome = Math.max(0, annualIncome - STANDARD_DEDUCTION_SINGLE);
@ -92,20 +88,11 @@ function calculateAnnualFederalTaxSingle(annualIncome) {
return tax; 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) { function calculateAnnualStateTax(annualIncome, stateCode) {
// Default to 5% if not found in dictionary
const rate = APPROX_STATE_TAX_RATES[stateCode] ?? 0.05; const rate = APPROX_STATE_TAX_RATES[stateCode] ?? 0.05;
return annualIncome * rate; return annualIncome * rate;
} }
/**
* Calculate the standard monthly loan payment for principal, annualRate (%) and term (years).
*/
function calculateLoanPayment(principal, annualRate, years) { function calculateLoanPayment(principal, annualRate, years) {
if (principal <= 0) return 0; 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. * 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) { export function simulateFinancialProjection(userProfile) {
const { const {
@ -168,42 +165,44 @@ export function simulateFinancialProjection(userProfile) {
// Potential override // Potential override
programLength, programLength,
// NEW: users state code (e.g. 'CA', 'NY', 'TX', etc.) // State code
stateCode = 'TX', // default to TX (no state income tax) stateCode = 'TX',
// Milestone impacts (with dates, add/subtract logic)
milestoneImpacts = []
} = userProfile; } = userProfile;
// scenario start date
const scenarioStart = startDate ? new Date(startDate) : new Date();
// 1. Monthly loan payment if not deferring // 1. Monthly loan payment if not deferring
let monthlyLoanPayment = loanDeferralUntilGraduation let monthlyLoanPayment = loanDeferralUntilGraduation
? 0 ? 0
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm); : calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
// 2. Determine how many credit hours remain // 2. Determine credit hours
let requiredCreditHours = 120; let requiredCreditHours = 120;
switch (programType) { switch (programType) {
case "Associate's Degree": case "Associate's Degree":
requiredCreditHours = 60; requiredCreditHours = 60;
break; break;
case "Bachelor's Degree":
requiredCreditHours = 120;
break;
case "Master's Degree": case "Master's Degree":
requiredCreditHours = 30; requiredCreditHours = 30;
break; break;
case "Doctoral Degree": case "Doctoral Degree":
requiredCreditHours = 60; requiredCreditHours = 60;
break; break;
default: // otherwise Bachelor's
requiredCreditHours = 120;
} }
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted); const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
const dynamicProgramLength = Math.ceil(remainingCreditHours / creditHoursPerYear); const dynamicProgramLength = Math.ceil(remainingCreditHours / creditHoursPerYear);
const finalProgramLength = programLength || dynamicProgramLength; const finalProgramLength = programLength || dynamicProgramLength;
// 3. Net annual tuition after aid // 3. Net annual tuition
const netAnnualTuition = Math.max(0, calculatedTuition - annualFinancialAid); const netAnnualTuition = Math.max(0, calculatedTuition - annualFinancialAid);
const totalTuitionCost = netAnnualTuition * finalProgramLength; const totalTuitionCost = netAnnualTuition * finalProgramLength;
// 4. Setup lumps per year // 4. lumps
let lumpsPerYear, lumpsSchedule; let lumpsPerYear, lumpsSchedule;
switch (academicCalendar) { switch (academicCalendar) {
case 'semester': case 'semester':
@ -228,8 +227,8 @@ export function simulateFinancialProjection(userProfile) {
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength); const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
// 5. Simulation loop // 5. Simulation loop
const maxMonths = 240; const maxMonths = 240; // 20 years
let date = startDate ? new Date(startDate) : new Date(); let date = new Date(scenarioStart);
let loanBalance = Math.max(studentLoanAmount, 0); let loanBalance = Math.max(studentLoanAmount, 0);
let loanPaidOffMonth = null; let loanPaidOffMonth = null;
@ -238,55 +237,47 @@ export function simulateFinancialProjection(userProfile) {
let projectionData = []; let projectionData = [];
let wasInDeferral = inCollege && loanDeferralUntilGraduation; 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) // For YTD taxes
// e.g. taxStateByYear[2025] = { federalYtdGross, federalYtdTaxSoFar, stateYtdGross, stateYtdTaxSoFar }
const taxStateByYear = {}; const taxStateByYear = {};
for (let month = 0; month < maxMonths; month++) { for (let month = 0; month < maxMonths; month++) {
date.setMonth(date.getMonth() + 1); date.setMonth(date.getMonth() + 1);
const currentYear = date.getFullYear(); 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) { if (loanBalance <= 0 && !loanPaidOffMonth) {
loanPaidOffMonth = `${currentYear}-${String(date.getMonth() + 1).padStart(2, '0')}`; loanPaidOffMonth = `${currentYear}-${String(date.getMonth() + 1).padStart(2, '0')}`;
} }
// Are we still in college? // are we in college?
let stillInCollege = false; let stillInCollege = false;
if (inCollege) { if (inCollege) {
if (graduationDate) { if (graduationDateObj) {
stillInCollege = date < graduationDate; stillInCollege = date < graduationDateObj;
} else { } else {
const simStart = startDate ? new Date(startDate) : new Date();
const elapsedMonths =
(date.getFullYear() - simStart.getFullYear()) * 12 +
(date.getMonth() - simStart.getMonth());
stillInCollege = (elapsedMonths < totalAcademicMonths); stillInCollege = (elapsedMonths < totalAcademicMonths);
} }
} }
// 6. Tuition lumps // 6. tuition lumps
let tuitionCostThisMonth = 0; let tuitionCostThisMonth = 0;
if (stillInCollege && lumpsPerYear > 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 academicYearIndex = Math.floor(elapsedMonths / 12);
const monthInYear = elapsedMonths % 12; const monthInYear = elapsedMonths % 12;
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) { if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) {
tuitionCostThisMonth = lumpAmount; tuitionCostThisMonth = lumpAmount;
} }
} }
// 7. Exiting college? // 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 (stillInCollege && loanDeferralUntilGraduation) {
if (tuitionCostThisMonth > 0) { if (tuitionCostThisMonth > 0) {
loanBalance += tuitionCostThisMonth; loanBalance += tuitionCostThisMonth;
@ -294,15 +285,48 @@ export function simulateFinancialProjection(userProfile) {
} }
} }
// 9. Gross monthly income // 9. Base monthly income
let grossMonthlyIncome = 0; let grossMonthlyIncome = 0;
if (!inCollege || !stillInCollege) { if (!stillInCollege) {
grossMonthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12; grossMonthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
} else { } else {
grossMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12); 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]) { if (!taxStateByYear[currentYear]) {
taxStateByYear[currentYear] = { taxStateByYear[currentYear] = {
federalYtdGross: 0, 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].federalYtdGross += grossMonthlyIncome;
taxStateByYear[currentYear].stateYtdGross += grossMonthlyIncome; taxStateByYear[currentYear].stateYtdGross += grossMonthlyIncome;
// Compute total fed tax for the year so far // fed tax
const newFedTaxTotal = calculateAnnualFederalTaxSingle( const newFedTaxTotal = calculateAnnualFederalTaxSingle(
taxStateByYear[currentYear].federalYtdGross taxStateByYear[currentYear].federalYtdGross
); );
// Monthly fed tax = difference
const monthlyFederalTax = newFedTaxTotal - taxStateByYear[currentYear].federalYtdTaxSoFar; const monthlyFederalTax = newFedTaxTotal - taxStateByYear[currentYear].federalYtdTaxSoFar;
taxStateByYear[currentYear].federalYtdTaxSoFar = newFedTaxTotal; taxStateByYear[currentYear].federalYtdTaxSoFar = newFedTaxTotal;
// Compute total state tax for the year so far // state tax
const newStateTaxTotal = calculateAnnualStateTax( const newStateTaxTotal = calculateAnnualStateTax(
taxStateByYear[currentYear].stateYtdGross, taxStateByYear[currentYear].stateYtdGross,
stateCode stateCode
@ -332,26 +355,20 @@ export function simulateFinancialProjection(userProfile) {
const monthlyStateTax = newStateTaxTotal - taxStateByYear[currentYear].stateYtdTaxSoFar; const monthlyStateTax = newStateTaxTotal - taxStateByYear[currentYear].stateYtdTaxSoFar;
taxStateByYear[currentYear].stateYtdTaxSoFar = newStateTaxTotal; taxStateByYear[currentYear].stateYtdTaxSoFar = newStateTaxTotal;
// Combined monthly tax
const combinedTax = monthlyFederalTax + monthlyStateTax; const combinedTax = monthlyFederalTax + monthlyStateTax;
// Net monthly income after taxes
const netMonthlyIncome = grossMonthlyIncome - combinedTax; const netMonthlyIncome = grossMonthlyIncome - combinedTax;
// 11. Expenses & loan // 11. Expenses & loan
let thisMonthLoanPayment = 0; 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) { if (nowExitingCollege) {
monthlyLoanPayment = calculateLoanPayment( monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, 10);
loanBalance,
interestRate,
10
);
} }
// If not deferring, we do normal payments // if deferring
if (stillInCollege && loanDeferralUntilGraduation) { if (stillInCollege && loanDeferralUntilGraduation) {
const interestForMonth = loanBalance * (interestRate / 100 / 12); const interestForMonth = loanBalance * (interestRate / 100 / 12);
loanBalance += interestForMonth; loanBalance += interestForMonth;
@ -370,11 +387,11 @@ export function simulateFinancialProjection(userProfile) {
} }
} }
// 12. leftover after mandatory expenses // leftover after mandatory expenses
let leftover = netMonthlyIncome - totalMonthlyExpenses; let leftover = netMonthlyIncome - totalMonthlyExpenses;
if (leftover < 0) leftover = 0; if (leftover < 0) leftover = 0;
// Baseline contributions // baseline contributions
const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution; const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution;
let effectiveRetirementContribution = 0; let effectiveRetirementContribution = 0;
let effectiveEmergencyContribution = 0; let effectiveEmergencyContribution = 0;
@ -383,12 +400,9 @@ export function simulateFinancialProjection(userProfile) {
effectiveRetirementContribution = monthlyRetirementContribution; effectiveRetirementContribution = monthlyRetirementContribution;
effectiveEmergencyContribution = monthlyEmergencyContribution; effectiveEmergencyContribution = monthlyEmergencyContribution;
leftover -= baselineContributions; leftover -= baselineContributions;
} else {
effectiveRetirementContribution = 0;
effectiveEmergencyContribution = 0;
} }
// Check shortfall // shortfall check
const totalWantedContributions = effectiveRetirementContribution + effectiveEmergencyContribution; const totalWantedContributions = effectiveRetirementContribution + effectiveEmergencyContribution;
const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions; const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
let shortfall = actualExpensesPaid - netMonthlyIncome; let shortfall = actualExpensesPaid - netMonthlyIncome;
@ -397,7 +411,7 @@ export function simulateFinancialProjection(userProfile) {
currentEmergencySavings -= canCover; currentEmergencySavings -= canCover;
shortfall -= canCover; shortfall -= canCover;
if (shortfall > 0) { if (shortfall > 0) {
// bankrupt scenario // bankrupt scenario, end
break; break;
} }
} }
@ -433,7 +447,7 @@ export function simulateFinancialProjection(userProfile) {
loanPaymentThisMonth: Math.round(thisMonthLoanPayment * 100) / 100 loanPaymentThisMonth: Math.round(thisMonthLoanPayment * 100) / 100
}); });
wasInDeferral = (stillInCollege && loanDeferralUntilGraduation); wasInDeferral = stillInCollege && loanDeferralUntilGraduation;
} }
return { return {

BIN
upser_profile.db Normal file

Binary file not shown.

Binary file not shown.