New components for AI ops/REtirement planning

This commit is contained in:
Josh 2025-06-26 11:36:16 +00:00
parent fe2ec2d3c1
commit 8c78540e6c
9 changed files with 402 additions and 29 deletions

View File

@ -28,7 +28,7 @@ import FinancialProfileForm from './components/FinancialProfileForm.js';
import CareerRoadmap from './components/CareerRoadmap.js'; import CareerRoadmap from './components/CareerRoadmap.js';
import Paywall from './components/Paywall.js'; import Paywall from './components/Paywall.js';
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js'; import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
import MultiScenarioView from './components/MultiScenarioView.js'; import RetirementPlanner from './components/RetirementPlanner.js';
import ResumeRewrite from './components/ResumeRewrite.js'; import ResumeRewrite from './components/ResumeRewrite.js';
function App() { function App() {
@ -53,7 +53,7 @@ function App() {
'/career-roadmap', '/career-roadmap',
'/paywall', '/paywall',
'/financial-profile', '/financial-profile',
'/multi-scenario', '/retirement-planner',
'/premium-onboarding', '/premium-onboarding',
'/enhancing', '/enhancing',
'/retirement', '/retirement',
@ -446,10 +446,10 @@ function App() {
} }
/> />
<Route <Route
path="/multi-scenario" path="/retirement-planner"
element={ element={
<PremiumRoute user={user}> <PremiumRoute user={user}>
<MultiScenarioView /> <RetirementPlanner />
</PremiumRoute> </PremiumRoute>
} }
/> />

View File

@ -28,6 +28,7 @@ import { simulateFinancialProjection } from '../utils/FinancialProjectionService
import parseFloatOrZero from '../utils/ParseFloatorZero.js'; import parseFloatOrZero from '../utils/ParseFloatorZero.js';
import { getFullStateName } from '../utils/stateUtils.js'; import { getFullStateName } from '../utils/stateUtils.js';
import CareerCoach from "./CareerCoach.js"; import CareerCoach from "./CareerCoach.js";
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import ScenarioEditModal from './ScenarioEditModal.js'; import ScenarioEditModal from './ScenarioEditModal.js';

View File

@ -0,0 +1,244 @@
// src/components/RetirementChatBar.js
import React, { useEffect, useRef, useState } from 'react';
import { cn } from '../utils/cn.js';
import { Button } from './ui/button.js';
import authFetch from '../utils/authFetch.js';
/*
1. Static OPS cheat-sheet card
- sent exactly once, identified by the sentinel below
- keep the FULL text here (truncated for brevity)
*/
const OPS_SENTINEL = 'APTIVA OPS CHEAT-SHEET'; // do NOT change
const STATIC_SYSTEM_CARD = `
${OPS_SENTINEL}
🛠 Allowed OPS JSON
CREATE / UPDATE / DELETE milestones, tasks, impacts
Always wrap ops payload in a \`\`\`ops ... \`\`\` fence
One short confirmation line then the fence nothing after
(include your complete text here)
`.trim();
/* Other global helpers */
const CTX_SENTINEL = '[DETAILED USER PROFILE]';
const MAX_TURNS = 6;
/* ------------------------------------------------------------------ helpers */
function buildStatusSituationCard(userProfile = {}, scenarioRow = {}) {
const careerName = scenarioRow.career_name || 'your chosen career';
const status = scenarioRow.status || 'planned';
const situation = userProfile.career_situation || 'exploring';
return `[STATUS]
Currently **${status}**, you are ${situation} ${careerName}.`;
}
function buildMilestoneGridCard(grid = []) {
if (!grid.length) return '[CURRENT MILESTONES]\n- none -';
const rows = grid.map(r => `${r.id}|${r.title.trim()}|${r.date}`).join('\n');
return `[CURRENT MILESTONES]
(id | title | date)
${rows}`;
}
/* Build the outbound message array each turn */
function buildMessages({
chatHistory = [],
userProfile = {},
scenarioRow = {},
milestoneGrid = [],
largeSummaryCard = '',
forceContext = false
}) {
const safeHistory = chatHistory.map(m =>
m.content != null ? m : { ...m, content: m.text ?? '' }
);
/* Send static card once per convo */
const needOpsCard = !safeHistory.some(
m => m.role === 'system' && m.content?.includes(OPS_SENTINEL)
);
const staticCards = needOpsCard
? [{ role: 'system', content: STATIC_SYSTEM_CARD }]
: [];
/* Send big context when missing or forced */
const needCtxCard =
forceContext ||
!chatHistory.some(
m => m.role === 'system' && m.content?.startsWith(CTX_SENTINEL)
);
const contextCards =
needCtxCard && largeSummaryCard
? [{ role: 'system', content: `${CTX_SENTINEL}\n${largeSummaryCard}` }]
: [];
/* Small per-turn helpers */
const smallCards = [
{ role: 'system', content: buildStatusSituationCard(userProfile, scenarioRow) },
{ role: 'system', content: buildMilestoneGridCard(milestoneGrid) }
];
const recent = safeHistory.slice(-MAX_TURNS);
return [...staticCards, ...contextCards, ...smallCards, ...recent]
.map(m => ({ ...m, content: m.content ?? '' }));
}
/* ------------------------------------------------------------------ component */
export default function RetirementChatBar({
scenario = null, // full scenario row
userProfile = {}, // caller supplies or {}
financialProfile = {},
collegeProfile = {},
milestoneGrid = [],
onScenarioPatch,
className = ''
}) {
const [chatHistory, setChatHistory] = useState([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [forceCtx, setForceCtx] = useState(false);
const bottomRef = useRef(null);
/* wipe chat on scenario change */
useEffect(() => setChatHistory([]), [scenario?.id]);
/* autoscroll */
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }),
[chatHistory]);
/* ------------------------------------------------------------------ */
/* SEND PROMPT — guarded diff + safe input-handling */
/* ------------------------------------------------------------------ */
async function sendPrompt() {
const prompt = input.trim();
if (!prompt || !scenario?.id) return;
/* ① optimistic UI show the user bubble immediately */
const userMsg = { role: 'user', content: prompt };
setChatHistory(h => [...h, userMsg]);
setInput('');
setLoading(true);
try {
/* ② rebuild outbound history (adds system helper cards) */
const messagesToSend = buildMessages({
chatHistory : [...chatHistory, userMsg],
userProfile,
scenarioRow : scenario,
milestoneGrid,
largeSummaryCard: window.CACHED_SUMMARY || '',
forceContext : forceCtx
});
/* ③ POST to the retirement endpoint */
const res = await authFetch('/api/premium/retirement/aichat', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
prompt,
scenario_id : scenario.id, // ← keep it minimal
chatHistory : messagesToSend // ← backend needs this to find userMsg
})
});
const data = await res.json();
const assistantReply = data.reply || '(no response)';
setChatHistory(h => [...h, { role: 'assistant', content: assistantReply }]);
/* ④ if we got a real patch, build + forward the diff array */
if (data.scenarioPatch && onScenarioPatch) {
onScenarioPatch(scenario.id, data.scenarioPatch); // ✅ id + patch
}
} catch (err) {
console.error(err);
setChatHistory(h => [
...h,
{ role: 'assistant', content: '⚠️ Server error.' }
]);
} finally {
setLoading(false);
setForceCtx(false);
}
}
/* enter-to-send */
function handleKeyUp(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendPrompt();
}
}
/* ----------------------- render ----------------------- */
const scenarioLabel = scenario
? (scenario.scenario_title || scenario.career_name || 'Untitled Scenario')
: 'Select a scenario';
return (
<aside
className={cn(
'flex flex-col shrink-0 w-full md:w-[360px] border-l bg-white',
className
)}
>
<header className="p-3 border-b flex items-center gap-2 text-sm font-semibold">
{scenarioLabel}
<Button
size="sm"
variant="outline"
disabled={!scenario}
onClick={() => setForceCtx(true)}
title="Force refresh context for next turn"
>
🔄 Refresh context
</Button>
</header>
{/* chat log */}
<div className="flex-1 overflow-y-auto p-3 space-y-2 text-sm">
{chatHistory.map((m, i) => (
<div
key={i}
className={cn(
'max-w-[90%] rounded px-3 py-2 whitespace-pre-wrap',
m.role === 'user'
? 'ml-auto bg-blue-600 text-white'
: 'mr-auto bg-gray-100 text-gray-800'
)}
>
{m.content}
</div>
))}
<div ref={bottomRef} />
</div>
{/* composer */}
<form
onSubmit={e => { e.preventDefault(); sendPrompt(); }}
className="p-3 border-t flex gap-2"
>
<textarea
rows={1}
value={input}
onChange={e => setInput(e.target.value)}
onKeyUp={handleKeyUp}
disabled={!scenario}
placeholder={scenario
? 'Ask about this scenario…'
: 'Click a scenario card first'}
className="flex-1 resize-none border rounded px-2 py-1 text-sm disabled:bg-gray-100"
/>
<Button type="submit" disabled={!scenario || loading || !input.trim()}>
{loading ? '…' : 'Send'}
</Button>
</form>
</aside>
);
}

