From 7b7536462b01330cc8d26f6913f9eced6eaf20c6 Mon Sep 17 00:00:00 2001 From: austinchima Date: Mon, 29 Jun 2026 01:34:21 -0300 Subject: [PATCH] feat: Redesign core pages into interactive Command Center layout --- .env.example | 15 + Precept.Api/Controllers/AuthController.cs | 45 ++ Precept.Web/src/AuthContext.tsx | 11 +- Precept.Web/src/index.css | 20 +- Precept.Web/src/main.tsx | 31 +- Precept.Web/src/pages/AppTracker.tsx | 35 +- Precept.Web/src/pages/Dashboard.tsx | 867 ++++++++++------------ Precept.Web/src/pages/JDMatcher.tsx | 37 +- Precept.Web/src/pages/Landing.tsx | 59 +- Precept.Web/src/pages/Readiness.tsx | 35 +- Precept.Web/src/pages/Settings.tsx | 22 +- Precept.Web/src/pages/StoryBank.tsx | 35 +- Precept.Web/src/types.ts | 1 + Precept.Web/src/vite-env.d.ts | 1 + 14 files changed, 667 insertions(+), 547 deletions(-) create mode 100644 .env.example create mode 100644 Precept.Web/src/vite-env.d.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d36c3a9 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy this file to `.env` (which is git-ignored) before running `docker compose up`. +# +# These values intentionally match Precept.Api/appsettings.Development.json so a +# locally-run backend (`dotnet run`) connects to the containerized Postgres on +# localhost:5432 with no further configuration. Change them for production. + +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=precept_db + +# Must be at least 32 bytes for HMAC-SHA256. Replace with a strong secret in production. +JWT_SECRET_KEY=dev-only-not-for-production-must-be-at-least-32-bytes-long-for-hmac-sha256 + +# Comma-separated host allow-list, or * for any. Restrict this in production. +ALLOWED_HOSTS=* diff --git a/Precept.Api/Controllers/AuthController.cs b/Precept.Api/Controllers/AuthController.cs index 91222c2..0ec02a6 100644 --- a/Precept.Api/Controllers/AuthController.cs +++ b/Precept.Api/Controllers/AuthController.cs @@ -533,6 +533,45 @@ public async Task UpdateProfile([FromBody] UpdateProfileRequest r }); } + // ───────────────────────────────────────────────────────────── + // DELETE /api/auth/account + // ───────────────────────────────────────────────────────────── + + /// + /// Permanently deletes the authenticated user's account and ALL of their data. + /// Deleting the AspNetUsers row triggers ON DELETE CASCADE on every owned table + /// (stories, applications + events, job descriptions, skills, behavioral stories, + /// testimonials, refresh tokens, and the Identity claim/login/role/token tables), + /// so nothing is left behind in PostgreSQL. This is irreversible. + /// + [Authorize] + [HttpDelete("account")] + [EnableRateLimiting("auth")] + public async Task DeleteAccount() + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(); + + var user = await userManager.FindByIdAsync(userId); + if (user == null) + return NotFound(); + + // Single DELETE FROM "AspNetUsers" — the database cascades to all dependent rows. + var result = await userManager.DeleteAsync(user); + if (!result.Succeeded) + { + logger.AccountDeletionFailed(userId); + return BadRequest(result.Errors); + } + + // Refresh-token rows are gone via cascade; clear the client cookie too. + ClearRefreshCookie(); + logger.AccountDeleted(userId); + + return Ok(new { message = "Account and all associated data have been permanently deleted." }); + } + /// /// [Fail-Safe Identity-Wide Cascade Revocation]: Invalidates all active sessions for a user identity. /// Design Scope Note: While our replay detection mechanism is strictly "Lineage-Aware" (using ReplacedByToken @@ -573,4 +612,10 @@ public static partial class AuthControllerLoggerExtensions [LoggerMessage(Level = LogLevel.Information, Message = "Refresh token revoked for user {UserId}")] public static partial void TokenRevoked(this ILogger logger, string? userId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Account permanently deleted for user {UserId}")] + public static partial void AccountDeleted(this ILogger logger, string? userId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Account deletion failed for user {UserId}")] + public static partial void AccountDeletionFailed(this ILogger logger, string? userId); } \ No newline at end of file diff --git a/Precept.Web/src/AuthContext.tsx b/Precept.Web/src/AuthContext.tsx index 1354b4f..ccf7e42 100644 --- a/Precept.Web/src/AuthContext.tsx +++ b/Precept.Web/src/AuthContext.tsx @@ -124,8 +124,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }; + // Permanently deletes the account and all server-side data. Only clears local + // session state once the backend confirms the deletion succeeded. + const deleteAccount = async () => { + await api.delete('/api/auth/account'); + setIsAuthenticated(false); + setUser(null); + setAccessToken(null); + }; + return ( - + {children} ); diff --git a/Precept.Web/src/index.css b/Precept.Web/src/index.css index 7a162f1..b60efb3 100644 --- a/Precept.Web/src/index.css +++ b/Precept.Web/src/index.css @@ -111,16 +111,16 @@ --font-label-caps: "JetBrains Mono", monospace; --font-code: "JetBrains Mono", monospace; - /* Spacings */ - --spacing-md: 24px; - --spacing-xl: 96px; - --spacing-container-max: 1440px; - --spacing-sm: 12px; - --spacing-lg: 48px; - --spacing-margin: 64px; - --spacing-gutter: 24px; - --spacing-base: 8px; - --spacing-xs: 4px; + /* Design-system spacings (prefixed --space- to avoid Tailwind v4 collision) */ + --space-xs: 4px; + --space-sm: 12px; + --space-base: 8px; + --space-md: 24px; + --space-lg: 48px; + --space-xl: 96px; + --space-margin: 64px; + --space-gutter: 24px; + --space-container-max: 1440px; } @layer base { diff --git a/Precept.Web/src/main.tsx b/Precept.Web/src/main.tsx index 6c136ec..0bfbdb6 100644 --- a/Precept.Web/src/main.tsx +++ b/Precept.Web/src/main.tsx @@ -4,16 +4,27 @@ import App from './App.tsx'; import './index.css'; if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - navigator.serviceWorker.register('/sw.js').then( - (registration) => { - console.log('Precept PWA ServiceWorker registered with scope:', registration.scope); - }, - (err) => { - console.error('Precept PWA ServiceWorker registration failed:', err); - } - ); - }); + if (import.meta.env.PROD) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').then( + (registration) => { + console.log('Precept PWA ServiceWorker registered with scope:', registration.scope); + }, + (err) => { + console.error('Precept PWA ServiceWorker registration failed:', err); + } + ); + }); + } else { + // In dev the SW caches Vite's hashed chunks and serves stale ones, + // breaking HMR and causing duplicate-React errors. Unregister and purge. + navigator.serviceWorker.getRegistrations().then((registrations) => { + registrations.forEach((registration) => registration.unregister()); + }); + if (window.caches) { + caches.keys().then((keys) => keys.forEach((key) => caches.delete(key))); + } + } } createRoot(document.getElementById('root')!).render( diff --git a/Precept.Web/src/pages/AppTracker.tsx b/Precept.Web/src/pages/AppTracker.tsx index d18e1b6..063b551 100644 --- a/Precept.Web/src/pages/AppTracker.tsx +++ b/Precept.Web/src/pages/AppTracker.tsx @@ -8,7 +8,7 @@ import confetti from 'canvas-confetti'; import { getCompanyIcon } from '../lib/utils'; import { AnimatedSection } from '../components/animation/AnimatedSection'; import { CountUp } from '../components/animation/CountUp'; -import { Plus, LayoutGrid, List, X, Trash2, Loader2 } from 'lucide-react'; +import { Plus, LayoutGrid, List, X, Trash2, Loader2, Terminal } from 'lucide-react'; /* ─────── DESIGN TOKENS (Landing.tsx) ─────── */ const C = { @@ -308,8 +308,36 @@ export default function AppTracker() { Loading pipeline… ) : ( -
- {view === 'board' ? ( +
+ {/* Window Chrome Header */} +
+
+ + + +
+
+ precept · ~/career/pipeline +
+
+ + {apps.length} active items +
+
+ +
+ {view === 'board' ? (
{COLUMNS.map((col) => { const color = statusColor(col); @@ -449,6 +477,7 @@ export default function AppTracker() {
)} +
)} diff --git a/Precept.Web/src/pages/Dashboard.tsx b/Precept.Web/src/pages/Dashboard.tsx index 0d50611..3056145 100644 --- a/Precept.Web/src/pages/Dashboard.tsx +++ b/Precept.Web/src/pages/Dashboard.tsx @@ -1,17 +1,31 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, Cell } from 'recharts'; import { api } from '../api'; -import { Application, Story, Skill, BehavioralStory } from '../types'; +import { Application, Story, Skill, BehavioralStory, ConfidenceLevel } from '../types'; import { useAuth } from '../AuthContext'; +import { useToast } from '../components/ui/Toast'; import { getSkillIcon, getCompanyIcon } from '../lib/utils'; import { CountUp } from '../components/animation/CountUp'; -import { AnimatedSection } from '../components/animation/AnimatedSection'; import SkillRadar from '../components/SkillRadar'; import { computeSkillAxes, READINESS_TARGET } from '../lib/skills'; -import { ArrowRight, ArrowUpRight, Plus, Hash } from 'lucide-react'; - -/* ─────── DESIGN TOKENS (Landing.tsx) ─────── */ +import { + Layers, + FileCode2, + RefreshCw, + GitBranch, + Activity, + Terminal, + Hash, + ArrowUpRight, + Plus, + Sparkles, + ArrowRight, + Zap, + Check, + ChevronDown +} from 'lucide-react'; + +/* ─────── DESIGN TOKENS (Matching Landing.tsx) ─────── */ const C = { bg0: '#02050A', bg1: '#06090F', bg2: '#0B0F17', bg3: '#11161F', ink: '#E6EBF2', inkDim: '#9CA8B8', inkMute: '#5A6678', @@ -20,22 +34,13 @@ const C = { violet: '#8b5cf6', rose: '#f43f5e', amber: '#f59e0b', sky: '#38bdf8', emerald: '#10b981', } as const; -const cardStyle = (): React.CSSProperties => ({ - background: `linear-gradient(180deg, ${C.bg1} 0%, ${C.bg0} 100%)`, - border: `1px solid ${C.hair}`, - borderRadius: 18, - boxShadow: '0 1px 0 rgba(255,255,255,0.04) inset', -}); - -const Eyebrow = ({ children, color = C.teal }: { children: React.ReactNode; color?: string }) => ( - - - {children} - -); +const ConfidenceRungs: { key: ConfidenceLevel; label: string; color: string; pct: number }[] = [ + { key: 'Panic', label: 'Panic', color: C.rose, pct: 18 }, + { key: 'Shaky', label: 'Shaky', color: C.amber, pct: 36 }, + { key: 'Okay', label: 'Okay', color: C.sky, pct: 56 }, + { key: 'Solid', label: 'Solid', color: C.teal, pct: 80 }, + { key: 'CanTeach', label: 'Can Teach',color: C.emerald, pct: 100 }, +]; interface DashboardStats { storyStats: { @@ -62,6 +67,7 @@ interface DashboardStats { export default function Dashboard() { const navigate = useNavigate(); const { user } = useAuth(); + const toast = useToast(); const [stats, setStats] = useState(null); const [applications, setApplications] = useState([]); @@ -70,9 +76,10 @@ export default function Dashboard() { const [behavioralStories, setBehavioralStories] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [activeAppTab, setActiveAppTab] = useState<'Total' | 'Active' | 'Interviews' | 'Offers'>('Total'); - const [activeStoryTag, setActiveStoryTag] = useState(null); - const [activeStarTab, setActiveStarTab] = useState<'situation' | 'task' | 'action' | 'result'>('situation'); + // Spotlight controls + const [spotlightType, setSpotlightType] = useState<'technical' | 'behavioral'>('technical'); + const [selectedStoryIndex, setSelectedStoryIndex] = useState(0); + const [selectedBehavioralIndex, setSelectedBehavioralIndex] = useState(0); useEffect(() => { const loadDashboardData = async () => { @@ -99,549 +106,439 @@ export default function Dashboard() { }, []); const activeApps = applications.filter((a) => ['Applied', 'PhoneScreen', 'Interviewing'].includes(a.status)); - const interviewApps = applications.filter((a) => ['PhoneScreen', 'Interviewing'].includes(a.status)); - const offerApps = applications.filter((a) => a.status === 'Offer'); - - const getFilteredApps = () => { - switch (activeAppTab) { - case 'Active': return activeApps; - case 'Interviews': return interviewApps; - case 'Offers': return offerApps; - default: return applications; - } - }; - - const recentApps = getFilteredApps() + const recentApps = [...applications] .sort((a, b) => new Date(b.dateApplied || b.followUpDate).getTime() - new Date(a.dateApplied || a.followUpDate).getTime()) - .slice(0, 3); - - const storyTags: string[] = Array.from( - new Set( - behavioralStories.flatMap((s) => - s.tags ? s.tags.split(',').map((t) => t.trim()).filter(Boolean) : [], - ), - ), - ).slice(0, 4); - - const filteredBehavioralStories = activeStoryTag - ? behavioralStories.filter((s) => s.tags?.toLowerCase().includes(activeStoryTag.toLowerCase())) - : behavioralStories; - - const recentBehavioralStories = filteredBehavioralStories.slice(0, 3); - - const topSkills = [...skills] - .sort((a, b) => { - const pA = a.proficiencyLevel === 'Expert' ? 4 : a.proficiencyLevel === 'Advanced' ? 3 : a.proficiencyLevel === 'Intermediate' ? 2 : 1; - const pB = b.proficiencyLevel === 'Expert' ? 4 : b.proficiencyLevel === 'Advanced' ? 3 : b.proficiencyLevel === 'Intermediate' ? 2 : 1; - return pB - pA; - }) .slice(0, 6); - const statusPill = (status: string): React.CSSProperties => { + const skillAxes = computeSkillAxes(skills); + + const statusPill = (status: string): { bg: string; color: string; border: string } => { let color: string = C.teal; if (status === 'Applied') color = C.teal; else if (status === 'PhoneScreen' || status === 'Interviewing' || status === 'Tech Interview') color = C.sky; else if (status === 'Offer') color = C.emerald; else if (status === 'Rejected') color = C.rose; else color = C.inkDim; - return { background: `${color}1c`, color, border: `1px solid ${color}44` }; - }; - - const getProficiencyPercentage = (level: string) => { - switch (level) { - case 'Expert': return 95; - case 'Advanced': return 80; - case 'Intermediate': return 60; - case 'Beginner': return 30; - default: return 50; - } - }; - - const getConfidenceColor = (level: string): string => { - switch (level.toLowerCase()) { - case 'panic': return C.rose; - case 'shaky': return '#fb923c'; - case 'okay': return C.amber; - case 'solid': return C.teal; - case 'canteach': return C.emerald; - default: return C.violet; - } + return { bg: `${color}1c`, color, border: `1px solid ${color}44` }; }; const getCompanyLogo = (name: string) => { const { icon, color, isText, initials } = getCompanyIcon(name); if (isText) { return ( -
+
{initials}
); } return ( -
- +
+
); }; - const skillAxes = computeSkillAxes(skills); + // Interactive handlers + const handleUpdateConfidence = async (newRung: ConfidenceLevel) => { + if (spotlightType === 'behavioral') return; + const currentStory = stories[selectedStoryIndex % stories.length]; + if (!currentStory) return; + + try { + const updated = await api.put(`/api/story/${currentStory.id}`, { ...currentStory, confidenceLevel: newRung }); + setStories((prev) => prev.map((s) => (s.id === updated.id ? updated : s))); + toast.success(`Updated confidence to ${newRung}`); + } catch (err) { + console.error(err); + toast.error('Failed to update story confidence.'); + } + }; + + const handleUpdateAppStatus = async (appId: string, newStatus: any) => { + try { + await api.patch(`/api/application/${appId}/status`, { status: newStatus }); + setApplications((prev) => prev.map((a) => (a.id === appId ? { ...a, status: newStatus } : a))); + toast.success(`Moved application to ${newStatus}`); + } catch (err) { + console.error(err); + toast.error('Failed to update status.'); + } + }; if (isLoading) { return (
-
- Booting command center… +
+ Initializing Precept Command Center…
); } - const appCount = activeAppTab === 'Total' ? applications.length : - activeAppTab === 'Active' ? activeApps.length : - activeAppTab === 'Interviews' ? (stats?.applicationStats.interviewingCount || 0) : - (stats?.applicationStats.offersCount || 0); + // Active spotlight item + const activeTechStory = stories.length > 0 ? stories[selectedStoryIndex % stories.length] : null; + const activeSTARStory = behavioralStories.length > 0 ? behavioralStories[selectedBehavioralIndex % behavioralStories.length] : null; + + const currentConfidence = activeTechStory ? activeTechStory.confidenceLevel : 'Okay'; + const currentRungIndex = Math.max(0, ConfidenceRungs.findIndex(r => r.key.toLowerCase() === currentConfidence.toLowerCase())); + + // Stories due for review + const dueForReview = stories.filter(s => s.confidenceLevel === 'Panic' || s.confidenceLevel === 'Shaky'); return (
- - {/* HERO WELCOME */} -
+ + {/* HEADER COCKPIT BAR */} +
- Command center -

+
+ + Career OS Cockpit +
+

Welcome back,{' '} {user?.firstName || 'developer'}.

-

- Your pipeline, stories and skills — in one cockpit. +

+ All systems nominal. Track your pipeline, drill stories, and defend your readiness.

- -
- - {/* ACTIVE APPS + STATS */} -
-
-
-
- Active applications · {activeAppTab.toLowerCase()} -
-
- -
-
- - +
+
+
- {/* Desktop table */} - - - - - - - - - - - - {recentApps.length === 0 && ( - - )} - {recentApps.map((app) => ( - navigate('/applications')} - className="transition-colors cursor-pointer group" - style={{ borderBottom: `1px solid ${C.hair}` }} - onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.025)')} - onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} - > - - - - - - - ))} - -
CompanyRoleStatusApplied -
No active records yet. Add one above.
-
- {getCompanyLogo(app.companyName)} - {app.companyName} -
-
{app.roleTitle} - - {app.status} - - - {app.dateApplied ? new Date(app.dateApplied).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '—'} - - -
-
- - {/* Mobile list */} -
- {recentApps.length === 0 && ( -
No active records yet.
- )} - {recentApps.map((app) => ( -
navigate('/applications')} className="p-4 cursor-pointer transition-colors" style={{ ...cardStyle(), borderRadius: 14 }}> -
-
- {getCompanyLogo(app.companyName)} -
-
{app.companyName}
-
{app.roleTitle}
-
-
- {app.status} -
-
- ))} -
- - {/* Quick stats footer */} -
- {[ - { l: 'Total', n: applications.length, c: C.teal }, - { l: 'Active', n: activeApps.length, c: C.sky }, - { l: 'Interviews', n: stats?.applicationStats.interviewingCount || 0, c: C.amber }, - { l: 'Offers', n: stats?.applicationStats.offersCount || 0, c: C.emerald }, - ].map((s) => ( -
-
{s.l}
-
- -
-
- ))} -
-
- - {/* LOWER GRID */} -
- {/* STAR Story Bank */} -
-
-
- Story bank · STAR -
- narratives -
+ {/* TOP METRICS RIBBON */} +
+ {[ + { label: 'Active Pipeline', val: activeApps.length, sub: `${applications.length} total tracked`, color: C.teal, route: '/applications' }, + { label: 'Story Inventory', val: stories.length + behavioralStories.length, sub: `${stories.length} tech · ${behavioralStories.length} STAR`, color: C.violet, route: '/story-bank' }, + { label: 'Drill Readiness', val: stats?.storyStats.totalReviewed || 0, sub: `${dueForReview.length} items due for review`, color: C.amber, route: '/readiness' }, + { label: 'JD Match Score', val: stats?.jobDescriptionStats.averageMatchScore ? `${Math.round(stats.jobDescriptionStats.averageMatchScore)}%` : '—', sub: `${stats?.jobDescriptionStats.totalJobDescriptions || 0} analyses run`, color: C.sky, route: '/job-descriptions' }, + ].map((m, idx) => ( +
navigate(m.route)} + className="p-4 rounded-xl cursor-pointer transition-all duration-300 group hover:border-white/20" + style={{ background: `linear-gradient(180deg, ${C.bg1} 0%, ${C.bg0} 100%)`, border: `1px solid ${C.hair}` }} + > +
+ {m.label} +
-
- - +
+ {typeof m.val === 'number' ? : m.val}
+
{m.sub}
+ ))} +
- {/* Tags */} -
- - {(storyTags.length === 0 ? ['Problem Solving', 'Leadership', 'System Design'] : storyTags).map((tag) => { - const isActive = activeStoryTag === tag; - return ( - - ); - })} + {/* FUNCTIONAL COMMAND CENTER WORKSPACE */} +
+ {/* Window Chrome Header */} +
+
+ + +
+
+ precept · ~/career/cockpit +
+
+ + live session +
+
- {/* Body */} -
- - - - {recentBehavioralStories.length === 0 && ( -
- No narratives yet — bank one to start. -
- )} - {recentBehavioralStories.map((story) => { - const preview = - activeStarTab === 'situation' ? story.situation : - activeStarTab === 'task' ? story.task : - activeStarTab === 'action' ? story.action : - story.result; - return ( -
navigate('/story-bank')} - className="p-4 cursor-pointer transition-all duration-300 group" - style={{ background: C.bg2, border: `1px solid ${C.hair}`, borderRadius: 14 }} - onMouseEnter={(e) => (e.currentTarget.style.borderColor = C.hair2)} - onMouseLeave={(e) => (e.currentTarget.style.borderColor = C.hair)} + +
+ +
+ {(spotlightType === 'technical' ? stories.length : behavioralStories.length) > 1 && ( + + )} +
+
+ + {/* Story Content Card */} +
+ {spotlightType === 'technical' ? ( + activeTechStory ? ( + <> +
+ + {activeTechStory.category} + + {activeTechStory.sourceProject || 'General'}
- {activeStarTab} +

+ {activeTechStory.title} +

+

+ {activeTechStory.explanation} +

+ {activeTechStory.codeSnippet && ( +
+                          {activeTechStory.codeSnippet}
+                        
+ )} + + ) : ( +
+ No technical stories banked yet. Bank stories to enable instant recall drilling.
-
- {story.tags ? story.tags.split(',').map((t, i) => ( - - {t.trim()} + ) + ) : ( + activeSTARStory ? ( + <> +
+ + STAR Method - )) : ( - behavioral - )} + {activeSTARStory.company || 'General'} +
+

+ {activeSTARStory.title} +

+
+

Situation: {activeSTARStory.situation}

+

Action: {activeSTARStory.action}

+

Result: {activeSTARStory.result}

+
+ + ) : ( +
+ No STAR behavioral stories banked yet.
-

- {preview || 'No context recorded.'} -

-
- ); - })} - -
-
- - {/* SKILLS SIDE */} -
-
navigate('/readiness')} - role="button" - tabIndex={0} - onKeyDown={(e) => { if (e.key === 'Enter') navigate('/readiness'); }} - className="p-6 flex flex-col relative overflow-hidden cursor-pointer group" - style={cardStyle()} - data-testid="dash-skills-radar" - > -
- Skills matrix - + ) + )} +
-
- {skillAxes.length >= 3 ? ( - - ) : ( -
- Add skills in ≥3 categories
to chart your matrix. + + {/* Interactive Confidence Update Bar & Drill CTA */} +
+ {spotlightType === 'technical' && activeTechStory && ( +
+
+ + Click to Update Confidence + + + {activeTechStory.confidenceLevel} + +
+
+ {ConfidenceRungs.map((r) => { + const isCurrent = r.key.toLowerCase() === activeTechStory.confidenceLevel.toLowerCase(); + return ( + + ); + })} +
)} + +
- +
-
- Top skills - - {topSkills.length === 0 && ( -
No skills yet.
- )} - {topSkills.map((skill) => { - const details = getSkillIcon(skill.name); - const pct = getProficiencyPercentage(skill.proficiencyLevel); - return ( -
-
- - {skill.name} -
-
- level{pct}% -
-
-
+ {/* Column 2: Live Pipeline Action Center (4 Cols) */} +
+
+
+ Active Pipeline + {activeApps.length} active +
+ +
+ {recentApps.length === 0 && ( +
No applications logged yet.
+ )} + {recentApps.map((a) => { + const pill = statusPill(a.status); + return ( +
navigate('/applications')} + className="flex items-center justify-between rounded-xl p-3 cursor-pointer transition-colors hover:border-white/20" + style={{ background: C.bg1, border: `1px solid ${C.hair}` }} + > +
+ {getCompanyLogo(a.companyName)} +
+
{a.companyName}
+
{a.roleTitle}
+
+
+ + {/* Interactive Quick Status Changer */} +
e.stopPropagation()}> + + +
-
- ); - })} - -
-
-
+ ); + })} +
+
- {/* ANALYTICS */} -
-
-
- Analytics -

- Pipeline insights. -

+
-
-
- {/* Pipeline funnel */} -
+ {/* Column 3: Readiness Radar & Review Queue (3 Cols) */} +
-
Application pipeline
-
- total +
+ Readiness + {dueForReview.length} due
-
- {[ - { l: 'Response rate', v: stats?.applicationStats.responseRate || 0, c: C.sky }, - { l: 'Offer conversion', v: stats?.applicationStats.interviewingCount ? (stats.applicationStats.offersCount / stats.applicationStats.interviewingCount) * 100 : 0, c: C.teal }, - { l: 'Rejection rate', v: stats?.applicationStats.rejectionRate || 0, c: C.rose }, - ].map((m) => ( -
-
- {m.l} - {m.v.toFixed(1)}% -
-
-
-
+ + {/* Radar Preview */} +
navigate('/readiness')} + className="mb-4 flex items-center justify-center min-h-[150px] cursor-pointer rounded-xl p-2 transition-transform hover:scale-[1.02]" + style={{ background: C.bg1, border: `1px solid ${C.hair}` }} + > + {skillAxes.length >= 3 ? ( + + ) : ( +
+ Add skills across ≥3 categories to generate your radar. +
+ )}
- ))} -
- {/* JD match gauge */} -
-
JD match avg.
-
- - - - -
-
-
- + {/* Due For Review Quick Action List */} +
+
Immediate Review Queue
+ {dueForReview.slice(0, 3).map((s) => ( +
navigate('/readiness')} + className="flex items-center justify-between rounded-lg px-2.5 py-2 font-mono text-[11px] cursor-pointer transition-colors hover:border-white/20" + style={{ background: C.bg1, color: C.inkDim, border: `1px solid ${C.hair}` }} + > + + + {s.title} + + Drill
-
percent
-
+ ))} + {dueForReview.length === 0 && ( +
+ ✓ All banked stories are solid! +
+ )}
-
- {/* Story confidence */} -
-
Story readiness
-
- / reviewed -
-
- due for drill -
-
- {stats?.storyStats.confidenceBreakdown && Object.keys(stats.storyStats.confidenceBreakdown).length > 0 ? ( - - ({ name: k, count: v }))} margin={{ top: 6, right: 4, left: -22, bottom: 0 }}> - - - - - {Object.entries(stats.storyStats.confidenceBreakdown).map((entry, i) => ( - - ))} - - - - ) : ( -
No confidence data yet
- )} -
+
+
-
+
+
); } + diff --git a/Precept.Web/src/pages/JDMatcher.tsx b/Precept.Web/src/pages/JDMatcher.tsx index 299c18d..0cd7d14 100644 --- a/Precept.Web/src/pages/JDMatcher.tsx +++ b/Precept.Web/src/pages/JDMatcher.tsx @@ -3,7 +3,7 @@ import { api } from '../api'; import { useToast } from '../components/ui/Toast'; import { getSkillIcon } from '../lib/utils'; import { AnimatedSection } from '../components/animation/AnimatedSection'; -import { Zap, FileText, Link2, AlertTriangle, ChartPie, CheckCircle2, XCircle, Plus, Loader2 } from 'lucide-react'; +import { Zap, FileText, Link2, AlertTriangle, ChartPie, CheckCircle2, XCircle, Plus, Loader2, Terminal } from 'lucide-react'; const C = { bg0: '#02050A', bg1: '#06090F', bg2: '#0B0F17', bg3: '#11161F', @@ -135,7 +135,36 @@ export default function JDMatcher() {

- +
+ {/* Window Chrome Header */} +
+
+ + + +
+
+ precept · ~/career/jd-matcher +
+
+ + AI Engine Ready +
+
+ +
+ {/* LEFT — input */}
@@ -315,7 +344,9 @@ export default function JDMatcher() { )}
- + +
+
); } diff --git a/Precept.Web/src/pages/Landing.tsx b/Precept.Web/src/pages/Landing.tsx index aa9f1ed..f0b8443 100644 --- a/Precept.Web/src/pages/Landing.tsx +++ b/Precept.Web/src/pages/Landing.tsx @@ -106,7 +106,15 @@ function Navbar() { Precept - + ─ Career OS @@ -128,7 +136,7 @@ function Navbar() {
{/* Right CTA */} -
+ @@ -311,7 +319,7 @@ function Hero() {