Edit Goals added, Milestones/tasks drawer added.

This commit is contained in:
Josh 2025-06-17 12:31:30 +00:00
parent 9d00ac337c
commit 5eb0750dfb
10 changed files with 488 additions and 72 deletions

View File

@ -259,6 +259,19 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
}
});
// server3.js (add near the other career-profile routes)
app.put('/api/premium/career-profile/:id/goals', authenticatePremiumUser, async (req, res) => {
const { id } = req.params;
const { career_goals } = req.body;
// simple ownership check
const [rows] = await pool.query('SELECT user_id FROM career_profiles WHERE id=?', [id]);
if (!rows[0] || rows[0].user_id !== req.id) {
return res.status(403).json({ error: 'Not your profile.' });
}
await pool.query('UPDATE career_profiles SET career_goals=? WHERE id=?', [career_goals, id]);
res.json({ career_goals });
});
// DELETE a career profile (scenario) by ID
app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, async (req, res) => {
@ -2473,6 +2486,40 @@ app.delete('/api/premium/tasks/:taskId', authenticatePremiumUser, async (req, re
}
});
app.get('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
try {
const { career_path_id, status = 'all' } = req.query;
const args = [req.id]; // << first placeholder = user_id
let sql = `
SELECT
t.id, t.milestone_id, t.title, t.description,
t.due_date, t.status,
t.created_at, t.updated_at,
m.title AS milestone_title,
m.date AS milestone_date,
cp.id AS career_path_id,
cp.career_name
FROM tasks t
JOIN milestones m ON m.id = t.milestone_id
JOIN career_paths cp ON cp.id = m.career_path_id
WHERE cp.user_id = ?
`;
if (career_path_id) { sql += ' AND cp.id = ?'; args.push(career_path_id); }
if (status !== 'all') { sql += ' AND t.status = ?'; args.push(status); }
sql += ' ORDER BY COALESCE(t.due_date, m.date) ASC';
const [rows] = await pool.query(sql, args);
return res.json(rows);
} catch (err) {
console.error('Error fetching tasks:', err);
return res.status(500).json({ error: 'Failed to fetch tasks.' });
}
});
/* ------------------------------------------------------------------
MILESTONE IMPACTS ENDPOINTS
------------------------------------------------------------------ */
@ -2664,6 +2711,10 @@ app.delete('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser,
}
});
/* ------------------------------------------------------------------
O*NET KSA DATA
------------------------------------------------------------------ */
let onetKsaData = []; // entire array from ksa_data.json
let allKsaNames = []; // an array of unique KSA names (for fuzzy matching)

View File