View File

@ -16,7 +16,7 @@ function RetirementLanding() {
</p> </p>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<Button onClick={() => navigate('/financial-profile')}>Update Financial Profile</Button> <Button onClick={() => navigate('/financial-profile')}>Update Financial Profile</Button>
<Button onClick={() => navigate('/multi-scenario')}>Set Retirement Milestones and get AI help with planning</Button> <Button onClick={() => navigate('/retirement-planner')}>Set Retirement Milestones and get AI help with planning</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,14 +1,29 @@
// src/components/MultiScenarioView.js // src/components/RetirementPlanner.js
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import ScenarioContainer from './ScenarioContainer.js'; import ScenarioContainer from './ScenarioContainer.js';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import RetirementChatBar from './RetirementChatBar.js';
import ScenarioDiffDrawer from './ScenarioDiffDrawer.js';
export default function MultiScenarioView() { export default function RetirementPlanner() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [financialProfile, setFinancialProfile] = useState(null); const [financialProfile, setFinancialProfile] = useState(null);
const [scenarios, setScenarios] = useState([]); const [scenarios, setScenarios] = useState([]);
const [diff , setDiff ] = useState(null);
const [chatId, setChatId] = useState(null);
const [selectedScenario, setSelectedScenario] = useState(null);
const applyPatch = (id, patch) => {
setScenarios(prev => {
const base = prev.find(s => s.id === id);
const next = prev.map(s => (s.id === id ? { ...s, ...patch } : s));
setDiff({ base, patch });
return next;
});
};
useEffect(() => { useEffect(() => {
loadScenariosAndFinancial(); loadScenariosAndFinancial();
@ -29,7 +44,7 @@ export default function MultiScenarioView() {
setFinancialProfile(finData); setFinancialProfile(finData);
setScenarios(scenData.careerProfiles || []); setScenarios(scenData.careerProfiles || []);
} catch (err) { } catch (err) {
console.error('MultiScenarioView =>', err); console.error('RetirementPlanner =>', err);
setError(err.message || 'Failed to load'); setError(err.message || 'Failed to load');
} finally { } finally {
setLoading(false); setLoading(false);
@ -290,28 +305,51 @@ export default function MultiScenarioView() {
} }
} }
if (loading) return <p>Loading scenarios...</p>;
if (error) return <p style={{ color: 'red' }}>{error}</p>;
const visible = scenarios.slice(0, 2); const visible = scenarios.slice(0, 2);
return ( if (loading) return <p>Loading scenarios</p>;
<div style={{ margin: '1rem' }}> if (error) return <p className="text-red-600">{error}</p>;
<Button onClick={handleAddScenario} style={{ marginBottom: '1rem' }}>
+ Add Scenario
</Button>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
{visible.map(sc => ( return (
<ScenarioContainer <div className="flex flex-col md:flex-row">
key={sc.id} {/* main column */}
scenario={sc} <div className="flex-1 p-4">
financialProfile={financialProfile} <Button onClick={handleAddScenario} className="mb-4">
onClone={handleCloneScenario} + Add Scenario
onRemove={handleRemoveScenario} </Button>
/>
))} <div className="grid gap-4 auto-cols-max md:grid-cols-[repeat(auto-fill,minmax(420px,1fr))]">
{visible.map(sc => (
<ScenarioContainer
key={sc.id}
scenario={sc}
financialProfile={financialProfile}
onClone={handleCloneScenario}
onRemove={handleRemoveScenario}
isSelected={selectedScenario?.id === sc.id}
onSelect={() => setSelectedScenario(sc)}
/>
))}
</div>
</div> </div>
{/* right rail */}
<RetirementChatBar
scenario={selectedScenario}
financialProfile={financialProfile}
onScenarioPatch={applyPatch}
className="w-full md:w-[360px] border-l bg-white"
/>
{/* diff drawer */}
{!!diff && (
<ScenarioDiffDrawer
base={diff.base}
patch={diff.patch}
onClose={() => setDiff(null)}
/>
)}
</div> </div>
); );
} }

View File

@ -17,7 +17,8 @@ export default function ScenarioContainer({
scenario, scenario,
financialProfile, financialProfile,
onRemove, onRemove,
onClone onClone,
onSelect
}) { }) {
// ------------------------------------------------------------- // -------------------------------------------------------------
// 1) States // 1) States
@ -846,7 +847,11 @@ export default function ScenarioContainer({
// 10) Render // 10) Render
// ------------------------------------------------------------- // -------------------------------------------------------------
return ( return (
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}> <article
onClick={() => onSelect(localScenario.id)}
className="w-[420px] border border-gray-300 p-4 rounded cursor-pointer
hover:shadow-sm transition-shadow bg-white"
>
<select <select
style={{ marginBottom: '0.5rem', width: '100%' }} style={{ marginBottom: '0.5rem', width: '100%' }}
value={localScenario?.id || ''} value={localScenario?.id || ''}
@ -1375,6 +1380,6 @@ export default function ScenarioContainer({
</div> </div>
</div> </div>
)} )}
</div> </article>
); );
} }

