From 1868c4348a490ed7cba2eebcfa07f6d1d7633c20 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 19 Jun 2025 11:44:43 +0000 Subject: [PATCH] Fixed some things, broke some others. --- backend/config/mysqlPool.js | 36 +- backend/server3.js | 163 +++++--- ecosystem.config.cjs | 56 +-- src/components/CareerCoach.js | 12 +- src/components/CareerRoadmap.js | 122 ++++-- src/components/MilestoneEditModal.js | 475 ++++++++++++++---------- src/components/SignIn.js | 4 +- src/utils/FinancialProjectionService.js | 55 +-- 8 files changed, 543 insertions(+), 380 deletions(-) diff --git a/backend/config/mysqlPool.js b/backend/config/mysqlPool.js index 2c6fafb..df066c0 100644 --- a/backend/config/mysqlPool.js +++ b/backend/config/mysqlPool.js @@ -1,29 +1,15 @@ // backend/config/mysqlPool.js import mysql from 'mysql2/promise'; -import dotenv from 'dotenv'; -import { fileURLToPath } from 'url'; -import path from 'path'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const pool = mysql.createPool({ + host : process.env.DB_HOST || '127.0.0.1', + port : process.env.DB_PORT || 3306, + user : process.env.DB_USER || 'root', + password : process.env.DB_PASSWORD || '', + database : process.env.DB_NAME || 'user_profile_db', + waitForConnections : true, + connectionLimit : 10, + ...(process.env.DB_SOCKET ? { socketPath: process.env.DB_SOCKET } : {}) +}); -// load .env. -dotenv.config({ path: path.resolve(__dirname, '..', `.env.${process.env.NODE_ENV || 'development'}`) }); - -/** decide: socket vs TCP */ -let poolConfig; -if (process.env.DB_SOCKET) { - poolConfig = { socketPath: process.env.DB_SOCKET }; -} else { - poolConfig = { - host : process.env.DB_HOST || '127.0.0.1', - port : process.env.DB_PORT || 3306, - user : process.env.DB_USER || 'root', - password: process.env.DB_PASSWORD || '', - }; -} -poolConfig.database = process.env.DB_NAME || 'user_profile_db'; -poolConfig.waitForConnections = true; -poolConfig.connectionLimit = 10; - -export default mysql.createPool(poolConfig); +export default pool; \ No newline at end of file diff --git a/backend/server3.js b/backend/server3.js index fa7c48a..9907d5e 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -1,35 +1,31 @@ -// -// server3.js - MySQL Version -// +// ─── server3.js ──────────────────────────────────────────────────────────── +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const env = (process.env.NODE_ENV || 'development').trim(); +const envPath = path.resolve(__dirname, '..', `.env.${env}`); +dotenv.config({ path: envPath }); // ✅ envs are now ready + import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; -import dotenv from 'dotenv'; -import path from 'path'; import fs from 'fs/promises'; import multer from 'multer'; -import fetch from "node-fetch"; +import fetch from 'node-fetch'; import mammoth from 'mammoth'; -import { fileURLToPath } from 'url'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import pkg from 'pdfjs-dist'; -import db from './config/mysqlPool.js'; // Adjust path as necessary +import db from './config/mysqlPool.js'; import './jobs/reminderCron.js'; import OpenAI from 'openai'; import Fuse from 'fuse.js'; import { createReminder } from './utils/smsService.js'; -const pool = db; - -// Basic file init -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const rootPath = path.resolve(__dirname, '..'); // Up one level -const env = process.env.NODE_ENV?.trim() || 'development'; -const envPath = path.resolve(rootPath, `.env.${env}`); -dotenv.config({ path: envPath }); // Load .env file const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api"; @@ -49,6 +45,7 @@ function internalFetch(req, url, opts = {}) { } + // 2) Basic middlewares app.use(helmet()); app.use(express.json({ limit: '5mb' })); @@ -73,6 +70,8 @@ const authenticatePremiumUser = (req, res, next) => { } }; +const pool = db; + /* ------------------------------------------------------------------ CAREER PROFILE ENDPOINTS ------------------------------------------------------------------ */ @@ -513,17 +512,26 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => { } = req.body; let existingTitles = []; + let miniGrid = "-none-"; // slim grid + try { const [rows] = await pool.query( - `SELECT title, DATE_FORMAT(date,'%Y-%m-%d') AS d - FROM milestones - WHERE user_id = ? AND career_profile_id = ?`, + + `SELECT id, DATE_FORMAT(date,'%Y-%m-%d') AS d, title + FROM milestones + WHERE user_id = ? AND career_profile_id = ?`, [req.id, scenarioRow.id] ); + existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`); + + if (rows.length) { + miniGrid = rows.map(r => `${r.id}|${r.title.trim()}|${r.d}`).join("\n"); + } } catch (e) { - console.error("Could not fetch existing milestones =>", e); + console.error("Could not fetch existing milestones ⇒", e); } + // ------------------------------------------------ // 1. Helper Functions // ------------------------------------------------ @@ -824,34 +832,70 @@ ${econText} // 5. Construct System-Level Prompts // ------------------------------------------------ const systemPromptIntro = ` -You are Jess, a professional career coach working inside AptivaAI. ++You are **Jess**, a professional career coach inside AptivaAI. ++Your mandate: turn the user’s real data into clear, empathetic, *actionable* guidance. ++ ++──────────────────────────────────────────────────────── ++What Jess can do directly in Aptiva ++──────────────────────────────────────────────────────── ++• **Create** new milestones (with tasks & financial impacts) ++• **Update** any field on an existing milestone ++• **Delete** milestones that are no longer relevant ++• **Add / edit / remove** tasks inside a milestone ++• Run salary benchmarks, AI-risk checks, and financial projections ++ ++──────────────────────────────────────────────────────── ++Mission & Tone ++──────────────────────────────────────────────────────── ++Our mission is to help people grow *with* AI rather than be displaced by it. ++Speak in a warm, encouraging tone, but prioritize *specific next steps* over generic motivation. ++Validate ambitions, break big goals into realistic milestones, and show how AI can be a collaborator. ++ ++Finish every reply with **one concrete suggestion or question** that moves the plan forward. ++Never ask for info you already have unless you truly need clarification. ++`.trim(); -The user has already provided detailed information about their situation, career goals, finances, education, and more. -Your job is to leverage *all* this context to provide specific, empathetic, and helpful advice. +const systemPromptOpsCheatSheet = ` +──────────────────────────────────────────────────────── +🛠 APTIVA OPS YOU CAN USE ANY TIME +──────────────────────────────────────────────────────── +1. CREATE a milestone (optionally with tasks + impacts) +2. UPDATE any field on an existing milestone +3. DELETE a milestone that is no longer relevant + • You already have permission—no need to ask the user. +4. CREATE / UPDATE / DELETE tasks inside a milestone +──────────────────────────────────────────────────────── +When you perform an op, respond with a fenced JSON block +_tagged_ \`\`\`ops\`\`\` exactly like this: -Remember: AptivaAI’s mission is to help the workforce grow *with* AI, not be displaced by it. -Just like previous revolutions—industrial, digital—our goal is to show individuals how to -utilize AI tools, enhance their own capabilities, and pivot into new opportunities if automation -begins to handle older tasks. +\`\`\`ops +{ + "milestones":[ + { "op":"DELETE", "id":"1234-uuid" }, -Speak in a warm, empathetic tone. Validate the user's ambitions, -explain how to break down big goals into realistic steps, -and highlight how AI can serve as a *collaborative* tool rather than a rival. + { "op":"UPDATE", "id":"5678-uuid", + "patch":{ "date":"2026-02-01", "title":"New title" } }, -Reference the user's location and any relevant experiences or ambitions they've shared. -Validate their ambitions, explain how to break down big goals into realistic steps, -and gently highlight how the user might further explore or refine their plans with AptivaAI's Interest Inventory. + { "op":"CREATE", + "data":{ + "title":"Finish AWS Solutions Architect cert", + "type":"Career", + "date":"2026-06-01", + "description":"Study + exam", + "tasks":[ + { "title":"Book exam", "due_date":"2026-03-15" } + ], + "impacts":[ + { "impact_type":"cost", "direction":"subtract", + "amount":350, "start_date":"2026-03-15" } + ] + } + } + ] +} +\`\`\` -If the user has mentioned ambitious financial or lifestyle goals (e.g., wanting to buy a Ferrari, -become a millionaire, etc.), acknowledge them as "bold" or "exciting," and clarify -how the user might move toward them via skill-building, networking, or -other relevant steps. - -Use bullet points to restate user goals or interests. -End with an open-ended question about what they'd like to tackle next in their plan. - -Do not re-ask for the details below unless you need clarifications. -Reflect the user's actual data. Avoid purely generic responses. +⚠️ If you’re not changing milestones, skip the ops block entirely. All milestone titles are already 3–5 words; use them verbatim when the user refers to a milestone by name. `.trim(); const systemPromptStatusSituation = ` @@ -862,6 +906,13 @@ ${combinedStatusSituation} const systemPromptDetailedContext = ` [DETAILED USER PROFILE & CONTEXT] ${summaryText} +`.trim(); + +const dynMilestonePrompt = ` +[CURRENT MILESTONES] +(id | date) +${miniGrid} +You may UPDATE or DELETE any of these. `.trim(); const systemPromptMilestoneFormat = ` @@ -898,18 +949,38 @@ RESPOND ONLY with valid JSON in this shape: Otherwise, answer normally. `.trim(); + const avoidBlock = existingTitles.length ? "\nAVOID repeating any of these title|date combinations:\n" + existingTitles.map(t => `- ${t}`).join("\n") : ""; + const recentHistory = chatHistory.slice(-MAX_CHAT_TURNS); + + const firstTurn = chatHistory.length === 0; + + const STATIC_SYSTEM_CARD = ` +${systemPromptIntro} + +${systemPromptOpsCheatSheet} + +/* Milestone JSON spec, date guard, and avoid-list */ +${systemPromptMilestoneFormat} +${systemPromptDateGuard} +`.trim(); + +/* How many past exchanges to keep */ +const MAX_CHAT_TURNS = 6; + // Build up the final messages array const messagesToSend = [ { role: "system", content: systemPromptIntro }, + { role: "system", content: systemPromptOpsCheatSheet }, { role: "system", content: systemPromptStatusSituation }, { role: "system", content: systemPromptDetailedContext }, { role: "system", content: systemPromptMilestoneFormat }, - { role: "system", content: systemPromptMilestoneFormat + avoidBlock }, // <-- merged + { role: "system", content: systemPromptMilestoneFormat + avoidBlock }, + { role: "system", content: systemPromptDateGuard }, ...chatHistory // includes user and assistant messages so far ]; diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index b0fdcd7..d13e35e 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -18,48 +18,28 @@ module.exports = { } }, - /* ─────────────── SERVER-3 (Premium) ─────────────── */ - { - name: 'server3', - script: './backend/server3.js', - watch: false, // set true if you want auto-reload in dev + { + name : 'server3', + script : './backend/server3.js', + watch : false, - env_development: { - NODE_ENV : 'development', - PREMIUM_PORT : 5002, + /* 👇 everything lives here, nothing else to pass at start-time */ + env: { + NODE_ENV : 'production', + PREMIUM_PORT : 5002, - /* Twilio */ - TWILIO_ACCOUNT_SID : 'ACd700c6fb9f691ccd9ccab73f2dd4173d', - TWILIO_AUTH_TOKEN : 'fb8979ccb172032a249014c9c30eba80', - TWILIO_MESSAGING_SERVICE_SID : 'MGaa07992a9231c841b1bfb879649026d6', + DB_HOST : '34.67.180.54', + DB_PORT : 3306, + DB_USER : 'sqluser', + DB_PASSWORD : 'ps { + const saved = localStorage.getItem('coachChat:'+careerProfileId); + if (saved) setMessages(JSON.parse(saved)); +}, [careerProfileId]); + +useEffect(() => { + localStorage.setItem('coachChat:'+careerProfileId, JSON.stringify(messages.slice(-20))); +}, [messages, careerProfileId]); + /* -------------- intro ---------------- */ useEffect(() => { if (!scenarioRow) return; @@ -319,7 +329,7 @@ I'm here to support you with personalized coaching. What would you like to focus disabled={loading} className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded" > - AI Growth Plan + Grow Career with AI {/* pushes Edit Goals to the far right */}
diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index 075490a..e39b2f8 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -349,6 +349,8 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) { const [strippedSocCode, setStrippedSocCode] = useState(null); const [salaryData, setSalaryData] = useState(null); const [economicProjections, setEconomicProjections] = useState(null); + const [salaryLoading, setSalaryLoading] = useState(false); + const [econLoading, setEconLoading] = useState(false); // Milestones & Projection const [scenarioMilestones, setScenarioMilestones] = useState([]); @@ -587,7 +589,26 @@ useEffect(() => { }, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]); - +useEffect(() => { + if ( + financialProfile && + scenarioRow && + collegeProfile && + scenarioMilestones.length + ) { + buildProjection(scenarioMilestones); // uses the latest scenarioMilestones + } +}, [ + financialProfile, + scenarioRow, + collegeProfile, + scenarioMilestones, + simulationYears, + interestStrategy, + flatAnnualRate, + randomRangeMin, + randomRangeMax +]); useEffect(() => { if (recommendations.length > 0) { @@ -823,8 +844,12 @@ try { useEffect(() => { // show blank state instantly whenever the SOC or area changes setSalaryData(null); - if (!strippedSocCode) return; + setSalaryLoading(true); + if (!strippedSocCode) { + setSalaryLoading(false); + return; +} const ctrl = new AbortController(); (async () => { try { @@ -833,11 +858,13 @@ useEffect(() => { if (res.ok) { setSalaryData(await res.json()); + setSalaryLoading(false); } else { console.error('[Salary fetch]', res.status); } } catch (e) { if (e.name !== 'AbortError') console.error('[Salary fetch error]', e); + setSalaryLoading(false); } })(); @@ -848,7 +875,11 @@ useEffect(() => { /* 7) Economic Projections ---------------------------------------- */ useEffect(() => { setEconomicProjections(null); - if (!strippedSocCode || !userState) return; + setEconLoading(true); + if (!strippedSocCode || !userState) { + setEconLoading(false); + return; + } const ctrl = new AbortController(); (async () => { @@ -861,11 +892,13 @@ useEffect(() => { if (res.ok) { setEconomicProjections(await res.json()); + setEconLoading(false); } else { console.error('[Econ fetch]', res.status); } } catch (e) { if (e.name !== 'AbortError') console.error('[Econ fetch error]', e); + setEconLoading(false); } })(); @@ -874,17 +907,11 @@ useEffect(() => { // 8) Build financial projection - async function buildProjection() { + async function buildProjection(milestones) { + if (!milestones?.length) return; + const allMilestones = milestones; try { - const milUrl = `${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`; - const mr = await authFetch(milUrl); - if (!mr.ok) { - console.error('Failed to fetch milestones =>', mr.status); - return; - } - const md = await mr.json(); - const allMilestones = md.milestones || []; - setScenarioMilestones(allMilestones); + setScenarioMilestones(allMilestones); // fetch impacts const imPromises = allMilestones.map((m) => @@ -1037,7 +1064,7 @@ useEffect(() => { useEffect(() => { if (!financialProfile || !scenarioRow || !collegeProfile) return; - buildProjection(); + fetchMilestones(); }, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]); @@ -1174,29 +1201,27 @@ const onEditMilestone = useCallback((m) => { setMilestoneForModal(m); // open modal }, []); +const currentIdRef = useRef(null); + +/* 1️⃣ The only deps it really needs */ const fetchMilestones = useCallback(async () => { - if (!careerProfileId) { - setScenarioMilestones([]); - return; - } + if (!careerProfileId) return; - try { - const res = await authFetch( - `${apiURL}/premium/milestones?careerProfileId=${careerProfileId}` - ); - if (!res.ok) return; + const [profRes, uniRes] = await Promise.all([ + authFetch(`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`), + authFetch(`${apiURL}/premium/milestones?careerProfileId=universal`) + ]); + if (!profRes.ok || !uniRes.ok) return; - const data = await res.json(); - const allMilestones = data.milestones || []; - setScenarioMilestones(allMilestones); + const [{ milestones: profMs }, { milestones: uniMs }] = + await Promise.all([profRes.json(), uniRes.json()]); - /* impacts (optional – only if you still need them in CR) */ - // ... fetch impacts here if CareerRoadmap charts rely on them ... - - } catch (err) { - console.error('Error fetching milestones', err); - } -}, [careerProfileId, apiURL]); + const merged = [...profMs, ...uniMs]; + setScenarioMilestones(merged); + if (financialProfile && scenarioRow && collegeProfile) { + buildProjection(merged); + } // single rebuild +}, [financialProfile, scenarioRow, careerProfileId, apiURL]); // ← NOTICE: no buildProjection here return ( @@ -1248,7 +1273,13 @@ const fetchMilestones = useCallback(async () => { {/* 2) Salary Benchmarks */}
- {salaryData?.regional && ( + {salaryLoading && ( +
+

Loading salary data…

+
+ )} + {!salaryLoading && salaryData?.regional && ( +

Regional Salary Data ({userArea || 'U.S.'}) @@ -1280,8 +1311,9 @@ const fetchMilestones = useCallback(async () => { />

)} + - {salaryData?.national && ( + {!salaryLoading && salaryData?.national && (

National Salary Data

@@ -1310,14 +1342,26 @@ const fetchMilestones = useCallback(async () => { />

)} + + {!salaryLoading && !salaryData?.regional && !salaryData?.national && ( +
+

No salary data found.

+
+ )} +
{/* 3) Economic Projections */}
- {economicProjections?.state && ( + {econLoading && ( +
+

Loading projections…

+
+ )} + {!econLoading && economicProjections?.state && ( )} - {economicProjections?.national && ( + {!econLoading && economicProjections?.national && ( )}
@@ -1485,7 +1529,9 @@ const fetchMilestones = useCallback(async () => { fetchMilestones={fetchMilestones} // helper to refresh list onClose={(didSave) => { setMilestoneForModal(false); // or setShowMilestoneModal(false) - if (didSave) fetchMilestones(); + if (didSave) { + fetchMilestones(); + } }} /> diff --git a/src/components/MilestoneEditModal.js b/src/components/MilestoneEditModal.js index 54d5a4f..63c6a3e 100644 --- a/src/components/MilestoneEditModal.js +++ b/src/components/MilestoneEditModal.js @@ -27,7 +27,6 @@ export default function MilestoneEditModal({ const [milestones, setMilestones] = useState(incomingMils); const [editingMilestoneId, setEditingMilestoneId] = useState(null); const [newMilestoneMap, setNewMilestoneMap] = useState({}); - const [impactsToDeleteMap, setImpactsToDeleteMap] = useState({}); const [addingNewMilestone, setAddingNewMilestone] = useState(false); const [newMilestoneData, setNewMilestoneData] = useState({ title: "", @@ -40,224 +39,280 @@ export default function MilestoneEditModal({ }); const [copyWizardMilestone, setCopyWizardMilestone] = useState(null); + function toSqlDate(str = '') { + // Handles '', null, undefined gracefully + return str.slice(0, 10); // "YYYY-MM-DD" +} + /* keep milestones in sync with prop */ useEffect(() => { setMilestones(incomingMils); }, [incomingMils]); - /* ──────────────────────────────── - Inline‑edit helpers (trimmed copy of ScenarioContainer logic) - */ - const loadMilestoneImpacts = useCallback(async (m) => { - try { - const impRes = await authFetch( - `/api/premium/milestone-impacts?milestone_id=${m.id}` - ); - if (!impRes.ok) throw new Error("impact fetch failed"); - const data = await impRes.json(); - const impacts = (data.impacts || []).map((imp) => ({ - 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 || "" - })); +/* ──────────────────────────────── + Inline-edit helpers +──────────────────────────────────*/ +const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({}); // snapshot per milestone - setNewMilestoneMap((prev) => ({ - ...prev, - [m.id]: { - title: m.title || "", - description: m.description || "", - date: m.date || "", - progress: m.progress || 0, - newSalary: m.new_salary || "", - impacts, - isUniversal: m.is_universal ? 1 : 0 - } - })); - setEditingMilestoneId(m.id); - setImpactsToDeleteMap((prev) => ({ ...prev, [m.id]: [] })); - } catch (err) { - console.error("loadImpacts", err); - } - }, []); +/* 1️⃣ fetch impacts + open editor */ +const loadMilestoneImpacts = useCallback(async (m) => { + try { + const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`); + if (!res.ok) throw new Error('impact fetch failed'); + const json = await res.json(); - const handleEditMilestoneInline = (m) => { - if (editingMilestoneId === m.id) { - setEditingMilestoneId(null); - } else { - loadMilestoneImpacts(m); - } - }; - - const updateInlineImpact = (milestoneId, idx, field, value) => { - setNewMilestoneMap((prev) => { - const copy = { ...prev }; - const item = copy[milestoneId]; - if (!item) return prev; - const impactsClone = [...item.impacts]; - impactsClone[idx] = { ...impactsClone[idx], [field]: value }; - copy[milestoneId] = { ...item, impacts: impactsClone }; - return copy; - }); - }; - - const addInlineImpact = (milestoneId) => { - setNewMilestoneMap((prev) => { - const itm = prev[milestoneId]; - if (!itm) return prev; - const impactsClone = [...itm.impacts, { - impact_type: "ONE_TIME", - direction: "subtract", - amount: 0, - start_date: "", - end_date: "" - }]; - return { ...prev, [milestoneId]: { ...itm, impacts: impactsClone } }; - }); - }; - - const removeInlineImpact = (mid, idx) => { - setNewMilestoneMap((prev) => { - const itm = prev[mid]; - if (!itm) return prev; - const impactsClone = [...itm.impacts]; - const [removed] = impactsClone.splice(idx, 1); - setImpactsToDeleteMap((p) => ({ - ...p, - [mid]: [...(p[mid] || []), removed.id].filter(Boolean) - })); - return { ...prev, [mid]: { ...itm, impacts: impactsClone } }; - }); - }; - - const saveInlineMilestone = async (m) => { - const data = newMilestoneMap[m.id]; - if (!data) return; - const payload = { - milestone_type: "Financial", - title: data.title, - description: data.description, - date: data.date, - career_profile_id: careerProfileId, - progress: data.progress, - status: data.progress >= 100 ? "completed" : "planned", - new_salary: data.newSalary ? parseFloat(data.newSalary) : null, - is_universal: data.isUniversal || 0 - }; - try { - const res = await authFetch(`/api/premium/milestones/${m.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }); - if (!res.ok) throw new Error(await res.text()); - const saved = await res.json(); - - /* impacts */ - const toDelete = impactsToDeleteMap[m.id] || []; - for (const delId of toDelete) { - await authFetch(`/api/premium/milestone-impacts/${delId}`, { - method: "DELETE" - }); - } - for (const imp of data.impacts) { - const impPayload = { - milestone_id: saved.id, - impact_type: imp.impact_type, - direction: imp.direction, - amount: parseFloat(imp.amount) || 0, - start_date: imp.start_date || null, - end_date: imp.end_date || null - }; - if (imp.id) { - await authFetch(`/api/premium/milestone-impacts/${imp.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(impPayload) - }); - } else { - await authFetch("/api/premium/milestone-impacts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(impPayload) - }); - } - } - - await fetchMilestones(); - setEditingMilestoneId(null); - onClose(true); - } catch (err) { - alert("Failed to save milestone"); - console.error(err); - } - }; - - /* brand‑new milestone helpers (trimmed) */ - const addNewImpactToNewMilestone = () => { - setNewMilestoneData((p) => ({ - ...p, - impacts: [ - ...p.impacts, - { - impact_type: "ONE_TIME", - direction: "subtract", - amount: 0, - start_date: "", - end_date: "" - } - ] + const impacts = (json.impacts || []).map(imp => ({ + id : imp.id, + impact_type : imp.impact_type || 'ONE_TIME', + direction : imp.direction || 'subtract', + amount : imp.amount || 0, + start_date : toSqlDate(imp.start_date) || '', + end_date : toSqlDate(imp.end_date) || '' })); + + /* editable copy for the form */ + setNewMilestoneMap(prev => ({ + ...prev, + [m.id]: { + title : m.title || '', + description : m.description || '', + date : toSqlDate(m.date) || '', + progress : m.progress || 0, + newSalary : m.new_salary || '', + impacts, + isUniversal : m.is_universal ? 1 : 0 + } + })); + + /* snapshot the IDs that existed when editing started */ + setOriginalImpactIdsMap(prev => ({ + ...prev, + [m.id]: impacts.map(i => i.id) // array of strings + })); + + setEditingMilestoneId(m.id); // open the accordion + } catch (err) { + console.error('loadImpacts', err); + } +}, []); + +/* 2️⃣ toggle open / close */ +const handleEditMilestoneInline = (milestone) => { + setEditingMilestoneId((curr) => + curr === milestone.id ? null : milestone.id + ); + if (editingMilestoneId !== milestone.id) loadMilestoneImpacts(milestone); +}; + +/* 3️⃣ generic field updater for one impact row */ +const updateInlineImpact = (mid, idx, field, value) => { + setNewMilestoneMap(prev => { + const m = prev[mid]; + if (!m) return prev; + const impacts = [...m.impacts]; + impacts[idx] = { ...impacts[idx], [field]: value }; + return { ...prev, [mid]: { ...m, impacts } }; + }); +}; + +/* 4️⃣ add an empty impact row */ +const addInlineImpact = (mid) => { + setNewMilestoneMap(prev => { + const m = prev[mid]; + if (!m) return prev; + return { + ...prev, + [mid]: { + ...m, + impacts: [ + ...m.impacts, + { + impact_type : 'ONE_TIME', + direction : 'subtract', + amount : 0, + start_date : '', + end_date : '' + } + ] + } + }; + }); +}; + +/* 5️⃣ remove one impact row (local only – diff happens on save) */ +const removeInlineImpact = (mid, idx) => { + setNewMilestoneMap(prev => { + const m = prev[mid]; + if (!m) return prev; + const clone = [...m.impacts]; + clone.splice(idx, 1); + return { ...prev, [mid]: { ...m, impacts: clone } }; + }); +}; + +/* 6️⃣ persist the edits – PUT milestone, diff impacts */ +const saveInlineMilestone = async (m) => { + const data = newMilestoneMap[m.id]; + if (!data) return; + + /* --- update the milestone header --- */ + const payload = { + milestone_type : 'Financial', + title : data.title, + description : data.description, + date : toSqlDate(data.date), + career_profile_id : careerProfileId, + progress : data.progress, + status : data.progress >= 100 ? 'completed' : 'planned', + new_salary : data.newSalary ? parseFloat(data.newSalary) : null, + is_universal : data.isUniversal || 0 }; - const saveNewMilestone = async () => { - if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) { - alert("Need title and date"); - return; - } - const payload = { - title: newMilestoneData.title, - description: newMilestoneData.description, - date: newMilestoneData.date, - career_profile_id: careerProfileId, - progress: newMilestoneData.progress, - status: newMilestoneData.progress >= 100 ? "completed" : "planned", - is_universal: newMilestoneData.isUniversal || 0 - }; - try { - const res = await authFetch("/api/premium/milestone", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }); - if (!res.ok) throw new Error(await res.text()); - const created = Array.isArray(await res.json()) ? (await res.json())[0] : await res.json(); + try { + const res = await authFetch(`/api/premium/milestones/${m.id}`, { + method : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body : JSON.stringify(payload) + }); + if (!res.ok) throw new Error(await res.text()); + const saved = await res.json(); - // impacts - for (const imp of newMilestoneData.impacts) { - const impPayload = { - milestone_id: created.id, - impact_type: imp.impact_type, - direction: imp.direction, - amount: parseFloat(imp.amount) || 0, - start_date: imp.start_date || null, - end_date: imp.end_date || null - }; - await authFetch("/api/premium/milestone-impacts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(impPayload) + /* --- figure out what changed ---------------------------------- */ + const originalIds = originalImpactIdsMap[m.id] || []; + const currentIds = (data.impacts || []).map(i => i.id).filter(Boolean); + const toDelete = originalIds.filter(id => !currentIds.includes(id)); + + /* --- deletions first --- */ + for (const delId of toDelete) { + await authFetch(`/api/premium/milestone-impacts/${delId}`, { + method: 'DELETE' + }); + } + + /* --- creates / updates --- */ + for (const imp of data.impacts) { + const impPayload = { + milestone_id : saved.id, + impact_type : imp.impact_type, + direction : imp.direction, + amount : parseFloat(imp.amount) || 0, + start_date : toSqlDate(imp.start_date) || null, + end_date : toSqlDate(imp.end_date) || null + }; + + if (imp.id) { + await authFetch(`/api/premium/milestone-impacts/${imp.id}`, { + method : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body : JSON.stringify(impPayload) + }); + } else { + await authFetch('/api/premium/milestone-impacts', { + method : 'POST', + headers: { 'Content-Type': 'application/json' }, + body : JSON.stringify(impPayload) }); } - await fetchMilestones(); - onClose(true); - } catch (err) { - alert("Failed to save milestone"); } + + /* --- refresh + close --- */ + await fetchMilestones(); + setEditingMilestoneId(null); + + } catch (err) { + alert('Failed to save milestone'); + console.error(err); + } +}; + +/* ───────────── misc helpers the JSX still calls ───────────── */ + +/* A) delete one milestone row altogether */ +const deleteMilestone = async (milestone) => { + if (!window.confirm(`Delete “${milestone.title}” ?`)) return; + try { + const res = await authFetch( + `/api/premium/milestones/${milestone.id}`, + { method: 'DELETE' } + ); + if (!res.ok) throw new Error(await res.text()); + await fetchMilestones(); // refresh parent list + onClose(true); // bubble up that something changed + } catch (err) { + alert('Failed to delete milestone'); + console.error(err); + } +}; + +/* B) add a blank impact row while creating a brand-new milestone */ +const addNewImpactToNewMilestone = () => { + setNewMilestoneData(prev => ({ + ...prev, + impacts: [ + ...prev.impacts, + { + impact_type : 'ONE_TIME', + direction : 'subtract', + amount : 0, + start_date : '', + end_date : '' + } + ] + })); +}; + +/* C) create an entirely new milestone + its impacts */ +const saveNewMilestone = async () => { + if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) { + alert('Need title and date'); return; + } + + const payload = { + title : newMilestoneData.title, + description : newMilestoneData.description, + date : toSqlDate(newMilestoneData.date), + career_profile_id: careerProfileId, + progress : newMilestoneData.progress, + status : newMilestoneData.progress >= 100 ? 'completed' : 'planned', + is_universal : newMilestoneData.isUniversal || 0 }; + try { + const res = await authFetch('/api/premium/milestone', { + method : 'POST', + headers: { 'Content-Type': 'application/json' }, + body : JSON.stringify(payload) + }); + if (!res.ok) throw new Error(await res.text()); + const created = + Array.isArray(await res.json()) ? (await res.json())[0] : await res.json(); + + /* impacts for the new milestone */ + for (const imp of newMilestoneData.impacts) { + const impPayload = { + milestone_id : created.id, + impact_type : imp.impact_type, + direction : imp.direction, + amount : parseFloat(imp.amount) || 0, + start_date : toSqlDate(imp.start_date) || null, + end_date : toSqlDate(imp.end_date) || null + }; + await authFetch('/api/premium/milestone-impacts', { + method : 'POST', + headers: { 'Content-Type': 'application/json' }, + body : JSON.stringify(impPayload) + }); + } + + await fetchMilestones(); // refresh list + setAddingNewMilestone(false); // collapse the new-mile form + onClose(true); + } catch (err) { + alert('Failed to save milestone'); + console.error(err); + } +}; + /* ──────────────────────────────── Render */ @@ -296,6 +351,12 @@ export default function MilestoneEditModal({ +

{m.description}

@@ -343,7 +404,7 @@ export default function MilestoneEditModal({ /> {/* impacts */}

-
Impacts
+
Financial Impacts
{data.impacts?.map((imp, idx) => (
@@ -351,6 +412,7 @@ export default function MilestoneEditModal({ value={imp.impact_type} onChange={(e) => updateInlineImpact(m.id, idx, "impact_type", e.target.value)} > + @@ -389,7 +451,7 @@ export default function MilestoneEditModal({
))} - +
@@ -441,6 +503,7 @@ export default function MilestoneEditModal({ }); }} > + @@ -516,7 +579,7 @@ export default function MilestoneEditModal({ ))} - + diff --git a/src/components/SignIn.js b/src/components/SignIn.js index ebde410..2e43701 100644 --- a/src/components/SignIn.js +++ b/src/components/SignIn.js @@ -30,7 +30,7 @@ function SignIn({ setIsAuthenticated, setUser }) { } try { - const response = await fetch('https://dev1.aptivaai.com/api/signin', { + const response = await fetch('https://dev1.aptivaai.com/api/signin', { // <-here method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), @@ -105,7 +105,7 @@ function SignIn({ setIsAuthenticated, setUser }) {

Don’t have an account?{' '} Sign Up diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js index e825bcb..5d91941 100644 --- a/src/utils/FinancialProjectionService.js +++ b/src/utils/FinancialProjectionService.js @@ -370,41 +370,48 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) { baseMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12); } - /************************************************ - * 7.3 MILESTONE IMPACTS – strict number handling + /************************************************ + * 7.3 MILESTONE IMPACTS – safe number handling ************************************************/ -let extraImpactsThisMonth = 0; +let extraImpactsThisMonth = 0; // affects expenses +let salaryAdjustThisMonth = 0; // affects gross income milestoneImpacts.forEach((rawImpact) => { - /* --- safety / coercion ------------------------------------------------ */ - const amount = Number(rawImpact.amount) || 0; // ← always a number - const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // 'ONE_TIME' | 'MONTHLY' - const direction = (rawImpact.direction || 'subtract').toLowerCase(); // 'add' | 'subtract' + /* ---------- 1. Normalise ---------- */ + const amount = Number(rawImpact.amount) || 0; + const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // SALARY / SALARY_ANNUAL / MONTHLY / ONE_TIME + const direction = (rawImpact.direction || 'subtract').toLowerCase(); // add / subtract - /* --- date math -------------------------------------------------------- */ - const startDateClamped = moment(rawImpact.start_date).startOf('month'); - let startOffset = startDateClamped.diff(scenarioStartClamped, 'months'); - if (startOffset < 0) startOffset = 0; + /* ---------- 2. Work out timing ---------- */ + const startDate = moment(rawImpact.start_date).startOf('month'); + const endDate = rawImpact.end_date ? moment(rawImpact.end_date).startOf('month') : null; - let endOffset = Infinity; - if (rawImpact.end_date && rawImpact.end_date.trim() !== '') { - const endDateClamped = moment(rawImpact.end_date).startOf('month'); - endOffset = endDateClamped.diff(scenarioStartClamped, 'months'); - if (endOffset < 0) endOffset = 0; - } + const startOffset = Math.max(0, startDate.diff(scenarioStartClamped, 'months')); + const endOffset = endDate ? Math.max(0, endDate.diff(scenarioStartClamped, 'months')) : Infinity; - /* --- apply impact ----------------------------------------------------- */ - const applyAmount = (dir) => - dir === 'add' ? (baseMonthlyIncome += amount) : (extraImpactsThisMonth += amount); + const isActiveThisMonth = + (type === 'ONE_TIME' && monthIndex === startOffset) || + (type !== 'ONE_TIME' && monthIndex >= startOffset && monthIndex <= endOffset); - if (type === 'ONE_TIME') { - if (monthIndex === startOffset) applyAmount(direction); + if (!isActiveThisMonth) return; // skip to next impact + + /* ---------- 3. Apply the impact ---------- */ + const sign = direction === 'add' ? 1 : -1; + + if (type.startsWith('SALARY')) { + // SALARY = already-monthly | SALARY_ANNUAL = annual → divide by 12 + const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount; + salaryAdjustThisMonth += sign * monthlyDelta; } else { - // MONTHLY (or anything else) – apply for the whole span - if (monthIndex >= startOffset && monthIndex <= endOffset) applyAmount(direction); + // MONTHLY or ONE_TIME expenses / windfalls + extraImpactsThisMonth += sign * amount; } }); +/* ---------- 4. Reflect deltas in this month’s calc ---------- */ +baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax +// `extraImpactsThisMonth` is already added to expenses later in the loop + /************************************************ * 7.4 CALCULATE TAXES