@ -73,6 +73,8 @@ export default function CareerCoach({
userProfile,
financialProfile,
scenarioRow,
setScenarioRow,
careerProfileId,
collegeProfile,
onMilestonesCreated,
onAiRiskFetched
@ -83,6 +85,9 @@ export default function CareerCoach({
const [loading, setLoading] = useState(false);
const [aiRisk, setAiRisk] = useState(null);
const chatRef = useRef(null);
const [showGoals , setShowGoals ] = useState(false);
const [draftGoals, setDraftGoals] = useState(scenarioRow?.career_goals || "");
const [saving , setSaving ] = useState(false);
/* -------------- scroll --------------- */
useEffect(() => {
@ -248,7 +253,7 @@ I'm here to support you with personalized coaching. What would you like to focus
<h2 className="text-2xl font-semibold mb-4">Career Coach</h2>
{/* Quick-action bar */}
<div className="flex flex-wrap gap-2 mb-3">
<div className="flex flex-wrap items-center gap-2 mb-3">
<button
onClick={() => triggerQuickAction("networking")}
disabled={loading}
@ -270,7 +275,21 @@ I'm here to support you with personalized coaching. What would you like to focus
>
Interview Help
</button>
</div>
{/* pushes Edit Goals to the far right */}
<div className="flex-grow"></div>
<button
onClick={() => {
// always load the latest goals before showing the modal
setDraftGoals(scenarioRow?.career_goals || "");
setShowGoals(true);
}}
className="border border-gray-300 bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-300"
>
Edit&nbsp;Goals
</button>
</div>
{/* Chat area */}
<div
@ -322,6 +341,53 @@ I'm here to support you with personalized coaching. What would you like to focus
Send
</button>
</form>
{showGoals && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow">
<h3 className="text-xl font-semibold mb-3">Edit Your Career Goals</h3>
<textarea
value={draftGoals}
onChange={(e) => setDraftGoals(e.target.value)}
rows={5}
className="w-full border rounded p-2"
placeholder="Describe your short- and long-term goals…"
/>
<div className="mt-4 flex justify-end gap-2">
<button
className="px-4 py-2 rounded border"
onClick={() => setShowGoals(false)}
>
Cancel
</button>
<button
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
disabled={saving}
onClick={async () => {
setSaving(true);
await authFetch(
`/api/premium/career-profile/${careerProfileId}/goals`,
{
method : 'PUT',
headers: { 'Content-Type':'application/json' },
body : JSON.stringify({ career_goals: draftGoals })
}
);
// lift new goals into parent state so Jess sees them
setScenarioRow((p) => ({ ...p, career_goals: draftGoals }));
setSaving(false);
setShowGoals(false);
}}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -18,6 +18,7 @@ import {
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import MilestonePanel from './MilestonePanel.js';
import MilestoneDrawer from './MilestoneDrawer.js';
import MilestoneEditModal from './MilestoneEditModal.js';
import buildChartMarkers from '../utils/buildChartMarkers.js';
import getMissingFields from '../utils/getMissingFields.js';
@ -353,6 +354,9 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
const [milestoneForModal, setMilestoneForModal] = useState(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [focusMid , setFocusMid ] = useState(null);
const [drawerMilestone, setDrawerMilestone] = useState(null);
// Config
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
@ -558,18 +562,18 @@ useEffect(() => {
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
useEffect(() => {
const guard = modalGuard.current;
if (!dataReady || guard.checked) return;
/* honour skip flag (one-time after onboarding / sessionStorage) */
if (guard.skip) {
guard.skip = false; // consume it
guard.checked = true;
return;
}
if (!dataReady || !careerProfileId) return;
// one key per career profile
const key = `modalChecked:${careerProfileId}`;
// already checked in this browser session?
if (sessionStorage.getItem(key) === '1') return;
const status = (scenarioRow.college_enrollment_status || '').toLowerCase();
const requireCollege = ['currently_enrolled', 'prospective_student', 'deferred'].includes(status);
const requireCollege = ['currently_enrolled','prospective_student','deferred']
.includes(status);
const missing = getMissingFields(
{ scenario: scenarioRow, financial: financialProfile, college: collegeProfile },
@ -577,8 +581,11 @@ useEffect(() => {
);
if (missing.length) setShowEditModal(true);
guard.checked = true; // ensure we dont rerun
}, [dataReady, scenarioRow, financialProfile, collegeProfile]);
sessionStorage.setItem(key, '1'); // remember for this tab
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
useEffect(() => {
@ -1199,6 +1206,8 @@ const fetchMilestones = useCallback(async () => {
userProfile={userProfile}
financialProfile={financialProfile}
scenarioRow={scenarioRow}
setScenarioRow={setScenarioRow}
careerProfileId={careerProfileId}
collegeProfile={collegeProfile}
onMilestonesCreated={() => {
/* refresh or reload logic here */
@ -1326,61 +1335,65 @@ const fetchMilestones = useCallback(async () => {
</div>*/}
{/* --- FINANCIAL PROJECTION SECTION -------------------------------- */}
<div className="bg-white p-4 rounded shadow">
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
<div className="bg-white p-4 rounded shadow">
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
{projectionData.length ? (
<div className="flex flex-col md:flex-row gap-4">
{/* Milestone list / editor */}
<MilestonePanel
className="md:w-56"
groups={milestoneGroups}
onEdit={onEditMilestone} /* <-- use your existing handler */
/>
{projectionData.length ? (
<div>
{/* Chart now full width */}
<div style={{ height: 360, width: '100%' }}>
<Line
ref={chartRef}
data={{
labels: projectionData.map(p => p.month),
datasets: chartDatasets
}}
options={{
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
tooltip: { mode: 'index', intersect: false },
annotation: { annotations: allAnnotations }, // ✅ new
zoom: zoomConfig
},
scales: xAndYScales // unchanged
}}
/>
</div>
{/* Chart */}
<div className="flex-1">
<div style={{ height: 360, width: '100%' }}>
<Line
ref={chartRef}
data={{
labels: projectionData.map(p => p.month),
datasets: chartDatasets
}}
options={{
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
tooltip: { mode: 'index', intersect: false },
annotation: { annotations: allAnnotations }, // ✅ new
zoom: zoomConfig
},
scales: xAndYScales // unchanged
}}
/>
<Button onClick={() => chartRef.current?.resetZoom()}
className="mt-2 text-xs text-blue-600 hover:underline"
>
Reset Zoom
</Button>
{loanPayoffMonth && hasStudentLoan && (
<p className="font-semibold text-sm mt-2">
Loan Paid Off:&nbsp;
<span className="text-yellow-600">{loanPayoffMonth}</span>
</p>
)}
</div>
) : (
<p className="text-sm text-gray-500">No financial projection data found.</p>
)}
</div>
<Button
onClick={() => chartRef.current?.resetZoom()}
className="mt-2 text-xs text-blue-600 hover:underline"
>
Reset Zoom
</Button>
{/* Milestones stacked list under chart */}
<div className="mt-4 bg-white p-4 rounded shadow">
<h4 className="text-lg font-semibold mb-2">Milestones</h4>
<MilestonePanel
groups={milestoneGroups}
onEdit={onEditMilestone}
onSelect={(m) => {
setDrawerMilestone(m);
setDrawerOpen(true);
}}
/>
{loanPayoffMonth && hasStudentLoan && (
<p className="font-semibold text-sm mt-2">
Loan Paid Off:&nbsp;
<span className="text-yellow-600">{loanPayoffMonth}</span>
</p>
)}
</div>
</div>
) : (
<p className="text-sm text-gray-500">No financial projection data found.</p>
)}
</div>
</div>
{/* 6) Simulation length + Edit scenario */}
<div className="mt-4 space-x-2">
@ -1474,6 +1487,17 @@ const fetchMilestones = useCallback(async () => {
)}
<MilestoneDrawer
open={drawerOpen}
milestone={drawerMilestone}
onClose={() => setDrawerOpen(false)}
onTaskToggle={(id, newStatus) => {
// optimistic local patch or just refetch
fetchMilestones(); // simplest: keep server source of truth
}}
/>
{/* 7) AI Next Steps */}
{/* <div className="bg-white p-4 rounded shadow mt-4">

View File

@ -0,0 +1,57 @@
// src/components/EditableCareerGoals.js
import React, { useState } from 'react';
import { Button } from './ui/button.js';
import { Pencil, Save } from 'lucide-react';
import authFetch from '../utils/authFetch.js';
export default function EditableCareerGoals({ initialGoals='', careerProfileId, onSaved }) {
const [editing , setEditing ] = useState(false);
const [draftText, setDraftText] = useState(initialGoals);
const [saving , setSaving ] = useState(false);
async function save() {
setSaving(true);
const res = await authFetch(`/api/premium/career-profile/${careerProfileId}/goals`, {
method : 'PUT',
headers: { 'Content-Type':'application/json' },
body : JSON.stringify({ career_goals: draftText })
});
if (res.ok) {
onSaved(draftText);
setEditing(false);
}
setSaving(false);
}
return (
<div className="bg-white p-4 rounded shadow">
<div className="flex items-start justify-between">
<h3 className="text-lg font-semibold">Your Career Goals</h3>
{!editing && (
<Button size="icon" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="w-4 h-4" />
</Button>
)}
</div>
{editing ? (
<>
<textarea
value={draftText}
onChange={e => setDraftText(e.target.value)}
rows={4}
className="w-full border rounded p-2 mt-2"
placeholder="Describe your short- and long-term goals…"
/>
<Button onClick={save} disabled={saving} className="mt-2">
{saving ? 'Saving…' : <><Save className="w-4 h-4 mr-1" /> Save</>}
</Button>
</>
) : (
<p className="mt-2 whitespace-pre-wrap text-gray-700">
{initialGoals || <span className="italic text-gray-400">No goals entered yet.</span>}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,144 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Button } from './ui/button.js';
import { Card, CardContent } from './ui/card.js';
import { ChevronLeft, Check, Loader2 } from 'lucide-react';
import { flattenTasks } from '../utils/taskHelpers.js';
import authFetch from '../utils/authFetch.js';
import format from 'date-fns/format';
/* simple status → color map */
const pillStyle = {
completed : 'bg-green-100 text-green-800',
in_progress : 'bg-blue-100 text-blue-800',
not_started : 'bg-gray-100 text-gray-700'
};
export default function MilestoneDrawer({
milestone, // ← pass a single milestone object
milestones = [], // still needed to compute progress %
open,
onClose,
onTaskToggle = () => {}
}) {
/* gather tasks progress for this milestone */
const [tasks, setTasks] = useState(
milestone ? flattenTasks([milestone]) : []
);
// refresh local copy whenever the user selects a different milestone
useEffect(() => {
setTasks(milestone ? flattenTasks([milestone]) : []);
}, [milestone]);
const done = tasks.filter(t => t.status === 'completed').length;
const prog = tasks.length ? Math.round(100 * done / tasks.length) : 0;
if (!open || !milestone) return null;
async function toggle(t) {
const next = {
not_started : 'in_progress',
in_progress : 'completed',
completed : 'not_started' // undo
};
const newStatus = next[t.status] || 'not_started';
/* 1⃣ optimistic local update */
setTasks(prev =>
prev.map(x =>
x.id === t.id ? { ...x, status: newStatus } : x
)
);
/* 2⃣ inform parent so progress bars refresh elsewhere */
onTaskToggle(t.id, newStatus);
await authFetch(`/api/premium/tasks/${t.id}`, {
method : 'PUT',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ status: newStatus })
});
}
const statusLabel = {
not_started : 'Not started',
in_progress : 'In progress',
completed : 'Completed'
};
return (
<div className="fixed inset-y-0 right-0 w-full max-w-sm bg-white shadow-xl z-40 flex flex-col">
{/* Header */}
<div className="px-4 py-2 border-b flex items-center gap-3">
<Button size="icon" variant="ghost" onClick={onClose}>
<ChevronLeft className="w-5 h-5" />
</Button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{milestone.title}</p>
{milestone.date && (
<p className="text-xs text-gray-500 truncate">
{format(new Date(milestone.date), 'PP')}
</p>
)}
</div>
</div>
{/* Body */}
<Card className="flex-1 overflow-y-auto rounded-none">
<CardContent className="space-y-4">
{/* Progress bar */}
<div>
<progress value={prog} max={100} className="w-full h-2" />
<p className="text-xs text-gray-500 mt-1">{prog}% complete</p>
</div>
{/* Task list */}
{tasks.map(t => (
<div
key={t.id}
className="border p-3 rounded-lg flex items-start justify-between"
>
<div className="pr-2">
<p className="font-medium break-words">{t.title}</p>
{t.due_date && (
<p className="text-xs text-gray-500">
{format(new Date(t.due_date), 'PP')}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
aria-label="Toggle task status"
onClick={() => toggle(t)}
>
{t.status === 'completed'
? <Check className="w-5 h-5 text-green-600" />
: (
<span
className={`shrink-0 inline-block px-2 py-0.5 rounded-full text-[10px] font-semibold ${pillStyle[t.status]}`} style={{ whiteSpace: 'nowrap' }} /* never wrap */
>
{statusLabel[t.status]}
</span>
)
}
</Button>
</div>
))}
{!tasks.length && (
<p className="text-sm text-gray-500">
No tasks have been added to this milestone yet.
</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -3,9 +3,9 @@ import { Button } from './ui/button.js';
/* MilestonePanel.jsx ---------------------------------- */
export default function MilestonePanel({ groups, onEdit }) {
export default function MilestonePanel({ groups, onEdit, onSelect }) {
return (
<aside className="w-full md:w-56 pr-4">
<aside className="w-full pr-4">
{groups.map(g => (
<details key={g.month} className="mb-2">
<summary className="cursor-pointer font-semibold">
@ -14,15 +14,26 @@ export default function MilestonePanel({ groups, onEdit }) {
<ul className="mt-1 space-y-2">
{g.items.map(m => (
<li key={m.id} className="flex items-start gap-1 text-sm">
<span className="flex-1">{m.title}</span>
<li
key={m.id}
className="flex items-start justify-between text-sm group cursor-pointer"
onClick={() => onSelect?.(m)}
>
<span>{m.title}</span>
{/* edit pencil invisible until row hover */}
<Button
onClick={() => onEdit(m)}
size="icon"
className="bg-transparent hover:bg-muted p-1 rounded-sm"
>
<Pencil className="h-4 w-4" />
</Button>
size="icon"
variant="ghost"
className="opacity-0 group-hover:opacity-75 transition-opacity"
onClick={e => {
e.stopPropagation(); // dont also open drawer
onEdit(m);
}}
>
<Pencil className="h-4 w-4 text-gray-500 hover:text-gray-700" />
<span className="sr-only">Edit milestone</span>
</Button>
</li>
))}
</ul>

View File

@ -0,0 +1,13 @@
export function Badge({ className = '', children }) {
return (
<span
className={
`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold
bg-gray-200 text-gray-700 ${className}`
}
>
{children}
</span>
);
}

View File

@ -8,3 +8,13 @@ export const Card = ({ className, ...props }) => (
export const CardContent = ({ className, ...props }) => (
<div className={cn("p-4", className)} {...props} />
);
export const CardHeader = ({ className, ...props }) => (
<div
className={cn(
"px-4 py-2 border-b flex items-center justify-between",
className
)}
{...props}
/>
);

21
src/utils/infoIcon.js Normal file
View File

@ -0,0 +1,21 @@
import React from "react";
/**
* <InfoTooltip message="This is a helpful tooltip" />
*
* Re-usable tiny "i" badge that shows its `message` prop in a native
* browser tooltip (`title=`). Sized for use inline with headings or
* labels. Uses Tailwind classes onlyno external libs required.
*/
export default function InfoTooltip({ message = "" }) {
if (!message) return null;
return (
<span
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-white text-[10px] font-semibold cursor-help"
title={message}
>
i
</span>
);
}

19
src/utils/taskHelpers.js Normal file
View File

@ -0,0 +1,19 @@
export function flattenTasks(milestones) {
return milestones.flatMap(m =>
(m.tasks || []).map(t => ({
...t,
milestone_id : m.id,
milestoneTitle : m.title,
milestoneDate : m.date,
career_path_id : m.career_path_id
}))
);
}
/* recompute milestone.progress client-side (optimistic) */
export function recomputeProgress(tasksForMilestone) {
const total = tasksForMilestone.length;
if (!total) return 0;
const done = tasksForMilestone.filter(t => t.status === 'completed').length;
return Math.round((done / total) * 100);
}