View File

@ -0,0 +1,36 @@
import React from "react";
import { Button } from "./ui/button.js";
export default function ScenarioDiffDrawer({ base, patch, onClose }) {
if (!patch) return null;
const keys = Object.keys(patch);
return (
<div className="fixed inset-x-0 bottom-0 max-h-60 overflow-y-auto bg-white border-t border-gray-300 z-50 p-4 shadow-lg">
<h4 className="font-semibold mb-3">Scenario changes</h4>
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="py-1 pr-4">Field</th>
<th className="py-1 pr-4">Before</th>
<th className="py-1">After</th>
</tr>
</thead>
<tbody>
{keys.map(k => (
<tr key={k} className="border-b last:border-0">
<td className="py-1 pr-4">{k}</td>
<td className="py-1 pr-4">{String(base[k] ?? "—")}</td>
<td className="py-1">{String(patch[k])}</td>
</tr>
))}
</tbody>
</table>
<Button onClick={onClose} className="mt-4">
Close
</Button>
</div>
);
}

49
src/utils/opsEngine.js Normal file
View File

@ -0,0 +1,49 @@
export async function applyOps(opsObj, { req, userId, scenarioId }) {
if (!Array.isArray(opsObj?.milestones)) return [];
const apiBase = process.env.APTIVA_INTERNAL_API || 'http://localhost:5002/api';
const auth = (p, o = {}) => internalFetch(req, `${apiBase}${p}`, o);
const confirmations = [];
for (const m of opsObj.milestones) {
const op = (m?.op || '').toUpperCase();
/* ---------- DELETE ---------- */
if (op === 'DELETE' && m.id) {
const cleanId = m.id.trim();
const r = await auth(`/premium/milestones/${cleanId}`, { method: 'DELETE' });
if (r.ok) confirmations.push(`Deleted milestone ${cleanId}`);
continue;
}
/* ---------- UPDATE ---------- */
if (op === 'UPDATE' && m.id && m.patch) {
const r = await auth(`/premium/milestones/${m.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(m.patch),
});
if (r.ok) confirmations.push(`Updated milestone ${m.id}`);
continue;
}
/* ---------- CREATE ---------- */
if (op === 'CREATE' && m.data) {
// inject career_profile_id if the bot forgot it
m.data.career_profile_id = m.data.career_profile_id || scenarioId;
const r = await auth('/premium/milestone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(m.data),
});
if (r.ok) {
const j = await r.json();
const newId = Array.isArray(j) ? j[0]?.id : j.id;
confirmations.push(`Created milestone ${newId || '(new)'}`);
}
}
}
return confirmations;
}