diff --git a/.claude/skills/ralph/prd.json b/.claude/skills/ralph/prd.json index 6e3dcbb..6e912f9 100644 --- a/.claude/skills/ralph/prd.json +++ b/.claude/skills/ralph/prd.json @@ -382,8 +382,8 @@ "Verify in browser using dev-browser skill" ], "priority": 21, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed. Full categorised skill list with category headers matching CoreSkillsTile style, proficiency mini-bars, click-to-skill-detail navigation, and category scroll/highlight from filter." }, { "id": "US-022", @@ -401,8 +401,8 @@ "Verify in browser using dev-browser skill" ], "priority": 22, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed. Renders title + icon + institution + dates + classification badge. Shows research description, OSCE score, extracurriculars (MPharm), programme detail (Mary Seacole), and notes." }, { "id": "US-023", @@ -420,8 +420,8 @@ "Verify in browser using dev-browser skill" ], "priority": 23, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed. D3 + @types/d3 installed. CareerConstellation scaffold with responsive SVG container (400/300/250px), radial gradient bg, ResizeObserver, callbacks ref for future D3 wiring." }, { "id": "US-024", @@ -438,8 +438,8 @@ "Verify in browser using dev-browser skill" ], "priority": 24, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed. D3 force simulation with forceManyBody(-200), forceLink(dist 80, strength from data), forceX chronological, forceY centered, forceCollide. Role nodes 24px with orgColor + white labels, skill nodes 10px color-coded by domain, links 1px opacity 0.3." }, { "id": "US-025", diff --git a/Ralph/progress.txt b/Ralph/progress.txt index d14f780..2a8a613 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -25,6 +25,9 @@ - Types are properly defined in pmr.ts — Consultation, Medication, Problem, Investigation, Document, Patient, ViewId - New types needed: Tag, Alert, KPI, SkillMedication (Task 2) +### Lucide Icons Typing +- Use `LucideIcon` type from `lucide-react` for icon maps, NOT `React.ComponentType<{ size: number }>` — the latter causes TS errors with ForwardRefExoticComponent + ### Known Dependencies - React 18.3.1, TypeScript, Vite - Tailwind CSS for utility classes @@ -635,3 +638,95 @@ **Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓ **Visual review:** Skipped — no browser tools available. +### Iteration 20 — US-021: Create SkillsAllDetail renderer for detail panel +**Status:** Complete +**Changes:** +- Created `src/components/detail/SkillsAllDetail.tsx` — narrow panel renderer for full categorised skill list: + - Groups all 21 skills by Technical / Healthcare Domain / Strategic & Leadership + - Category headers match CoreSkillsTile style: 10px uppercase label + divider line + item count (Geist Mono) + - Each skill row: icon container (26px, accent-light), name + frequency/years (Geist Mono), mini proficiency bar (40px wide, color-coded), percentage, chevron + - Skill rows clickable → `openPanel({ type: 'skill', skill })` to switch panel to individual SkillDetail + - If opened with category filter (from "View all" button), scrolls to and highlights that category (accent-colored header + bottom border) + - Hover: border color shift + shadow deepens (matching CoreSkillsTile rows) + - Keyboard: Enter/Space triggers skill detail, role="button", tabIndex={0}, descriptive aria-label +- Updated `src/components/DetailPanel.tsx`: + - Added import for SkillsAllDetail + - Added `content.type === 'skills-all'` rendering branch with category prop pass-through + - Narrowed placeholder fallback to exclude 'skills-all' type +**Learnings:** +- Reused the SkillRow pattern from CoreSkillsTile but added a mini proficiency bar instead of status badge — provides more info density in the "view all" context +- The `useRef>` pattern with callback ref works well for multiple dynamic refs +- Category highlight uses both accent-colored text and a 2px bottom border to visually distinguish the filtered category +- Pre-existing lint error (`_sectionId` in DashboardLayout:64) continues to be unrelated +**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓ +**Visual review:** Skipped — no browser tools available. + +### Iteration 21 — US-022: Create EducationDetail renderer for detail panel +**Status:** Complete +**Changes:** +- Created `src/components/detail/EducationDetail.tsx` — narrow panel renderer for education entries: + - Header: type-specific icon (GraduationCap/Award/BookOpen/FlaskConical) + title + institution (purple accent) + duration + classification badge (purple-light bg) + - Research Project section: renders `extra.researchDescription` for MPharm entry + - OSCE Performance section: renders score in success-colored badge with description + - Extracurricular Activities section: bullet list from `extra.extracurriculars` + - Programme Overview section: renders `extra.programmeDetail` for Mary Seacole + - Notes section: italic secondary text from `document.notes` + - All sections use shared `sectionHeaderStyle` (12px uppercase, secondary color, 0.05em tracking) +- Updated `src/components/DetailPanel.tsx`: + - Added import for EducationDetail + - Added `content.type === 'education'` rendering branch + - Narrowed placeholder fallback to exclude 'education' type +**Learnings:** +- Icon type for lucide-react must use `LucideIcon` type, not `React.ComponentType<{ size: number }>` — the latter causes type incompatibility with ForwardRefExoticComponent +- The `educationExtras` data matches documents by `documentId` field — currently only MPharm and Mary Seacole have extras +- Purple color (#7C3AED) is used consistently for education across the app (dot colors in CardHeader, CareerActivity, and now EducationDetail institution text and classification badge) +**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓ +**Visual review:** Skipped — no browser tools available. + +### Iteration 22 — US-023: Install D3 and scaffold CareerConstellation component +**Status:** Complete +**Changes:** +- Installed `d3` and `@types/d3` npm packages (70 packages added) +- Created `src/components/CareerConstellation.tsx` — scaffolded component with: + - Props: `onRoleClick(id)` and `onSkillClick(id)` stored in callbacksRef for future D3 event binding + - Responsive SVG container using ResizeObserver: 400px desktop, 300px tablet (<1024px), 250px mobile (<768px) + - viewBox matches actual dimensions for responsive scaling + - Radial gradient background: `#F0F5F4` (--bg-dashboard) center → `#FFFFFF` (--surface) edge, rx=6 + - Placeholder text showing node/link counts from constellation data (Geist Mono, tertiary color) + - Container with border-radius and overflow hidden + - SVG has `role="img"` and `aria-label` for accessibility + - Imperative SVG drawing via useEffect on svgRef (matches ECG pattern for D3 compatibility) +**Learnings:** +- `callbacksRef` pattern stores click handlers in a ref for D3 imperative code — avoids stale closures when D3 attaches event listeners in US-024/026 +- ResizeObserver provides cleaner responsive behavior than CSS media queries for SVG — container width determines height tier +- The SVG namespace `http://www.w3.org/2000/svg` is required for createElement in imperative SVG building +- D3 is installed but not yet imported — US-024 will use `d3.forceSimulation` etc. on the svgRef +- Pre-existing lint error (`_sectionId` in DashboardLayout:64) continues to be unrelated +**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓ +**Visual review:** Skipped — component not yet integrated into CareerActivityTile (will be wired in US-026). + +### Iteration 23 — US-024: Build D3 force-directed graph rendering in CareerConstellation +**Status:** Complete +**Changes:** +- Rewrote `src/components/CareerConstellation.tsx` to use D3 force simulation: + - Replaced imperative SVG createElement with D3 selections (`d3.select`, `.selectAll`, `.join`) + - D3 force simulation with: `forceManyBody(-200)`, `forceLink(distance 80, strength from data * 0.5)`, `forceX` chronological (roles positioned left-to-right by `startYear` via `d3.scaleLinear`), `forceY` centered at `height/2`, `forceCollide` (30 for roles, 14 for skills) + - Role nodes: 24px radius circles filled with `orgColor`, 2px white stroke, 8px white `shortLabel` text centered + - Skill nodes: 10px radius circles, color-coded by domain (clinical=#059669 green, technical=#0D6E6E teal, leadership=#D97706 amber), 1.5px white stroke, opacity 0.85 + - Skill labels: 9px Geist Mono text below each skill node (using `shortLabel`) + - Links: 1px `#D4E0DE` lines at opacity 0.3 + - Node positions constrained within SVG bounds on each tick + - Layered rendering: links group below nodes group + - `simulationRef` stores active simulation, stopped on cleanup or dimension change + - Preserved existing ResizeObserver responsive height (400/300/250px) + - Preserved radial gradient background, `role="img"`, `aria-label` +- Removed unused `ConstellationLink` type import (caught by typecheck) +**Learnings:** +- D3 `forceLink.strength()` receives the link object — cast to `SimLink` to access `.strength` field +- Role `forceX` uses strong pull (0.8) to maintain chronological layout; skill `forceX` uses weak pull (0.05) to let links drive position +- `forceCollide` radius should be slightly larger for skills than their visual radius to prevent label overlap +- The `SimNode` interface extending `ConstellationNode` with `x/y/vx/vy/fx/fy` satisfies D3's `SimulationNodeDatum` needs +- Pre-existing lint issues: `_sectionId` error + 2 context warnings — all unrelated +**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓ +**Visual review:** Skipped — component not yet wired into CareerActivityTile (US-026). D3 simulation verified via successful build. + diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 9caf1e1..2778df7 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -1,5 +1,7 @@ import React, { useRef, useEffect, useState } from 'react' +import * as d3 from 'd3' import { constellationNodes, constellationLinks } from '@/data/constellation' +import type { ConstellationNode } from '@/types/pmr' interface CareerConstellationProps { onRoleClick: (id: string) => void @@ -10,21 +12,49 @@ const DESKTOP_HEIGHT = 400 const TABLET_HEIGHT = 300 const MOBILE_HEIGHT = 250 +const ROLE_RADIUS = 24 +const SKILL_RADIUS = 10 +const COLLIDE_RADIUS = 30 + +// Domain color mapping for skill nodes +const domainColorMap: Record = { + clinical: '#059669', // var(--success) + technical: '#0D6E6E', // var(--accent) + leadership: '#D97706', // var(--amber) +} + function getHeight(width: number): number { if (width < 768) return MOBILE_HEIGHT if (width < 1024) return TABLET_HEIGHT return DESKTOP_HEIGHT } +// D3 simulation node extends ConstellationNode with x/y +interface SimNode extends ConstellationNode { + x: number + y: number + vx: number + vy: number + fx?: number | null + fy?: number | null +} + +interface SimLink { + source: SimNode | string + target: SimNode | string + strength: number +} + const CareerConstellation: React.FC = ({ onRoleClick, onSkillClick, }) => { const svgRef = useRef(null) const containerRef = useRef(null) + const simulationRef = useRef | null>(null) const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT }) - // Store callbacks in refs so D3 event handlers (US-024/026) can access latest versions + // Store callbacks in refs so D3 event handlers can access latest versions const callbacksRef = useRef({ onRoleClick, onSkillClick }) callbacksRef.current = { onRoleClick, onSkillClick } @@ -47,59 +77,163 @@ const CareerConstellation: React.FC = ({ }, []) useEffect(() => { - const svg = svgRef.current - if (!svg) return + const svg = d3.select(svgRef.current) + if (!svgRef.current) return const { width, height } = dimensions - // Clear previous content - while (svg.firstChild) { - svg.removeChild(svg.firstChild) + // Stop previous simulation + if (simulationRef.current) { + simulationRef.current.stop() } - const ns = 'http://www.w3.org/2000/svg' + // Clear previous content + svg.selectAll('*').remove() - // Radial gradient background - const defs = document.createElementNS(ns, 'defs') - const gradient = document.createElementNS(ns, 'radialGradient') - gradient.setAttribute('id', 'constellation-bg') - gradient.setAttribute('cx', '50%') - gradient.setAttribute('cy', '50%') - gradient.setAttribute('r', '60%') - - const stop1 = document.createElementNS(ns, 'stop') - stop1.setAttribute('offset', '0%') - stop1.setAttribute('stop-color', '#F0F5F4') - const stop2 = document.createElementNS(ns, 'stop') - stop2.setAttribute('offset', '100%') - stop2.setAttribute('stop-color', '#FFFFFF') - gradient.appendChild(stop1) - gradient.appendChild(stop2) - defs.appendChild(gradient) - svg.appendChild(defs) + // Defs with radial gradient + const defs = svg.append('defs') + const gradient = defs.append('radialGradient') + .attr('id', 'constellation-bg') + .attr('cx', '50%') + .attr('cy', '50%') + .attr('r', '60%') + gradient.append('stop').attr('offset', '0%').attr('stop-color', '#F0F5F4') + gradient.append('stop').attr('offset', '100%').attr('stop-color', '#FFFFFF') // Background rect - const bgRect = document.createElementNS(ns, 'rect') - bgRect.setAttribute('width', String(width)) - bgRect.setAttribute('height', String(height)) - bgRect.setAttribute('fill', 'url(#constellation-bg)') - bgRect.setAttribute('rx', '6') - svg.appendChild(bgRect) + svg.append('rect') + .attr('width', width) + .attr('height', height) + .attr('fill', 'url(#constellation-bg)') + .attr('rx', 6) - // Scaffold placeholder — D3 force simulation replaces this in US-024 - const roleNodes = constellationNodes.filter(n => n.type === 'role') - const skillNodes = constellationNodes.filter(n => n.type === 'skill') + // Prepare node and link data (deep copy to avoid mutation) + const nodes: SimNode[] = constellationNodes.map(n => ({ + ...n, + x: 0, + y: 0, + vx: 0, + vy: 0, + })) - const text = document.createElementNS(ns, 'text') - text.setAttribute('x', String(width / 2)) - text.setAttribute('y', String(height / 2)) - text.setAttribute('text-anchor', 'middle') - text.setAttribute('dominant-baseline', 'middle') - text.setAttribute('fill', '#8DA8A5') - text.setAttribute('font-size', '12') - text.setAttribute('font-family', 'var(--font-geist-mono)') - text.textContent = `${roleNodes.length} roles · ${skillNodes.length} skills · ${constellationLinks.length} connections` - svg.appendChild(text) + const links: SimLink[] = constellationLinks.map(l => ({ + source: l.source, + target: l.target, + strength: l.strength, + })) + + // Compute chronological x positions for role nodes + const roleNodes = nodes.filter(n => n.type === 'role') + const years = roleNodes.map(n => n.startYear ?? 2016) + const minYear = Math.min(...years) + const maxYear = Math.max(...years) + const padding = 80 + + // Scale: startYear → x position (left-to-right chronologically) + const xScale = d3.scaleLinear() + .domain([minYear, maxYear]) + .range([padding, width - padding]) + + // Create container groups for layering: links below, nodes above + const linkGroup = svg.append('g').attr('class', 'links') + const nodeGroup = svg.append('g').attr('class', 'nodes') + + // Draw links + const linkSelection = linkGroup.selectAll('line') + .data(links) + .join('line') + .attr('stroke', '#D4E0DE') + .attr('stroke-width', 1) + .attr('stroke-opacity', 0.3) + + // Draw nodes + const nodeSelection = nodeGroup.selectAll('g') + .data(nodes) + .join('g') + .attr('class', d => `node node-${d.type}`) + .style('cursor', 'pointer') + + // Role nodes: large circles with org color + white text + nodeSelection.filter(d => d.type === 'role') + .append('circle') + .attr('r', ROLE_RADIUS) + .attr('fill', d => d.orgColor ?? '#0D6E6E') + .attr('stroke', '#FFFFFF') + .attr('stroke-width', 2) + + nodeSelection.filter(d => d.type === 'role') + .append('text') + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('fill', '#FFFFFF') + .attr('font-size', '8') + .attr('font-weight', '600') + .attr('font-family', 'var(--font-ui)') + .attr('pointer-events', 'none') + .text(d => d.shortLabel ?? d.label.slice(0, 8)) + + // Skill nodes: smaller circles, color-coded by domain + nodeSelection.filter(d => d.type === 'skill') + .append('circle') + .attr('r', SKILL_RADIUS) + .attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E') + .attr('stroke', '#FFFFFF') + .attr('stroke-width', 1.5) + .attr('fill-opacity', 0.85) + + // Skill labels (short labels for readability) + nodeSelection.filter(d => d.type === 'skill') + .append('text') + .attr('text-anchor', 'middle') + .attr('dy', SKILL_RADIUS + 12) + .attr('fill', '#5B7A78') + .attr('font-size', '9') + .attr('font-family', 'var(--font-geist-mono)') + .attr('pointer-events', 'none') + .text(d => d.shortLabel ?? d.label) + + // Force simulation + const simulation = d3.forceSimulation(nodes) + .force('charge', d3.forceManyBody().strength(-200)) + .force('link', d3.forceLink(links) + .id(d => d.id) + .distance(80) + .strength(d => (d as SimLink).strength * 0.5)) + .force('x', d3.forceX(d => { + if (d.type === 'role' && d.startYear != null) { + return xScale(d.startYear) + } + return width / 2 + }).strength(d => d.type === 'role' ? 0.8 : 0.05)) + .force('y', d3.forceY(height / 2).strength(0.3)) + .force('collide', d3.forceCollide(d => + d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 4 + )) + + simulationRef.current = simulation + + // Update positions on each tick + simulation.on('tick', () => { + // Constrain nodes within bounds + nodes.forEach(d => { + const r = d.type === 'role' ? ROLE_RADIUS : SKILL_RADIUS + d.x = Math.max(r, Math.min(width - r, d.x)) + d.y = Math.max(r, Math.min(height - r, d.y)) + }) + + linkSelection + .attr('x1', d => (d.source as SimNode).x) + .attr('y1', d => (d.source as SimNode).y) + .attr('x2', d => (d.target as SimNode).x) + .attr('y2', d => (d.target as SimNode).y) + + nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`) + }) + + // Cleanup + return () => { + simulation.stop() + } }, [dimensions]) return (