Compare commits

..

10 Commits

Author SHA1 Message Date
admin 9d61d2c8ca powershell woes 2026-02-14 02:21:20 +00:00
admin fbfd25ffff US-018: Create ConsultationDetail renderer for detail panel
- Created ConsultationDetail.tsx component to render full role details
- Displays role title, organization, dates with current badge
- Renders history paragraph (consultation.history)
- Shows achievement bullets (consultation.examination)
- Displays outcomes/impact (consultation.plan)
- Renders coded entries as badges with code + description
- Wired into DetailPanel for both 'consultation' and 'career-role' types
- Styled consistently with dashboard design system
- Typecheck and build pass successfully

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 02:14:54 +00:00
admin f38e67252b US-017: Create KPIDetail renderer for detail panel
Created src/components/detail/KPIDetail.tsx that renders rich KPI story
content inside the detail panel. Wired into DetailPanel so content.type
=== 'kpi' renders this component.

Component displays:
- Large headline number (48px, colored by kpi.colorVariant)
- KPI label and subtitle
- Period badge (if story.period exists)
- Context paragraph (story.context)
- Your role paragraph (story.role)
- Key outcomes as bullet list (story.outcomes)

Graceful fallback implemented: if story is undefined, shows kpi.value
and kpi.explanation instead.

Styling matches dashboard design system with fonts (Elvaro Grotesque,
Geist Mono), colors (CSS custom properties), and spacing conventions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:40:58 +00:00
admin 0c87d9f5a4 US-016: Modify PatientSummaryTile: structured presentation with highlight strip
- Added visual hierarchy to profile text using bold key phrases
- Key terms highlighted: Healthcare leader, Python/SQL/data analytics, leading population health analytics, financial scenario modelling, pharmaceutical rebate negotiation, algorithm design, population-level pathway development, £14.6M+, executive stakeholders
- Profile text no longer a wall of text - strategic bolding creates visual structure
- Removed unused personalStatement import
- Highlight strip with key stats already implemented (9+ Years, 1.2M, £220M, £14.6M+)
- Profile text sourced from CV_v4.md Profile section

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:32:54 +00:00
admin 8830c223aa US-016: Modify PatientSummaryTile: structured presentation with highlight strip
Add visual highlight strip showing key stats (9+ Years, 1.2M Population, £220M Budget, £14.6M+ Savings) above the profile text. Stats displayed as teal-colored badges with labels for improved visual hierarchy and scanability.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:27:10 +00:00
admin 52ee98d8aa US-015: Modify EducationTile: richer content, panel trigger
- Add OSCE score (80%) to MPharm inline details via educationExtras data
- Show research project with full description (Drug delivery & cocrystals, 75.1%)
- Display A-level grades as Mathematics (A*) · Chemistry (B) · Politics (C)
- Include Mary Seacole programme detail from educationExtras
- Import and use educationExtras data for dynamic inline content
- Add osceScore field to EducationExtra type
- Each entry clickable to open detail panel, hover border shift intact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 00:33:44 +00:00
admin 03b4c6cafb US-015: Modify EducationTile: richer content, panel trigger
- Show richer inline content: MPharm research score (75.1%), Mary Seacole score (78%), A-level grades
- Each education entry is now clickable -> opens detail panel
- Hover state: border color shift to teal with shadow deepening
- Use documents data from documents.ts for accurate content
- Maintains existing visual hierarchy and spacing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:59:54 +00:00
admin 9ed77f99a8 US-014: Modify CareerActivityTile: panel triggers and hover preview
- Replace in-place accordion expansion with detail panel triggers for role items
- Add hover preview showing lift effect, shadow deepens, and 1-2 lines preview text
- Integrate with DetailPanelContext to open career-role panels on click
- Keep color-coded dots and entry type styling (teal, amber, green, purple)
- Add placeholder container for CareerConstellation component (to be implemented later)
- Remove unused AnimatePresence, motion imports and accordion-related code
- Remove prefersReducedMotion and borderColorMap (no longer needed)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:57:55 +00:00
admin afc3876210 US-013: Add detail panel trigger to LastConsultationTile
- Import useDetailPanel hook and ChevronRight icon
- Make header info row clickable to open consultation detail panel
- Add "View full record" button at bottom of tile
- Both triggers call openPanel({ type: 'consultation', consultation })
- Add hover states to clickable areas
- Include keyboard navigation support (Enter/Space)
- Add aria-labels for accessibility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:54:59 +00:00
admin c37fdab8fa US-012: Modify ProjectsTile: half width, compact card grid, panel trigger
- Remove full prop from Card (now half-width, single grid column)
- Replace accordion expansion with detail panel trigger
- Compact project cards with status dot + name + year (right-aligned)
- Tech stack shown as small inline tags (9px, monospace)
- Each project card clickable → openPanel({ type: 'project', investigation })
- Hover effects: border color shift to accent + shadow deepens
- Remove AnimatePresence and expansion state management
- Simplified component with focus on panel delegation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:52:36 +00:00
14 changed files with 2594 additions and 978 deletions
+85 -85
View File
@@ -1,5 +1,5 @@
{
"project": "GP Clinical Record Depth Enhancement",
{
"project": "GP Clinical Record — Depth Enhancement",
"branchName": "ralph/depth-enhancement",
"description": "Add depth, interactivity, and rich content to the GP clinical record dashboard: slide-in detail panels, sub-navigation, expanded skills/KPI data, career constellation D3 visualization, and login refresh. Full spec in Ralph/depth-design.md, requirements in Ralph/depth-requirements.md, workflow in Ralph/workflow_depth.md.",
"userStories": [
@@ -23,18 +23,18 @@
"Typecheck passes"
],
"priority": 1,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 1 at 2026-02-13 22:57. Model: opus."
},
{
"id": "US-002",
"title": "Add new TypeScript types and CSS custom properties for depth features",
"description": "As a developer, I need new types and CSS foundations that subsequent stories will use. Add types to src/types/pmr.ts and CSS variables + keyframes to src/index.css. See Ralph/depth-design.md Section 4 for type definitions and Section 9 for CSS.",
"acceptanceCriteria": [
"Add SkillCategory type: 'Technical' | 'Domain' | 'Leadership' to src/types/pmr.ts",
"Add SkillCategory type: \u0027Technical\u0027 | \u0027Domain\u0027 | \u0027Leadership\u0027 to src/types/pmr.ts",
"Add KPIStory interface with fields: context (string), role (string), outcomes (string[]), period (string optional) to src/types/pmr.ts",
"Add optional story?: KPIStory field to existing KPI interface in src/types/pmr.ts",
"Add ConstellationNode interface (id, type: 'role'|'skill', label, shortLabel?, organization?, startYear?, endYear?, orgColor?, domain?) to src/types/pmr.ts",
"Add ConstellationNode interface (id, type: \u0027role\u0027|\u0027skill\u0027, label, shortLabel?, organization?, startYear?, endYear?, orgColor?, domain?) to src/types/pmr.ts",
"Add ConstellationLink interface (source, target, strength) to src/types/pmr.ts",
"Add DetailPanelContent discriminated union type (kpi | skill | skills-all | consultation | project | education | career-role) to src/types/pmr.ts",
"Add EducationExtra interface (documentId, extracurriculars?, researchDescription?, programmeDetail?) to src/types/pmr.ts",
@@ -44,8 +44,8 @@
"Typecheck passes"
],
"priority": 2,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 2 at 2026-02-13 22:59. Model: sonnet."
},
{
"id": "US-003",
@@ -53,14 +53,14 @@
"description": "As a developer, I need the core detail panel infrastructure: a context for managing panel state, the slide-in panel component, and a focus trap hook. Create 3 new files. The panel renders placeholder content for now (real renderers come later). See Ralph/depth-design.md Sections 2.1, 2.2 for full interface specs.",
"acceptanceCriteria": [
"Create src/contexts/DetailPanelContext.tsx with DetailPanelProvider that manages: content (DetailPanelContent | null), openPanel, closePanel, isOpen",
"Width mapping is deterministic from content.type: kpi/skill/skills-all/education → 'narrow' (var(--panel-narrow)), consultation/project/career-role → 'wide' (var(--panel-wide))",
"Title mapping derives from content data (e.g., kpi kpi.label, skill skill.name, consultation consultation.role)",
"Width mapping is deterministic from content.type: kpi/skill/skills-all/education → \u0027narrow\u0027 (var(--panel-narrow)), consultation/project/career-role → \u0027wide\u0027 (var(--panel-wide))",
"Title mapping derives from content data (e.g., kpi → kpi.label, skill → skill.name, consultation → consultation.role)",
"Create src/components/DetailPanel.tsx: full-screen backdrop (var(--backdrop-bg) + backdrop-filter: blur(var(--backdrop-blur))) with panel sliding from right",
"Panel has header with X close button (lucide X icon), colored dot matching tile, and title text",
"Panel body is scrollable and renders placeholder text showing content type",
"Close triggers: backdrop click, Escape key, X button",
"ARIA: aria-modal=true, role=dialog, aria-labelledby pointing to title",
"Mobile (<768px): both narrow and wide become 100vw",
"Mobile (\u003c768px): both narrow and wide become 100vw",
"prefers-reduced-motion: instant appear, no slide animation",
"Create src/hooks/useFocusTrap.ts: useFocusTrap(containerRef, isActive) traps Tab/Shift+Tab within container when active, returns focus to previous element when deactivated",
"DetailPanel uses useFocusTrap when open",
@@ -68,8 +68,8 @@
"Verify in browser using dev-browser skill"
],
"priority": 3,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 3 at 2026-02-13 23:03. Model: sonnet."
},
{
"id": "US-004",
@@ -84,14 +84,14 @@
"Inactive tabs: var(--text-secondary)",
"Click scrolls smoothly to [data-tile-id=tileId] element",
"Create src/hooks/useActiveSection.ts using IntersectionObserver to track visible tile by data-tile-id attribute",
"Maps tile IDs to section IDs: patient-summaryoverview, core-skillsskills, career-activityexperience, projectsprojects, educationeducation",
"Maps tile IDs to section IDs: patient-summary→overview, core-skills→skills, career-activity→experience, projects→projects, education→education",
"SubNav accepts activeSection and onSectionClick props",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 4,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 4 at 2026-02-13 23:06. Model: sonnet."
},
{
"id": "US-005",
@@ -101,32 +101,32 @@
"src/data/skills.ts has ~21 SkillMedication entries",
"Technical category (8): Data Analysis, Python, SQL, Power BI, JavaScript/TypeScript, Excel, Algorithm Design, Data Pipelines",
"Healthcare Domain category (6): Medicines Optimisation, Population Health, NICE TA Implementation, Health Economics, Clinical Pathways, Controlled Drugs",
"Strategic & Leadership category (7): Budget Management, Stakeholder Engagement, Pharmaceutical Negotiation, Team Development, Change Management, Financial Modelling, Executive Communication",
"Strategic \u0026 Leadership category (7): Budget Management, Stakeholder Engagement, Pharmaceutical Negotiation, Team Development, Change Management, Financial Modelling, Executive Communication",
"Each skill has: id (kebab-case), name, frequency (medication-style: Daily, Twice daily, Once weekly, When required, etc.), startYear, yearsOfExperience, proficiency (0-100), category, status (Active/Historical), icon (lucide icon name)",
"Frequency and proficiency values are realistic based on CV_v4.md role descriptions",
"Typecheck passes"
],
"priority": 5,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 5 at 2026-02-13 23:08. Model: sonnet."
},
{
"id": "US-006",
"title": "Add KPI story data and update 4th KPI",
"description": "As a developer, I need to add rich story content to each KPI in src/data/kpis.ts for the detail panel, and change the 4th KPI from '12 Team Size Led' to '1.2M Population served'. Source from References/CV_v4.md. See Ralph/depth-design.md Section 5.2.",
"description": "As a developer, I need to add rich story content to each KPI in src/data/kpis.ts for the detail panel, and change the 4th KPI from \u002712 Team Size Led\u0027 to \u00271.2M Population served\u0027. Source from References/CV_v4.md. See Ralph/depth-design.md Section 5.2.",
"acceptanceCriteria": [
"Change 4th KPI from {id:'team', value:'12', label:'Team Size Led'} to {id:'population', value:'1.2M', label:'Population Served', sub:'Norfolk & Waveney ICS', colorVariant:'teal'}",
"Change 4th KPI from {id:\u0027team\u0027, value:\u002712\u0027, label:\u0027Team Size Led\u0027} to {id:\u0027population\u0027, value:\u00271.2M\u0027, label:\u0027Population Served\u0027, sub:\u0027Norfolk \u0026 Waveney ICS\u0027, colorVariant:\u0027teal\u0027}",
"Add story field (KPIStory) to all 4 KPIs with: context, role, outcomes[], period",
"£220M story: context about ICB prescribing budget for 1.2M population, role about forecasting models and ICB board accountability, outcomes about proactive financial planning",
"£14.6M story: context about efficiency programme, role about data analysis identification, outcomes about over-target performance",
"£220M story: context about ICB prescribing budget for 1.2M population, role about forecasting models and ICB board accountability, outcomes about proactive financial planning",
"£14.6M story: context about efficiency programme, role about data analysis identification, outcomes about over-target performance",
"9+ Years story: context about career span Aug 2016-present, role about progression from community pharmacy to system-level leadership",
"1.2M story: context about Norfolk & Waveney ICS population, role about population health analytics and data-driven decision making",
"1.2M story: context about Norfolk \u0026 Waveney ICS population, role about population health analytics and data-driven decision making",
"Add explanation field to 4th KPI matching the story context",
"Typecheck passes"
],
"priority": 6,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 6 at 2026-02-13 23:10. Model: sonnet."
},
{
"id": "US-007",
@@ -134,14 +134,14 @@
"description": "As a developer, I need src/data/educationExtras.ts with expanded detail for the education detail panel. Source from References/CV_v4.md Education section. See Ralph/depth-design.md Section 5.4.",
"acceptanceCriteria": [
"Create src/data/educationExtras.ts exporting educationExtras array of EducationExtra objects",
"MPharm entry (documentId matching doc-mpharm or equivalent from documents.ts): extracurriculars ['President of UEA Pharmacy Society', 'Secretary & Vice-President of UEA Ultimate Frisbee', 'Publicity Officer for UEA Alzheimer\\'s Society'], researchDescription about cocrystal formation for drug delivery",
"MPharm entry (documentId matching doc-mpharm or equivalent from documents.ts): extracurriculars [\u0027President of UEA Pharmacy Society\u0027, \u0027Secretary \u0026 Vice-President of UEA Ultimate Frisbee\u0027, \u0027Publicity Officer for UEA Alzheimer\\\u0027s Society\u0027], researchDescription about cocrystal formation for drug delivery",
"Mary Seacole entry: programmeDetail about NHS leadership qualification, change management, healthcare leadership, system-level thinking",
"Document IDs match those used in src/data/documents.ts",
"Typecheck passes"
],
"priority": 7,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 7 at 2026-02-13 23:11. Model: sonnet."
},
{
"id": "US-008",
@@ -159,8 +159,8 @@
"Verify in browser using dev-browser skill"
],
"priority": 8,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 8 at 2026-02-13 23:15. Model: sonnet."
},
{
"id": "US-009",
@@ -176,8 +176,8 @@
"Typecheck passes"
],
"priority": 9,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 9 at 2026-02-13 23:17. Model: sonnet."
},
{
"id": "US-010",
@@ -188,7 +188,7 @@
"Each KPI renders as a clickable button/card with: value at 28-32px font-size, weight 700, colored by kpi.colorVariant",
"Label at 12px, weight 500, color var(--text-primary), marginTop 4px",
"Sub-text at 10px, font-family var(--font-geist-mono), color var(--text-tertiary), marginTop 2px",
"Click calls openPanel({ type: 'kpi', kpi }) from DetailPanelContext",
"Click calls openPanel({ type: \u0027kpi\u0027, kpi }) from DetailPanelContext",
"Hover: border color shift + shadow deepens (transition 150ms)",
"Keyboard: Enter/Space triggers panel open",
"Card styling: padding 16px, background var(--surface), border 1px solid var(--border-light), border-radius var(--radius-sm)",
@@ -196,28 +196,28 @@
"Verify in browser using dev-browser skill"
],
"priority": 10,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 10 at 2026-02-13. Model: opus. Manually marked passed (script hung after story-complete signal)."
},
{
"id": "US-011",
"title": "Modify CoreSkillsTile: full width, categorised groups, panel triggers",
"description": "As a developer, I need to redesign CoreSkillsTile.tsx as full-width with skills grouped by 3 categories, showing top 3-4 per category with 'view all' buttons. Individual skills and 'view all' trigger the detail panel. See Ralph/depth-design.md Section 3.4.",
"description": "As a developer, I need to redesign CoreSkillsTile.tsx as full-width with skills grouped by 3 categories, showing top 3-4 per category with \u0027view all\u0027 buttons. Individual skills and \u0027view all\u0027 trigger the detail panel. See Ralph/depth-design.md Section 3.4.",
"acceptanceCriteria": [
"Card uses full prop (spans both grid columns)",
"Skills grouped by category: Technical, Healthcare Domain (Domain), Strategic & Leadership (Leadership)",
"Skills grouped by category: Technical, Healthcare Domain (Domain), Strategic \u0026 Leadership (Leadership)",
"Each category has a header: thin divider line with category label (styled like sidebar section dividers: 10px, uppercase, var(--text-tertiary))",
"Show top 3-4 skills per category on the dashboard tile (sorted by proficiency or relevance)",
"Each skill row is clickable openPanel({ type: 'skill', skill }) from DetailPanelContext",
"Each category with >4 skills shows a 'View all (N)' button openPanel({ type: 'skills-all', category })",
"Each skill row is clickable → openPanel({ type: \u0027skill\u0027, skill }) from DetailPanelContext",
"Each category with \u003e4 skills shows a \u0027View all (N)\u0027 button → openPanel({ type: \u0027skills-all\u0027, category })",
"Retain medication metaphor display (frequency, status badge)",
"Remove old single-expand accordion for skills (replaced by panel)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 11,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 1 at 2026-02-13 23:50. Model: opus."
},
{
"id": "US-012",
@@ -227,23 +227,23 @@
"Remove full prop from Card (half-width, single grid column)",
"Compact project cards: status dot + name + year (right-aligned) per row",
"Tech stack shown as small inline tags",
"Each project card clickable openPanel({ type: 'project', investigation }) from DetailPanelContext",
"Each project card clickable → openPanel({ type: \u0027project\u0027, investigation }) from DetailPanelContext",
"Remove old in-place expansion (replaced by panel)",
"Hover: border color shift, shadow deepens",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 12,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 2 at 2026-02-13 23:52. Model: sonnet."
},
{
"id": "US-013",
"title": "Modify LastConsultationTile: add panel trigger",
"description": "As a developer, I need to add a 'View full record' button to LastConsultationTile.tsx that opens the detail panel with full role details. See Ralph/depth-design.md Section 3.9.",
"description": "As a developer, I need to add a \u0027View full record\u0027 button to LastConsultationTile.tsx that opens the detail panel with full role details. See Ralph/depth-design.md Section 3.9.",
"acceptanceCriteria": [
"Add 'View full record' link/button at the bottom of the tile",
"Click openPanel({ type: 'consultation', consultation }) from DetailPanelContext, passing the first consultation entry",
"Add \u0027View full record\u0027 link/button at the bottom of the tile",
"Click → openPanel({ type: \u0027consultation\u0027, consultation }) from DetailPanelContext, passing the first consultation entry",
"Make the tile header area also clickable (opens same panel)",
"Keep existing inline content (header info row, achievement bullets)",
"Hover state on clickable areas",
@@ -251,15 +251,15 @@
"Verify in browser using dev-browser skill"
],
"priority": 13,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 3 at 2026-02-13 23:55. Model: sonnet."
},
{
"id": "US-014",
"title": "Modify CareerActivityTile: panel triggers and hover preview",
"description": "As a developer, I need to change CareerActivityTile.tsx so timeline items click to open the detail panel instead of expanding in-place, and add hover previews. See Ralph/depth-design.md Section 3.7.",
"acceptanceCriteria": [
"Role timeline items click openPanel({ type: 'career-role', consultation }) from DetailPanelContext",
"Role timeline items click → openPanel({ type: \u0027career-role\u0027, consultation }) from DetailPanelContext",
"Remove in-place accordion expansion for career items (replaced by panel)",
"Hover preview: items lift slightly on hover with shadow deepens, show 1-2 lines of preview text",
"Keep color-coded dots and entry type styling (teal roles, amber projects, green certs, purple education)",
@@ -268,8 +268,8 @@
"Verify in browser using dev-browser skill"
],
"priority": 14,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 4 at 2026-02-13 23:58. Model: sonnet."
},
{
"id": "US-015",
@@ -277,15 +277,15 @@
"description": "As a developer, I need to enhance EducationTile.tsx with richer inline content and click-to-panel interaction. See Ralph/depth-design.md Section 3.8.",
"acceptanceCriteria": [
"Show richer inline content: MPharm research project score (75.1%), OSCE score (80%), A-level grades (A* Maths, B Chemistry, C Politics)",
"Each education entry is clickable openPanel({ type: 'education', document }) from DetailPanelContext",
"Each education entry is clickable → openPanel({ type: \u0027education\u0027, document }) from DetailPanelContext",
"Hover: border color shift on clickable entries",
"Use education extras data from src/data/educationExtras.ts for inline detail where appropriate",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 15,
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed iteration 4 at 2026-02-14 00:33. Model: sonnet."
},
{
"id": "US-016",
@@ -293,40 +293,40 @@
"description": "As a developer, I need to improve PatientSummaryTile.tsx with the full CV_v4.md profile text and a visual highlight strip. See Ralph/depth-design.md Section 3.10 and Ralph/depth-requirements.md Section 4.1.",
"acceptanceCriteria": [
"Verify src/data/profile.ts has the complete profile text from References/CV_v4.md (update if needed)",
"Add a visual highlight strip showing key stats: e.g. '9+ Years Experience', '1.2M Population', '£220M Budget' as small styled badges or pills",
"Profile text is not a wall of text use hierarchy: bold key phrases, structured paragraphs if needed",
"Add a visual highlight strip showing key stats: e.g. \u00279+ Years Experience\u0027, \u00271.2M Population\u0027, \u0027£220M Budget\u0027 as small styled badges or pills",
"Profile text is not a wall of text — use hierarchy: bold key phrases, structured paragraphs if needed",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 16,
"passes": false,
"passes": true,
"notes": ""
},
{
"id": "US-017",
"title": "Create KPIDetail renderer for detail panel",
"description": "As a developer, I need src/components/detail/KPIDetail.tsx that renders rich KPI story content inside the detail panel. Wire it into DetailPanel so content.type === 'kpi' renders this component. See Ralph/depth-design.md Section 6.1.",
"description": "As a developer, I need src/components/detail/KPIDetail.tsx that renders rich KPI story content inside the detail panel. Wire it into DetailPanel so content.type === \u0027kpi\u0027 renders this component. See Ralph/depth-design.md Section 6.1.",
"acceptanceCriteria": [
"Create src/components/detail/KPIDetail.tsx accepting a KPI prop",
"Renders: headline number (large, colored by kpi.colorVariant), context paragraph (story.context), 'Your role' paragraph (story.role), outcome bullets (story.outcomes), period badge (story.period)",
"Renders: headline number (large, colored by kpi.colorVariant), context paragraph (story.context), \u0027Your role\u0027 paragraph (story.role), outcome bullets (story.outcomes), period badge (story.period)",
"Graceful fallback if story is undefined (show kpi.explanation instead)",
"Wire into DetailPanel: when content.type === 'kpi', render <KPIDetail kpi={content.kpi} />",
"Wire into DetailPanel: when content.type === \u0027kpi\u0027, render \u003cKPIDetail kpi={content.kpi} /\u003e",
"Styling matches dashboard design system (fonts, colors, spacing)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 17,
"passes": false,
"passes": true,
"notes": ""
},
{
"id": "US-018",
"title": "Create ConsultationDetail renderer for detail panel",
"description": "As a developer, I need src/components/detail/ConsultationDetail.tsx for displaying full role details in the detail panel. Used for both 'consultation' and 'career-role' content types. See Ralph/depth-design.md Section 6.4.",
"description": "As a developer, I need src/components/detail/ConsultationDetail.tsx for displaying full role details in the detail panel. Used for both \u0027consultation\u0027 and \u0027career-role\u0027 content types. See Ralph/depth-design.md Section 6.4.",
"acceptanceCriteria": [
"Create src/components/detail/ConsultationDetail.tsx accepting a Consultation prop",
"Renders: role title + organization + dates, history paragraph (consultation.history), achievement bullets (consultation.examination), plan/outcomes (consultation.plan), coded entries as badges (consultation.codedEntries)",
"Wire into DetailPanel: content.type === 'consultation' or 'career-role' renders this component",
"Wire into DetailPanel: content.type === \u0027consultation\u0027 or \u0027career-role\u0027 renders this component",
"Styled consistently with dashboard design system",
"Typecheck passes",
"Verify in browser using dev-browser skill"
@@ -342,13 +342,13 @@
"acceptanceCriteria": [
"Create src/components/detail/ProjectDetail.tsx accepting an Investigation prop",
"Renders: project name + year + status badge, methodology description, tech stack as tags, results bullets, external link button (if investigation.externalUrl exists, opens in new tab)",
"Wire into DetailPanel: content.type === 'project' renders this component",
"External link uses rel='noopener noreferrer'",
"Wire into DetailPanel: content.type === \u0027project\u0027 renders this component",
"External link uses rel=\u0027noopener noreferrer\u0027",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 19,
"passes": false,
"passes": true,
"notes": ""
},
{
@@ -358,8 +358,8 @@
"acceptanceCriteria": [
"Create src/components/detail/SkillDetail.tsx accepting a SkillMedication prop",
"Renders: skill name + frequency + status badge, visual proficiency bar (0-100%), years of experience, category label",
"If constellation data is available, show 'Used in' section listing roles that used this skill (import from src/data/constellation.ts)",
"Wire into DetailPanel: content.type === 'skill' renders this component",
"If constellation data is available, show \u0027Used in\u0027 section listing roles that used this skill (import from src/data/constellation.ts)",
"Wire into DetailPanel: content.type === \u0027skill\u0027 renders this component",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
@@ -373,11 +373,11 @@
"description": "As a developer, I need src/components/detail/SkillsAllDetail.tsx showing the full categorised list of all skills. Clicking an individual skill switches the panel to SkillDetail. See Ralph/depth-design.md Section 6.3.",
"acceptanceCriteria": [
"Create src/components/detail/SkillsAllDetail.tsx",
"Shows full list grouped by Technical / Healthcare Domain / Strategic & Leadership",
"Shows full list grouped by Technical / Healthcare Domain / Strategic \u0026 Leadership",
"Category headers styled consistently with CoreSkillsTile category headers",
"Each skill row is clickable calls openPanel({ type: 'skill', skill }) to switch panel content",
"Each skill row is clickable → calls openPanel({ type: \u0027skill\u0027, skill }) to switch panel content",
"If opened with a category filter (content.category), scroll to or highlight that category",
"Wire into DetailPanel: content.type === 'skills-all' renders this component",
"Wire into DetailPanel: content.type === \u0027skills-all\u0027 renders this component",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
@@ -396,7 +396,7 @@
"If MPharm: shows research project description, extracurricular activities list",
"If Mary Seacole: shows programme detail",
"Shows notes from document data if present",
"Wire into DetailPanel: content.type === 'education' renders this component",
"Wire into DetailPanel: content.type === \u0027education\u0027 renders this component",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
@@ -411,7 +411,7 @@
"acceptanceCriteria": [
"Run npm install d3 @types/d3",
"Create src/components/CareerConstellation.tsx with props: onRoleClick(id: string), onSkillClick(id: string)",
"Component renders a responsive SVG container using useRef<SVGSVGElement>",
"Component renders a responsive SVG container using useRef\u003cSVGSVGElement\u003e",
"Container: full width, height 400px desktop / 300px tablet / 250px mobile (use CSS or media queries)",
"SVG has viewBox for responsive scaling",
"Import constellation data from src/data/constellation.ts",
@@ -446,7 +446,7 @@
"title": "Add accessibility to CareerConstellation",
"description": "As a developer, I need the CareerConstellation to be accessible: keyboard navigable, screen-reader friendly, and respecting reduced motion. See Ralph/depth-design.md Section 2.4 accessibility notes.",
"acceptanceCriteria": [
"SVG has role=img and aria-label describing the graph ('Career constellation showing roles and skills across career timeline')",
"SVG has role=img and aria-label describing the graph (\u0027Career constellation showing roles and skills across career timeline\u0027)",
"Screen-reader-only text description of graph structure (hidden visually, available to assistive tech)",
"Keyboard navigation: Tab through role nodes, Enter/Space opens detail panel for focused node",
"Focus indicators visible on keyboard-focused nodes",
@@ -496,12 +496,12 @@
"title": "Change login username to a.recruiter and add connection status indicator",
"description": "As a developer, I need to change the typed username from a.charlwood to a.recruiter and add a connection status indicator below the login button. See Ralph/depth-design.md Section 3.3.",
"acceptanceCriteria": [
"Username typed in login animation is 'a.recruiter' (not 'A.CHARLWOOD' or similar)",
"Username typed in login animation is \u0027a.recruiter\u0027 (not \u0027A.CHARLWOOD\u0027 or similar)",
"Connection status indicator appears below the login button: 6px dot + text",
"Initial state: red/alert dot + 'Awaiting secure connection...' (var(--alert) color)",
"After ~2000ms: dot transitions to green + 'Secure connection established' (var(--success) color, 300ms transition)",
"Initial state: red/alert dot + \u0027Awaiting secure connection...\u0027 (var(--alert) color)",
"After ~2000ms: dot transitions to green + \u0027Secure connection established\u0027 (var(--success) color, 300ms transition)",
"Text: 10px, font-family var(--font-geist-mono), color var(--text-tertiary)",
"Login button disabled until BOTH typing is complete AND connectionState === 'connected'",
"Login button disabled until BOTH typing is complete AND connectionState === \u0027connected\u0027",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
@@ -514,11 +514,11 @@
"title": "Add post-login loading state and update TopBar session name",
"description": "As a developer, I need a brief loading state after clicking the login button before the dashboard appears, and the TopBar should show A.RECRUITER as the session user. See Ralph/depth-design.md Sections 3.3 and 3.2.",
"acceptanceCriteria": [
"On login button click: isLoading=true, card content replaced with spinner + 'Loading clinical records...' text",
"On login button click: isLoading=true, card content replaced with spinner + \u0027Loading clinical records...\u0027 text",
"Loading state lasts ~600ms, then calls onComplete() to transition to dashboard",
"Spinner is a CSS-animated spinner (not a GIF), styled with var(--accent) or similar",
"Loading text: 12px, color var(--text-secondary)",
"In TopBar.tsx: change session display name from 'Dr. A.CHARLWOOD' (or current value) to 'A.RECRUITER'",
"In TopBar.tsx: change session display name from \u0027Dr. A.CHARLWOOD\u0027 (or current value) to \u0027A.RECRUITER\u0027",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
@@ -535,7 +535,7 @@
"Selecting a skill result opens the detail panel for that skill (openPanel call or dispatch event)",
"Selecting a KPI result opens the KPI detail panel",
"Selecting a project result opens the project detail panel",
"Ensure DashboardLayout handlePaletteAction supports a new 'panel' action type or adapts existing types to trigger detail panel",
"Ensure DashboardLayout handlePaletteAction supports a new \u0027panel\u0027 action type or adapts existing types to trigger detail panel",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
@@ -548,12 +548,12 @@
"title": "Responsive testing and fixes for all new components",
"description": "As a developer, I need to verify and fix responsive behavior for the detail panel, sub-nav, constellation, and restructured layout at all breakpoints.",
"acceptanceCriteria": [
"DetailPanel: both narrow and wide render as 100vw on mobile (<768px)",
"DetailPanel: both narrow and wide render as 100vw on mobile (\u003c768px)",
"SubNav: works on tablet/mobile (horizontal scroll if needed, no overflow)",
"CareerConstellation: renders at 300px height on tablet, 250px on mobile",
"Projects + KPIs: stack vertically on mobile when grid falls to single column",
"CoreSkillsTile: full-width layout works on all breakpoints",
"All interactive elements have touch targets >= 44px on mobile",
"All interactive elements have touch targets \u003e= 44px on mobile",
"No horizontal overflow at 375px viewport width",
"Typecheck passes",
"Verify in browser using dev-browser skill"
+13
View File
@@ -8,3 +8,16 @@ Stories: 32 (US-001 through US-032)
## Status
No iterations completed yet.
2026-02-13 22:57 | PASS | US-001: Clean up unused legacy components and hooks | model=opus elapsed=01:58 tools=18
2026-02-13 22:59 | PASS | US-002: Add new TypeScript types and CSS custom properties for depth features | model=sonnet elapsed=01:54 tools=11
2026-02-13 23:03 | PASS | US-003: Create DetailPanelContext, DetailPanel component, and useFocusTrap hook | model=sonnet elapsed=03:39 tools=22
2026-02-13 23:06 | PASS | US-004: Create SubNav component and useActiveSection hook | model=sonnet elapsed=02:54 tools=18
2026-02-13 23:08 | PASS | US-005: Expand skills data from 5 to ~20 with three categories | model=sonnet elapsed=01:58 tools=11
2026-02-13 23:10 | PASS | US-006: Add KPI story data and update 4th KPI | model=sonnet elapsed=01:59 tools=9
2026-02-13 23:11 | PASS | US-007: Create education extras data file | model=sonnet elapsed=01:25 tools=10
2026-02-13 23:15 | PASS | US-008: Restructure DashboardLayout with SubNav, new tile order, and DetailPanel | model=sonnet elapsed=03:10 tools=27
2026-02-13 23:17 | PASS | US-009: Create constellation data mapping file | model=sonnet elapsed=02:20 tools=10
2026-02-13 23:50 | PASS | US-011: Modify CoreSkillsTile: full width, categorised groups, panel triggers | model=opus elapsed=02:54 tools=22
2026-02-13 23:52 | PASS | US-012: Modify ProjectsTile: half width, compact card grid, panel trigger | model=sonnet elapsed=02:16 tools=11
2026-02-13 23:55 | PASS | US-013: Modify LastConsultationTile: add panel trigger | model=sonnet elapsed=02:20 tools=15
2026-02-13 23:58 | PASS | US-014: Modify CareerActivityTile: panel triggers and hover preview | model=sonnet elapsed=02:49 tools=14
+568
View File
@@ -0,0 +1,568 @@
<#
.SYNOPSIS
Ralph Wiggum Loop - PRD-driven variant.
.DESCRIPTION
Iterates through user stories in prd.json, spawning a fresh `claude --print`
invocation for each story. Memory persists via filesystem only: git commits,
prd.json (passes field), and progress.txt.
Each iteration works on ONE user story (in priority order).
When all stories pass, the loop completes.
Circuit breakers prevent runaway costs:
- No git changes for N consecutive iterations (stalled)
- Same error repeated N consecutive iterations (stuck)
.PARAMETER Model
Initial Claude model to use. Default: "opus". The agent can dynamically switch
models between iterations via <next-model>opus|sonnet</next-model> signals.
.PARAMETER MaxNoProgress
Number of consecutive iterations with no git changes before circuit breaker trips. Default: 3.
.PARAMETER MaxSameError
Number of consecutive iterations with the same error before circuit breaker trips. Default: 3.
.PARAMETER StartFrom
Story ID to start from (e.g., "US-005"). Treats all earlier stories as already passed.
.EXAMPLE
.\.claude\skills\ralph\ralph.ps1 -Model "opus"
.EXAMPLE
.\.claude\skills\ralph\ralph.ps1 -StartFrom "US-010" -Model "sonnet"
#>
param(
[string]$Model = "opus",
[int]$MaxNoProgress = 3,
[int]$MaxSameError = 3,
[string]$StartFrom = ""
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$prdFile = Join-Path $scriptDir "prd.json"
$progressFile = Join-Path $scriptDir "progress.txt"
$logDir = Join-Path $scriptDir "logs"
# --- Find project root (git repo root) ---
$projectRoot = git rev-parse --show-toplevel 2>$null
if (-not $projectRoot) {
Write-Error "Not inside a git repository. Run from the project directory."
exit 1
}
$projectRoot = (Resolve-Path $projectRoot).Path
# --- Validation ---
if (-not (Test-Path $prdFile)) {
Write-Error "prd.json not found at $prdFile"
exit 1
}
# Ensure logs directory exists
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir | Out-Null
Write-Host "Created logs directory"
}
# --- PRD Read/Write ---
function Read-Prd {
Get-Content -Path $prdFile -Raw | ConvertFrom-Json
}
function Save-Prd {
param($prdObj)
$prdObj | ConvertTo-Json -Depth 10 | Set-Content -Path $prdFile -Encoding UTF8
}
$prd = Read-Prd
# --- Git Setup ---
$BranchName = $prd.branchName
if ($BranchName) {
$currentBranch = git branch --show-current
if ($currentBranch -ne $BranchName) {
$branchExists = git branch --list $BranchName
if ($branchExists) {
Write-Host "Switching to existing branch: $BranchName"
git checkout $BranchName
} else {
Write-Host "Creating branch: $BranchName"
git checkout -b $BranchName
}
}
}
# --- Handle StartFrom: mark earlier stories as passed ---
if ($StartFrom) {
$startPriority = [int]($StartFrom -replace 'US-0*', '')
$skippedCount = 0
foreach ($story in $prd.userStories) {
$storyPriority = [int]($story.id -replace 'US-0*', '')
if ($storyPriority -lt $startPriority -and $story.passes -ne $true) {
$story.passes = $true
$story.notes = "Skipped (--StartFrom $StartFrom)"
$skippedCount++
}
}
if ($skippedCount -gt 0) {
Save-Prd $prd
Write-Host "Marked $skippedCount stories before $StartFrom as skipped." -ForegroundColor DarkYellow
}
}
# --- Circuit Breaker State ---
$noProgressCount = 0
$lastErrorSignature = ""
$sameErrorCount = 0
# --- Prompt Generation ---
function Build-StoryPrompt {
param(
$story,
$prdObj,
[array]$completedStories
)
# Build completed list
$completedSection = ""
if ($completedStories.Count -gt 0) {
$completedLines = ($completedStories | ForEach-Object {
"- $($_.id): $($_.title)"
}) -join "`n"
$completedSection = "`n## Previously Completed Stories (do not redo)`n$completedLines`n"
}
# Build criteria list
$criteriaLines = ($story.acceptanceCriteria | ForEach-Object { "- [ ] $_" }) -join "`n"
# Build prompt using array-join (avoids PS 5.1 here-string indentation issues)
$sid = $story.id
$stitle = $story.title
$sdesc = $story.description
$pdesc = $prdObj.description
$prompt = @(
"# Ralph Iteration: $sid - $stitle"
""
"## Project"
"$pdesc"
""
"Read CLAUDE.md for full project conventions, architecture, and design system. This is mandatory before starting work."
""
"## Your Task"
""
"**${sid}: $stitle**"
""
"$sdesc"
""
"## Acceptance Criteria"
""
"$criteriaLines"
""
"## Reference Documents"
""
"Read these as needed for implementation detail:"
""
"- **CLAUDE.md** - Project conventions, architecture, design tokens, guardrails (READ FIRST)"
"- **Ralph/depth-design.md** - Component architecture, props interfaces, CSS specs, data models"
"- **Ralph/depth-requirements.md** - Full requirements with content sources and UX patterns"
"- **References/CV_v4.md** - Source of truth for all CV content (roles, dates, achievements, numbers)"
"$completedSection"
"## Workflow"
""
"1. Read CLAUDE.md to understand project conventions"
"2. Read Ralph/depth-design.md sections relevant to this story"
"3. Read existing source files you will modify to understand current patterns"
"4. Implement ALL acceptance criteria"
"5. Run npm run typecheck - fix any type errors"
"6. Run npm run build - fix any build errors"
"7. Stage and commit your changes:"
" git add [specific files] && git commit -m `"${sid}: [descriptive message]`""
"8. When ALL criteria are met, output: <story-complete>$sid</story-complete>"
""
"## Rules"
""
"- Work ONLY on $sid. Do not modify code for other stories."
"- Read files before modifying them."
"- Follow existing patterns and conventions in the codebase."
"- Use lucide-react for icons, never unicode symbols."
"- Use the project's CSS custom properties and Tailwind tokens."
"- Commit specific files, not git add -A."
"- Do NOT start a dev server (npm run dev). One is already running on port $devServerPort. Do NOT run any background tasks."
"- If genuinely blocked, output <story-blocked>$sid</story-blocked> with explanation."
"- To recommend a different model for the NEXT iteration, output <next-model>opus</next-model> or <next-model>sonnet</next-model>."
) -join "`n"
return $prompt
}
# --- Banner ---
$completedCount = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
$totalCount = $prd.userStories.Count
Write-Host ""
Write-Host "===== Ralph Wiggum Loop (PRD-driven) =====" -ForegroundColor Cyan
Write-Host "Project: $($prd.project)" -ForegroundColor Cyan
Write-Host "Branch: $BranchName | Model: $Model (dynamic switching enabled)" -ForegroundColor Cyan
Write-Host "Stories: $completedCount/$totalCount complete" -ForegroundColor Cyan
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
Write-Host "===========================================" -ForegroundColor Cyan
Write-Host ""
# Dev server port (assumed to be running externally)
$devServerPort = 5173
Write-Host "Dev server assumed running on port $devServerPort" -ForegroundColor DarkGray
Write-Host ""
# --- Story Loop ---
$iterationCount = 0
$originalDir = Get-Location
Set-Location $projectRoot
try {
while ($true) {
# Re-read PRD each iteration (in case previous iteration updated it)
$prd = Read-Prd
# Partition stories
$completedStories = @($prd.userStories | Where-Object { $_.passes -eq $true })
$pendingStories = @($prd.userStories | Where-Object { $_.passes -ne $true } | Sort-Object { $_.priority })
# Check if all done
if ($pendingStories.Count -eq 0) {
Write-Host ""
Write-Host "===== ALL STORIES COMPLETE =====" -ForegroundColor Green
Write-Host "$($completedStories.Count)/$($prd.userStories.Count) stories passed." -ForegroundColor Green
Write-Host "Branch: $BranchName" -ForegroundColor Green
break
}
$currentStory = $pendingStories[0]
$iterationCount++
$pctComplete = [math]::Round(($completedStories.Count / $prd.userStories.Count) * 100)
$storyLabel = "$($currentStory.id): $($currentStory.title)"
$pctStr = "${pctComplete}%"
$progressMsg = " Progress: $($completedStories.Count)/$($prd.userStories.Count) ($pctStr) - Remaining: $($pendingStories.Count)"
Write-Host ""
Write-Host "--- Iteration $iterationCount - $storyLabel ---" -ForegroundColor Yellow
Write-Host $progressMsg -ForegroundColor DarkGray
# Record HEAD before this iteration
$headBefore = git rev-parse HEAD 2>$null
$iterStart = Get-Date
Write-Host " Started: $($iterStart.ToString('HH:mm:ss')) | Model: $Model" -ForegroundColor DarkGray
Write-Host ""
# Generate prompt for this story
$promptContent = Build-StoryPrompt -story $currentStory -prdObj $prd -completedStories $completedStories
# --- Spawn Claude ---
$logFile = Join-Path $logDir "$($currentStory.id).log"
$rawLogFile = Join-Path $logDir "$($currentStory.id).raw.jsonl"
$maxRetries = 10
$retryCount = 0
$outputString = ""
$apiOverloaded = $false
do {
$apiOverloaded = $false
$textBuilder = [System.Text.StringBuilder]::new()
$toolCount = 0
# Clear raw log file for this attempt
if (Test-Path $rawLogFile) { Remove-Item $rawLogFile -Force }
if ($retryCount -gt 0) {
$backoffSeconds = [Math]::Pow(2, $retryCount - 1)
Write-Host " [Retry $retryCount/$maxRetries] API overloaded, waiting $backoffSeconds seconds..." -ForegroundColor DarkYellow
Start-Sleep -Seconds $backoffSeconds
Write-Host " Retrying Claude invocation..." -ForegroundColor DarkGray
}
# --- Spawn Claude via Process.Start for clean shutdown control ---
# Using Process.Start instead of pipeline so we can break on the result
# event and force-kill the process tree. The pipeline approach hangs when
# Claude spawns background tasks (e.g. npm run dev) that keep stdout open.
$promptTempFile = Join-Path $logDir "$($currentStory.id).prompt.tmp"
$promptContent | Set-Content -Path $promptTempFile -Encoding UTF8
$claudeArgs = "--print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json"
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = "cmd.exe"
$psi.Arguments = "/c type `"$promptTempFile`" | claude $claudeArgs"
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$psi.WorkingDirectory = $projectRoot
$claudeProc = [System.Diagnostics.Process]::Start($psi)
# Drain stderr async to prevent buffer deadlock
$claudeProc.add_ErrorDataReceived({ param($s,$e) })
$claudeProc.BeginErrorReadLine()
try {
while ($null -ne ($line = $claudeProc.StandardOutput.ReadLine())) {
$line = $line.Trim()
if (-not $line) { continue }
# Save raw event for debugging
try {
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
} catch { }
$isResultEvent = $false
try {
$evt = $line | ConvertFrom-Json -ErrorAction Stop
# --- Tool use start ---
if ($evt.type -eq 'content_block_start' -and $evt.content_block.type -eq 'tool_use') {
$toolCount++
$toolName = $evt.content_block.name
Write-Host " [$toolName]" -ForegroundColor DarkCyan
}
# --- Streaming text ---
elseif ($evt.type -eq 'content_block_delta' -and $evt.delta.type -eq 'text_delta' -and $evt.delta.text) {
Write-Host -NoNewline $evt.delta.text
[void]$textBuilder.Append($evt.delta.text)
}
# --- Result event (terminal — stop reading after this) ---
elseif ($evt.type -eq 'result') {
if ($evt.subtype -eq 'error_result' -and $evt.error) {
Write-Host " [ERROR] $($evt.error)" -ForegroundColor Red
[void]$textBuilder.AppendLine("ERROR: $($evt.error)")
}
elseif ($evt.result) {
[void]$textBuilder.AppendLine($evt.result)
}
$isResultEvent = $true
}
# --- Message-level content ---
elseif ($evt.message -and $evt.message.content) {
foreach ($block in $evt.message.content) {
if ($block.type -eq 'text' -and $block.text) {
Write-Host $block.text
[void]$textBuilder.AppendLine($block.text)
}
elseif ($block.type -eq 'tool_use') {
$toolCount++
Write-Host " [$($block.name)]" -ForegroundColor DarkCyan
}
}
}
} catch {
if ($line -and $line -notmatch '^\s*["\{\[\}\]]') {
Write-Host $line -ForegroundColor DarkYellow
[void]$textBuilder.AppendLine($line)
}
}
# Result is always the final stream event — stop reading
if ($isResultEvent) { break }
}
} finally {
# Kill the Claude process tree to prevent orphaned cmd.exe/node processes
if ($claudeProc -and -not $claudeProc.HasExited) {
try {
taskkill /T /F /PID $claudeProc.Id 2>$null | Out-Null
} catch { }
}
Remove-Item -Path $promptTempFile -ErrorAction SilentlyContinue
}
$outputString = $textBuilder.ToString()
# Check for 529 overloaded error
if ($outputString -match "529.*overloaded|overloaded_error") {
$apiOverloaded = $true
$retryCount++
if ($retryCount -ge $maxRetries) {
Write-Host " [ERROR] API overloaded after $maxRetries retries, giving up." -ForegroundColor Red
}
}
# Check for usage limit with cooldown
elseif ($outputString -match "(?i)usage limit reached.*reset at (\d{1,2})(?::(\d{2}))?\s*(am|pm)") {
$resetHour = [int]$Matches[1]
$resetMinute = if ($Matches[2]) { [int]$Matches[2] } else { 0 }
$resetAmPm = $Matches[3]
if ($resetAmPm -ieq "pm" -and $resetHour -ne 12) { $resetHour += 12 }
elseif ($resetAmPm -ieq "am" -and $resetHour -eq 12) { $resetHour = 0 }
$now = Get-Date
$resetTime = Get-Date -Hour $resetHour -Minute $resetMinute -Second 0
if ($resetTime -le $now) { $resetTime = $resetTime.AddDays(1) }
$resetTime = $resetTime.AddMinutes(2)
$waitSeconds = [Math]::Ceiling(($resetTime - $now).TotalSeconds)
$waitMinutes = [Math]::Ceiling($waitSeconds / 60)
Write-Host ""
Write-Host " [USAGE LIMIT] Reset at $($Matches[1]) $resetAmPm. Cooling down ~$waitMinutes minutes (until $($resetTime.ToString('HH:mm')))..." -ForegroundColor Yellow
Start-Sleep -Seconds $waitSeconds
Write-Host " [USAGE LIMIT] Cooldown complete. Retrying..." -ForegroundColor Green
$apiOverloaded = $true
}
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
# Save log
$outputString | Set-Content -Path $logFile -Encoding UTF8
# Show elapsed time
$elapsed = (Get-Date) - $iterStart
Write-Host ""
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
# --- Detect signals ---
$storyComplete = $outputString -match "<story-complete>$([regex]::Escape($currentStory.id))</story-complete>"
$storyBlocked = $outputString -match "<story-blocked>$([regex]::Escape($currentStory.id))</story-blocked>"
$headAfter = git rev-parse HEAD 2>$null
$hasGitChanges = $headAfter -ne $headBefore
# --- Update story status ---
if ($storyComplete) {
# Mark story as passed in prd.json
$prd = Read-Prd
$storyToUpdate = $prd.userStories | Where-Object { $_.id -eq $currentStory.id }
if ($storyToUpdate) {
$alreadyDone = if (-not $hasGitChanges) { " (already committed)" } else { "" }
$storyToUpdate.passes = $true
$storyToUpdate.notes = "Completed iteration $iterationCount at $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Model: $Model.$alreadyDone"
}
Save-Prd $prd
# Append to progress.txt
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
$el = $elapsed.ToString('mm\:ss')
$tag = if ($hasGitChanges) { "PASS" } else { "PASS (no new commits)" }
$progressEntry = "$ts | $tag | $($currentStory.id): $($currentStory.title) | model=$Model elapsed=$el tools=$toolCount"
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
Write-Host " [PASSED] $storyLabel" -ForegroundColor Green
if (-not $hasGitChanges) {
Write-Host " (Work was already committed)" -ForegroundColor DarkGray
}
$noProgressCount = 0
$sameErrorCount = 0
$lastErrorSignature = ""
}
elseif ($storyBlocked) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
$progressEntry = "$ts | BLOCKED | $storyLabel"
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
Write-Host " [BLOCKED] $storyLabel - check $logFile for details." -ForegroundColor Red
# Blocked counts as no progress
$noProgressCount++
}
else {
# No completion signal
if ($hasGitChanges) {
Write-Host " [PARTIAL] Git changes but no completion signal. Retrying story." -ForegroundColor DarkYellow
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
$progressEntry = "$ts | PARTIAL | $storyLabel"
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
$noProgressCount = 0
} else {
Write-Host " [NO PROGRESS] No changes and no signal." -ForegroundColor DarkYellow
$noProgressCount++
}
}
# --- Circuit Breaker: No Progress ---
if ($noProgressCount -ge $MaxNoProgress) {
Write-Host ""
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
Write-Host "No meaningful progress for $MaxNoProgress consecutive iterations." -ForegroundColor Red
Write-Host "Stuck on: $($currentStory.id) - $($currentStory.title)" -ForegroundColor Red
Write-Host "Check $logFile for details." -ForegroundColor Red
break
}
# --- Circuit Breaker: Repeated Error ---
$errorLines = $outputString | Select-String -Pattern "(?i)(error|exception|failed|fatal)[:.].*" -AllMatches
if ($errorLines) {
$filteredErrors = $errorLines.Matches | Where-Object { $_.Value -notmatch "529|overloaded" } | Select-Object -First 3
$currentErrorSignature = ($filteredErrors | ForEach-Object { $_.Value }) -join "|"
if ($currentErrorSignature -and $currentErrorSignature -eq $lastErrorSignature) {
$sameErrorCount++
Write-Host " [Circuit Breaker] Same error pattern repeated ($sameErrorCount/$MaxSameError)" -ForegroundColor DarkYellow
if ($sameErrorCount -ge $MaxSameError) {
Write-Host ""
Write-Host "===== CIRCUIT BREAKER: REPEATED ERROR =====" -ForegroundColor Red
Write-Host "Same error for $MaxSameError consecutive iterations:" -ForegroundColor Red
Write-Host " $currentErrorSignature" -ForegroundColor Red
break
}
} elseif ($currentErrorSignature) {
$sameErrorCount = 0
}
$lastErrorSignature = $currentErrorSignature
} else {
$sameErrorCount = 0
$lastErrorSignature = ""
}
# --- Dynamic Model Selection ---
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
$nextModel = $Matches[1]
if ($nextModel -ne $Model) {
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
$Model = $nextModel
}
}
# Brief pause between iterations
Start-Sleep -Seconds 2
}
} finally {
Set-Location $originalDir
}
# --- Final Summary ---
$prd = Read-Prd
$finalPassed = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
$finalTotal = $prd.userStories.Count
Write-Host ""
Write-Host "===========================================" -ForegroundColor Cyan
Write-Host " Ralph Loop finished after $iterationCount iteration(s)" -ForegroundColor Cyan
Write-Host " Stories: $finalPassed/$finalTotal passed" -ForegroundColor Cyan
Write-Host " Branch: $BranchName" -ForegroundColor Cyan
Write-Host " Logs: $logDir" -ForegroundColor Cyan
Write-Host "===========================================" -ForegroundColor Cyan
if ($finalPassed -eq $finalTotal) {
exit 0
} else {
exit 1
}
+582
View File
@@ -0,0 +1,582 @@
<#
.SYNOPSIS
Ralph Wiggum Loop — PRD-driven variant.
.DESCRIPTION
Iterates through user stories in prd.json, spawning a fresh `claude --print`
invocation for each story. Memory persists via filesystem only: git commits,
prd.json (passes field), and progress.txt.
Each iteration works on ONE user story (in priority order).
When all stories pass, the loop completes.
Circuit breakers prevent runaway costs:
- No git changes for N consecutive iterations (stalled)
- Same error repeated N consecutive iterations (stuck)
.PARAMETER Model
Initial Claude model to use. Default: "opus". The agent can dynamically switch
models between iterations via <next-model>opus|sonnet</next-model> signals.
.PARAMETER MaxNoProgress
Number of consecutive iterations with no git changes before circuit breaker trips. Default: 3.
.PARAMETER MaxSameError
Number of consecutive iterations with the same error before circuit breaker trips. Default: 3.
.PARAMETER StartFrom
Story ID to start from (e.g., "US-005"). Treats all earlier stories as already passed.
.PARAMETER SkipVerify
Skip post-iteration typecheck verification. Faster but less safe.
.EXAMPLE
.\.claude\skills\ralph\ralph.ps1 -Model "opus"
.EXAMPLE
.\.claude\skills\ralph\ralph.ps1 -StartFrom "US-010" -Model "sonnet"
#>
param(
[string]$Model = "opus",
[int]$MaxNoProgress = 3,
[int]$MaxSameError = 3,
[string]$StartFrom = "",
[switch]$SkipVerify
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$prdFile = Join-Path $scriptDir "prd.json"
$progressFile = Join-Path $scriptDir "progress.txt"
$logDir = Join-Path $scriptDir "logs"
# --- Find project root (git repo root) ---
$projectRoot = git rev-parse --show-toplevel 2>$null
if (-not $projectRoot) {
Write-Error "Not inside a git repository. Run from the project directory."
exit 1
}
$projectRoot = (Resolve-Path $projectRoot).Path
# --- Validation ---
if (-not (Test-Path $prdFile)) {
Write-Error "prd.json not found at $prdFile"
exit 1
}
# Ensure logs directory exists
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir | Out-Null
Write-Host "Created logs directory"
}
# --- PRD Read/Write ---
function Read-Prd {
Get-Content -Path $prdFile -Raw | ConvertFrom-Json
}
function Save-Prd {
param($prdObj)
$prdObj | ConvertTo-Json -Depth 10 | Set-Content -Path $prdFile -Encoding UTF8
}
$prd = Read-Prd
# --- Git Setup ---
$BranchName = $prd.branchName
if ($BranchName) {
$currentBranch = git branch --show-current
if ($currentBranch -ne $BranchName) {
$branchExists = git branch --list $BranchName
if ($branchExists) {
Write-Host "Switching to existing branch: $BranchName"
git checkout $BranchName
} else {
Write-Host "Creating branch: $BranchName"
git checkout -b $BranchName
}
}
}
# --- Handle StartFrom: mark earlier stories as passed ---
if ($StartFrom) {
$startPriority = [int]($StartFrom -replace 'US-0*', '')
$skippedCount = 0
foreach ($story in $prd.userStories) {
$storyPriority = [int]($story.id -replace 'US-0*', '')
if ($storyPriority -lt $startPriority -and $story.passes -ne $true) {
$story.passes = $true
$story.notes = "Skipped (--StartFrom $StartFrom)"
$skippedCount++
}
}
if ($skippedCount -gt 0) {
Save-Prd $prd
Write-Host "Marked $skippedCount stories before $StartFrom as skipped." -ForegroundColor DarkYellow
}
}
# --- Circuit Breaker State ---
$noProgressCount = 0
$lastErrorSignature = ""
$sameErrorCount = 0
# --- Prompt Generation ---
function Build-StoryPrompt {
param(
$story,
$prdObj,
[array]$completedStories
)
# Build completed list
$completedSection = ""
if ($completedStories.Count -gt 0) {
$completedLines = ($completedStories | ForEach-Object {
"- $($_.id): $($_.title)"
}) -join "`n"
$completedSection = "`n## Previously Completed Stories (do not redo)`n$completedLines`n"
}
# Build criteria list
$criteriaLines = ($story.acceptanceCriteria | ForEach-Object { "- [ ] $_" }) -join "`n"
# Build prompt using array-join (avoids PS 5.1 here-string indentation issues)
$sid = $story.id
$stitle = $story.title
$sdesc = $story.description
$pdesc = $prdObj.description
$prompt = @(
"# Ralph Iteration: $sid - $stitle"
""
"## Project"
"$pdesc"
""
"Read CLAUDE.md for full project conventions, architecture, and design system. This is mandatory before starting work."
""
"## Your Task"
""
"**${sid}: $stitle**"
""
"$sdesc"
""
"## Acceptance Criteria"
""
"$criteriaLines"
""
"## Reference Documents"
""
"Read these as needed for implementation detail:"
""
"- **CLAUDE.md** - Project conventions, architecture, design tokens, guardrails (READ FIRST)"
"- **Ralph/depth-design.md** - Component architecture, props interfaces, CSS specs, data models"
"- **Ralph/depth-requirements.md** - Full requirements with content sources and UX patterns"
"- **References/CV_v4.md** - Source of truth for all CV content (roles, dates, achievements, numbers)"
"$completedSection"
"## Workflow"
""
"1. Read CLAUDE.md to understand project conventions"
"2. Read Ralph/depth-design.md sections relevant to this story"
"3. Read existing source files you will modify to understand current patterns"
"4. Implement ALL acceptance criteria"
"5. Run npm run typecheck - fix any type errors"
"6. Run npm run build - fix any build errors"
"7. Stage and commit your changes:"
" git add [specific files] && git commit -m `"${sid}: [descriptive message]`""
"8. When ALL criteria are met, output: <story-complete>$sid</story-complete>"
""
"## Rules"
""
"- Work ONLY on $sid. Do not modify code for other stories."
"- Read files before modifying them."
"- Follow existing patterns and conventions in the codebase."
"- Use lucide-react for icons, never unicode symbols."
"- Use the project's CSS custom properties and Tailwind tokens."
"- Commit specific files, not git add -A."
"- If genuinely blocked, output <story-blocked>$sid</story-blocked> with explanation."
"- To recommend a different model for the NEXT iteration, output <next-model>opus</next-model> or <next-model>sonnet</next-model>."
) -join "`n"
return $prompt
}
# --- Banner ---
$completedCount = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
$totalCount = $prd.userStories.Count
Write-Host ""
Write-Host "===== Ralph Wiggum Loop (PRD-driven) =====" -ForegroundColor Cyan
Write-Host "Project: $($prd.project)" -ForegroundColor Cyan
Write-Host "Branch: $BranchName | Model: $Model (dynamic switching enabled)" -ForegroundColor Cyan
Write-Host "Stories: $completedCount/$totalCount complete" -ForegroundColor Cyan
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
if (-not $SkipVerify) { Write-Host "Post-iteration typecheck verification: ON" -ForegroundColor Cyan }
Write-Host "===========================================" -ForegroundColor Cyan
Write-Host ""
# --- Dev Server ---
$devServerPort = 5173
$devServerPid = $null
try {
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
Write-Host "Dev server detected on port $devServerPort" -ForegroundColor Green
} catch {
Write-Host "Starting dev server (port $devServerPort)..." -ForegroundColor Cyan
$devProc = Start-Process -FilePath "npm.cmd" -ArgumentList "run", "dev" -WorkingDirectory $projectRoot -PassThru -WindowStyle Minimized
$devServerPid = $devProc.Id
for ($w = 1; $w -le 20; $w++) {
Start-Sleep -Seconds 1
try {
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
Write-Host "Dev server ready on port $devServerPort" -ForegroundColor Green
break
} catch {
if ($w -eq 20) {
Write-Warning "Dev server may not be ready — visual review steps may fail"
}
}
}
}
Write-Host ""
# --- Story Loop ---
$iterationCount = 0
$originalDir = Get-Location
Set-Location $projectRoot
try {
while ($true) {
# Re-read PRD each iteration (in case previous iteration updated it)
$prd = Read-Prd
# Partition stories
$completedStories = @($prd.userStories | Where-Object { $_.passes -eq $true })
$pendingStories = @($prd.userStories | Where-Object { $_.passes -ne $true } | Sort-Object { $_.priority })
# Check if all done
if ($pendingStories.Count -eq 0) {
Write-Host ""
Write-Host "===== ALL STORIES COMPLETE =====" -ForegroundColor Green
Write-Host "$($completedStories.Count)/$($prd.userStories.Count) stories passed." -ForegroundColor Green
Write-Host "Branch: $BranchName" -ForegroundColor Green
break
}
$currentStory = $pendingStories[0]
$iterationCount++
$pctComplete = [math]::Round(($completedStories.Count / $prd.userStories.Count) * 100)
$storyLabel = "$($currentStory.id): $($currentStory.title)"
$pctStr = "${pctComplete}%"
$progressMsg = " Progress: $($completedStories.Count)/$($prd.userStories.Count) ($pctStr) - Remaining: $($pendingStories.Count)"
Write-Host ""
Write-Host "--- Iteration $iterationCount - $storyLabel ---" -ForegroundColor Yellow
Write-Host $progressMsg -ForegroundColor DarkGray
# Record HEAD before this iteration
$headBefore = git rev-parse HEAD 2>$null
$iterStart = Get-Date
Write-Host " Started: $($iterStart.ToString('HH:mm:ss')) | Model: $Model" -ForegroundColor DarkGray
Write-Host ""
# Generate prompt for this story
$promptContent = Build-StoryPrompt -story $currentStory -prdObj $prd -completedStories $completedStories
# --- Spawn Claude ---
$logFile = Join-Path $logDir "$($currentStory.id).log"
$rawLogFile = Join-Path $logDir "$($currentStory.id).raw.jsonl"
$maxRetries = 10
$retryCount = 0
$outputString = ""
$apiOverloaded = $false
do {
$apiOverloaded = $false
$textBuilder = [System.Text.StringBuilder]::new()
$toolCount = 0
# Clear raw log file for this attempt
if (Test-Path $rawLogFile) { Remove-Item $rawLogFile -Force }
if ($retryCount -gt 0) {
$backoffSeconds = [Math]::Pow(2, $retryCount - 1)
Write-Host " [Retry $retryCount/$maxRetries] API overloaded, waiting $backoffSeconds seconds..." -ForegroundColor DarkYellow
Start-Sleep -Seconds $backoffSeconds
Write-Host " Retrying Claude invocation..." -ForegroundColor DarkGray
}
$promptContent | claude --print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json 2>&1 | ForEach-Object {
$line = $_.ToString().Trim()
if (-not $line) { return }
# Save raw event for debugging
try {
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
} catch { }
try {
$evt = $line | ConvertFrom-Json -ErrorAction Stop
# --- Tool use start ---
if ($evt.type -eq 'content_block_start' -and $evt.content_block.type -eq 'tool_use') {
$toolCount++
$toolName = $evt.content_block.name
Write-Host " [$toolName]" -ForegroundColor DarkCyan
}
# --- Streaming text ---
elseif ($evt.type -eq 'content_block_delta' -and $evt.delta.type -eq 'text_delta' -and $evt.delta.text) {
Write-Host -NoNewline $evt.delta.text
[void]$textBuilder.Append($evt.delta.text)
}
# --- Result event ---
elseif ($evt.type -eq 'result') {
if ($evt.subtype -eq 'error_result' -and $evt.error) {
Write-Host " [ERROR] $($evt.error)" -ForegroundColor Red
[void]$textBuilder.AppendLine("ERROR: $($evt.error)")
}
elseif ($evt.result) {
[void]$textBuilder.AppendLine($evt.result)
}
}
# --- Message-level content ---
elseif ($evt.message -and $evt.message.content) {
foreach ($block in $evt.message.content) {
if ($block.type -eq 'text' -and $block.text) {
Write-Host $block.text
[void]$textBuilder.AppendLine($block.text)
}
elseif ($block.type -eq 'tool_use') {
$toolCount++
Write-Host " [$($block.name)]" -ForegroundColor DarkCyan
}
}
}
} catch {
if ($line -and $line -notmatch '^\s*[\{\[\}\]"]') {
Write-Host $line -ForegroundColor DarkYellow
[void]$textBuilder.AppendLine($line)
}
}
}
$outputString = $textBuilder.ToString()
# Check for 529 overloaded error
if ($outputString -match "529.*overloaded|overloaded_error") {
$apiOverloaded = $true
$retryCount++
if ($retryCount -ge $maxRetries) {
Write-Host " [ERROR] API overloaded after $maxRetries retries, giving up." -ForegroundColor Red
}
}
# Check for usage limit with cooldown
elseif ($outputString -match "(?i)usage limit reached.*reset at (\d{1,2})(?::(\d{2}))?\s*(am|pm)") {
$resetHour = [int]$Matches[1]
$resetMinute = if ($Matches[2]) { [int]$Matches[2] } else { 0 }
$resetAmPm = $Matches[3]
if ($resetAmPm -ieq "pm" -and $resetHour -ne 12) { $resetHour += 12 }
elseif ($resetAmPm -ieq "am" -and $resetHour -eq 12) { $resetHour = 0 }
$now = Get-Date
$resetTime = Get-Date -Hour $resetHour -Minute $resetMinute -Second 0
if ($resetTime -le $now) { $resetTime = $resetTime.AddDays(1) }
$resetTime = $resetTime.AddMinutes(2)
$waitSeconds = [Math]::Ceiling(($resetTime - $now).TotalSeconds)
$waitMinutes = [Math]::Ceiling($waitSeconds / 60)
Write-Host ""
Write-Host " [USAGE LIMIT] Reset at $($Matches[1]) $resetAmPm. Cooling down ~$waitMinutes minutes (until $($resetTime.ToString('HH:mm')))..." -ForegroundColor Yellow
Start-Sleep -Seconds $waitSeconds
Write-Host " [USAGE LIMIT] Cooldown complete. Retrying..." -ForegroundColor Green
$apiOverloaded = $true
}
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
# Save log
$outputString | Set-Content -Path $logFile -Encoding UTF8
# Show elapsed time
$elapsed = (Get-Date) - $iterStart
Write-Host ""
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
# --- Detect signals ---
$storyComplete = $outputString -match "<story-complete>$([regex]::Escape($currentStory.id))</story-complete>"
$storyBlocked = $outputString -match "<story-blocked>$([regex]::Escape($currentStory.id))</story-blocked>"
$headAfter = git rev-parse HEAD 2>$null
$hasGitChanges = $headAfter -ne $headBefore
# --- Post-iteration typecheck verification ---
$typecheckPassed = $true
if ($storyComplete -and $hasGitChanges -and -not $SkipVerify) {
Write-Host " Verifying typecheck..." -ForegroundColor DarkGray
$typecheckOutput = npm run typecheck 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " [VERIFY FAIL] Typecheck failed after completion signal. Not marking as passed." -ForegroundColor Red
$typecheckPassed = $false
} else {
Write-Host " [VERIFY OK] Typecheck passed." -ForegroundColor DarkGray
}
}
# --- Update story status ---
if ($storyComplete -and $hasGitChanges -and $typecheckPassed) {
# Mark story as passed in prd.json
$prd = Read-Prd
$storyToUpdate = $prd.userStories | Where-Object { $_.id -eq $currentStory.id }
if ($storyToUpdate) {
$storyToUpdate.passes = $true
$storyToUpdate.notes = "Completed iteration $iterationCount at $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Model: $Model."
}
Save-Prd $prd
# Append to progress.txt
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
$el = $elapsed.ToString('mm\:ss')
$progressEntry = "$ts | PASS | $($currentStory.id): $($currentStory.title) | model=$Model elapsed=$el tools=$toolCount"
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
Write-Host " [PASSED] $storyLabel" -ForegroundColor Green
$noProgressCount = 0
$sameErrorCount = 0
$lastErrorSignature = ""
}
elseif ($storyBlocked) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
$progressEntry = "$ts | BLOCKED | $storyLabel"
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
Write-Host " [BLOCKED] $storyLabel - check $logFile for details." -ForegroundColor Red
# Blocked counts as no progress
$noProgressCount++
}
elseif ($storyComplete -and -not $hasGitChanges) {
Write-Host " [WARNING] Completion signaled but no git commits. Retrying story." -ForegroundColor DarkYellow
$noProgressCount++
}
elseif ($storyComplete -and -not $typecheckPassed) {
Write-Host " [WARNING] Completion signaled but typecheck failed. Retrying story." -ForegroundColor DarkYellow
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
$progressEntry = "$ts | TYPECHECK_FAIL | $storyLabel"
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
# Has git changes, so not stalled — but not passed either
$noProgressCount = 0
}
else {
# No completion signal
if ($hasGitChanges) {
Write-Host " [PARTIAL] Git changes but no completion signal. Retrying story." -ForegroundColor DarkYellow
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
$progressEntry = "$ts | PARTIAL | $storyLabel"
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
$noProgressCount = 0
} else {
Write-Host " [NO PROGRESS] No changes and no signal." -ForegroundColor DarkYellow
$noProgressCount++
}
}
# --- Circuit Breaker: No Progress ---
if ($noProgressCount -ge $MaxNoProgress) {
Write-Host ""
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
Write-Host "No meaningful progress for $MaxNoProgress consecutive iterations." -ForegroundColor Red
Write-Host "Stuck on: $($currentStory.id) — $($currentStory.title)" -ForegroundColor Red
Write-Host "Check $logFile for details." -ForegroundColor Red
break
}
# --- Circuit Breaker: Repeated Error ---
$errorLines = $outputString | Select-String -Pattern "(?i)(error|exception|failed|fatal)[:.].*" -AllMatches
if ($errorLines) {
$filteredErrors = $errorLines.Matches | Where-Object { $_.Value -notmatch "529|overloaded" } | Select-Object -First 3
$currentErrorSignature = ($filteredErrors | ForEach-Object { $_.Value }) -join "|"
if ($currentErrorSignature -and $currentErrorSignature -eq $lastErrorSignature) {
$sameErrorCount++
Write-Host " [Circuit Breaker] Same error pattern repeated ($sameErrorCount/$MaxSameError)" -ForegroundColor DarkYellow
if ($sameErrorCount -ge $MaxSameError) {
Write-Host ""
Write-Host "===== CIRCUIT BREAKER: REPEATED ERROR =====" -ForegroundColor Red
Write-Host "Same error for $MaxSameError consecutive iterations:" -ForegroundColor Red
Write-Host " $currentErrorSignature" -ForegroundColor Red
break
}
} elseif ($currentErrorSignature) {
$sameErrorCount = 0
}
$lastErrorSignature = $currentErrorSignature
} else {
$sameErrorCount = 0
$lastErrorSignature = ""
}
# --- Dynamic Model Selection ---
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
$nextModel = $Matches[1]
if ($nextModel -ne $Model) {
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
$Model = $nextModel
}
}
# Brief pause between iterations
Start-Sleep -Seconds 2
}
} finally {
# Cleanup: restore directory, kill dev server
Set-Location $originalDir
if ($devServerPid) {
Write-Host "Stopping dev server (PID $devServerPid)..." -ForegroundColor DarkGray
taskkill /T /F /PID $devServerPid 2>$null | Out-Null
}
}
# --- Final Summary ---
$prd = Read-Prd
$finalPassed = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
$finalTotal = $prd.userStories.Count
Write-Host ""
Write-Host "===========================================" -ForegroundColor Cyan
Write-Host " Ralph Loop finished after $iterationCount iteration(s)" -ForegroundColor Cyan
Write-Host " Stories: $finalPassed/$finalTotal passed" -ForegroundColor Cyan
Write-Host " Branch: $BranchName" -ForegroundColor Cyan
Write-Host " Logs: $logDir" -ForegroundColor Cyan
Write-Host "===========================================" -ForegroundColor Cyan
if ($finalPassed -eq $finalTotal) {
exit 0
} else {
exit 1
}
+13 -1
View File
@@ -4,6 +4,8 @@ import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { useFocusTrap } from '@/hooks/useFocusTrap'
import { DetailPanelContent } from '@/types/pmr'
import type { CardHeaderProps } from './Card'
import { KPIDetail } from './detail/KPIDetail'
import { ConsultationDetail } from './detail/ConsultationDetail'
// Width mapping from content type
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
@@ -207,7 +209,16 @@ export function DetailPanel() {
padding: '24px',
}}
>
{/* Placeholder content - actual renderers will be added in later stories */}
{/* Render content based on type */}
{content.type === 'kpi' && <KPIDetail kpi={content.kpi} />}
{(content.type === 'consultation' || content.type === 'career-role') && (
<ConsultationDetail consultation={content.consultation} />
)}
{/* Other content types - placeholder for future stories */}
{content.type !== 'kpi' &&
content.type !== 'consultation' &&
content.type !== 'career-role' && (
<div
style={{
fontFamily: 'var(--font-ui)',
@@ -222,6 +233,7 @@ export function DetailPanel() {
Content renderers will be implemented in subsequent user stories.
</p>
</div>
)}
</div>
</div>
</>
@@ -0,0 +1,236 @@
import type { Consultation } from '@/types/pmr'
interface ConsultationDetailProps {
consultation: Consultation
}
export function ConsultationDetail({ consultation }: ConsultationDetailProps) {
return (
<div
style={{
fontFamily: 'var(--font-ui)',
display: 'flex',
flexDirection: 'column',
gap: '24px',
}}
>
{/* Role header */}
<div>
<div
style={{
fontSize: '20px',
fontWeight: 700,
color: 'var(--text-primary)',
lineHeight: '1.3',
marginBottom: '4px',
}}
>
{consultation.role}
</div>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: consultation.orgColor,
marginBottom: '8px',
}}
>
{consultation.organization}
</div>
<div
style={{
fontSize: '12px',
fontFamily: 'var(--font-geist)',
color: 'var(--text-tertiary)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>{consultation.duration}</span>
{consultation.isCurrent && (
<span
style={{
padding: '2px 8px',
backgroundColor: 'var(--success-light)',
color: 'var(--success)',
fontSize: '10px',
fontWeight: 600,
borderRadius: 'var(--radius-sm)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Current
</span>
)}
</div>
</div>
{/* History (presenting complaint) */}
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
History
</h3>
<p
style={{
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
margin: 0,
}}
>
{consultation.history}
</p>
</div>
{/* Examination (achievements) */}
{consultation.examination && consultation.examination.length > 0 && (
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
Key Achievements
</h3>
<ul
style={{
margin: 0,
paddingLeft: '20px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{consultation.examination.map((item, index) => (
<li
key={index}
style={{
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}}
>
{item}
</li>
))}
</ul>
</div>
)}
{/* Plan (outcomes) */}
{consultation.plan && consultation.plan.length > 0 && (
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
Outcomes & Impact
</h3>
<ul
style={{
margin: 0,
paddingLeft: '20px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{consultation.plan.map((item, index) => (
<li
key={index}
style={{
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}}
>
{item}
</li>
))}
</ul>
</div>
)}
{/* Coded entries (technical environment / tags) */}
{consultation.codedEntries && consultation.codedEntries.length > 0 && (
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
Coded Entries
</h3>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
}}
>
{consultation.codedEntries.map((entry, index) => (
<div
key={index}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
backgroundColor: 'var(--bg-dashboard)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
}}
>
<span
style={{
fontSize: '11px',
fontFamily: 'var(--font-geist)',
fontWeight: 600,
color: 'var(--accent)',
}}
>
{entry.code}
</span>
<span
style={{
fontSize: '11px',
color: 'var(--text-secondary)',
}}
>
{entry.description}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
+196
View File
@@ -0,0 +1,196 @@
import type { KPI } from '@/types/pmr'
interface KPIDetailProps {
kpi: KPI
}
// Color map for KPI values
const colorMap: Record<KPI['colorVariant'], string> = {
green: '#059669',
amber: '#D97706',
teal: '#0D6E6E',
}
export function KPIDetail({ kpi }: KPIDetailProps) {
// If story exists, render rich content; otherwise fallback to explanation
if (!kpi.story) {
return (
<div
style={{
fontFamily: 'var(--font-ui)',
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}}
>
<div
style={{
fontSize: '32px',
fontWeight: 700,
color: colorMap[kpi.colorVariant],
marginBottom: '16px',
}}
>
{kpi.value}
</div>
<p>{kpi.explanation}</p>
</div>
)
}
const { context, role, outcomes, period } = kpi.story
return (
<div
style={{
fontFamily: 'var(--font-ui)',
display: 'flex',
flexDirection: 'column',
gap: '24px',
}}
>
{/* Headline number */}
<div>
<div
style={{
fontSize: '48px',
fontWeight: 700,
color: colorMap[kpi.colorVariant],
lineHeight: '1',
marginBottom: '8px',
}}
>
{kpi.value}
</div>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
{kpi.label}
</div>
<div
style={{
fontSize: '12px',
fontFamily: 'var(--font-geist)',
color: 'var(--text-tertiary)',
marginTop: '2px',
}}
>
{kpi.sub}
</div>
</div>
{/* Period badge (if present) */}
{period && (
<div
style={{
display: 'inline-block',
padding: '4px 10px',
backgroundColor: 'var(--accent-light)',
color: 'var(--accent)',
fontSize: '11px',
fontWeight: 600,
borderRadius: 'var(--radius-sm)',
fontFamily: 'var(--font-geist)',
alignSelf: 'flex-start',
}}
>
{period}
</div>
)}
{/* Context paragraph */}
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
Context
</h3>
<p
style={{
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
margin: 0,
}}
>
{context}
</p>
</div>
{/* Your role paragraph */}
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
Your Role
</h3>
<p
style={{
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
margin: 0,
}}
>
{role}
</p>
</div>
{/* Outcome bullets */}
<div>
<h3
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}}
>
Key Outcomes
</h3>
<ul
style={{
margin: 0,
paddingLeft: '20px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{outcomes.map((outcome, index) => (
<li
key={index}
style={{
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}}
>
{outcome}
</li>
))}
</ul>
</div>
</div>
)
}
+72 -151
View File
@@ -1,10 +1,8 @@
import React, { useState, useCallback } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Card, CardHeader } from '../Card'
import { documents } from '@/data/documents'
import { consultations } from '@/data/consultations'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
import { useDetailPanel } from '@/contexts/DetailPanelContext'
type ActivityType = 'role' | 'project' | 'cert' | 'edu'
@@ -140,49 +138,46 @@ const dotColorMap: Record<ActivityType, string> = {
edu: '#7C3AED',
}
const borderColorMap: Record<ActivityType, string> = {
role: '#0D6E6E',
project: '#D97706',
cert: '#059669',
edu: '#7C3AED',
}
interface ActivityItemProps {
entry: ActivityEntry
isExpanded: boolean
onToggle: () => void
onItemClick: () => void
}
const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle }) => {
const ActivityItem: React.FC<ActivityItemProps> = ({ entry, onItemClick }) => {
const [isHovered, setIsHovered] = useState(false)
const dotColor = dotColorMap[entry.type]
const isExpandable = entry.type === 'role' && entry.consultationId
const isClickable = entry.type === 'role' && entry.consultationId
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isExpandable) return
if (!isClickable) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && isExpanded) {
e.preventDefault()
onToggle()
onItemClick()
}
},
[isExpandable, isExpanded, onToggle],
[isClickable, onItemClick],
)
// Get consultation data for expanded content
const consultation = isExpandable
// Get consultation data for preview text
const consultation = isClickable
? consultations.find((c) => c.id === entry.consultationId)
: null
// Get preview text (first 1-2 lines from examination)
const previewText =
consultation && consultation.examination.length > 0
? consultation.examination[0]
: null
return (
<div
role={isExpandable ? 'button' : undefined}
tabIndex={isExpandable ? 0 : undefined}
aria-expanded={isExpandable ? isExpanded : undefined}
onClick={isExpandable ? onToggle : undefined}
onKeyDown={isExpandable ? handleKeyDown : undefined}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={isClickable ? onItemClick : undefined}
onKeyDown={isClickable ? handleKeyDown : undefined}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
@@ -190,21 +185,13 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
fontSize: '12px',
transition: 'border-color 0.15s',
cursor: isExpandable ? 'pointer' : 'default',
...(isExpanded && {
borderColor: 'var(--accent-border)',
}),
}}
onMouseEnter={(e) => {
if (isExpandable) {
e.currentTarget.style.borderColor = 'var(--accent-border)'
}
}}
onMouseLeave={(e) => {
if (isExpandable && !isExpanded) {
e.currentTarget.style.borderColor = 'var(--border-light)'
}
transition: 'all 0.15s ease-out',
cursor: isClickable ? 'pointer' : 'default',
transform: isHovered && isClickable ? 'translateY(-1px)' : 'none',
boxShadow: isHovered && isClickable
? '0 2px 8px rgba(26,43,42,0.08)'
: '0 1px 2px rgba(26,43,42,0.05)',
borderColor: isHovered && isClickable ? 'var(--accent-border)' : 'var(--border-light)',
}}
>
{/* Item header row */}
@@ -249,142 +236,76 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle
>
{entry.date}
</div>
</div>
</div>
{/* Expanded content */}
<AnimatePresence initial={false}>
{isExpanded && consultation && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={
prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
}
style={{ overflow: 'hidden' }}
>
{/* Hover preview text for roles */}
{isHovered && previewText && (
<div
style={{
borderLeft: `2px solid ${borderColorMap[entry.type]}`,
marginLeft: '16px',
marginRight: '12px',
marginBottom: '12px',
paddingLeft: '14px',
paddingTop: '4px',
fontSize: '11px',
color: 'var(--text-secondary)',
marginTop: '6px',
lineHeight: 1.4,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{/* Role title */}
<div
style={{
fontSize: '12.5px',
fontWeight: 600,
color: 'var(--accent)',
marginBottom: '8px',
}}
>
{consultation.role}
</div>
{/* Achievement bullets */}
{consultation.examination.length > 0 && (
<ul
style={{
listStyle: 'none',
padding: 0,
margin: '0 0 10px 0',
display: 'flex',
flexDirection: 'column',
gap: '5px',
}}
>
{consultation.examination.map((item, i) => (
<li
key={i}
style={{
display: 'flex',
gap: '8px',
fontSize: '11.5px',
color: 'var(--text-primary)',
lineHeight: 1.45,
}}
>
<span
style={{
color: 'var(--accent)',
opacity: 0.5,
flexShrink: 0,
marginTop: '1px',
}}
>
</span>
{item}
</li>
))}
</ul>
)}
{/* Coded entries */}
{consultation.codedEntries && consultation.codedEntries.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginTop: '4px',
}}
>
{consultation.codedEntries.map((entry) => (
<span
key={entry.code}
style={{
fontSize: '10px',
fontFamily: 'var(--font-mono)',
padding: '2px 6px',
borderRadius: '3px',
background: 'var(--accent-light)',
color: 'var(--accent)',
border: '1px solid var(--accent-border)',
}}
>
{entry.code}
</span>
))}
{previewText}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}
export const CareerActivityTile: React.FC = () => {
const timeline = buildTimeline()
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
const { openPanel } = useDetailPanel()
const handleToggle = useCallback(
(id: string) => {
setExpandedItemId((prev) => (prev === id ? null : id))
const handleItemClick = useCallback(
(entry: ActivityEntry) => {
if (entry.type === 'role' && entry.consultationId) {
const consultation = consultations.find((c) => c.id === entry.consultationId)
if (consultation) {
openPanel({ type: 'career-role', consultation })
}
}
},
[],
[openPanel],
)
return (
<Card full tileId="career-activity">
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
{/* Placeholder for CareerConstellation component (to be added later) */}
<div
style={{
minHeight: '200px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)',
border: '1px dashed var(--border-light)',
marginBottom: '20px',
color: 'var(--text-tertiary)',
fontSize: '12px',
fontStyle: 'italic',
}}
>
Career Constellation visualization (to be implemented)
</div>
<div className="activity-grid">
{timeline.map((entry) => (
<ActivityItem
key={entry.id}
entry={entry}
isExpanded={expandedItemId === entry.id}
onToggle={() => handleToggle(entry.id)}
onItemClick={() => handleItemClick(entry)}
/>
))}
</div>
+123 -28
View File
@@ -1,64 +1,159 @@
import { useState } from 'react'
import { Card, CardHeader } from '../Card'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { documents } from '@/data/documents'
import { educationExtras } from '@/data/educationExtras'
/**
* Education tile - displays academic qualifications
* Full-width card below Career Activity
* Each entry is clickable to open detail panel
*/
export function EducationTile() {
// Education entries from CV, presented in reverse chronological order
const educationEntries = [
{
degree: 'MPharm (Hons) — 2:1',
detail: 'University of East Anglia · 2015',
},
{
degree: 'NHS Leadership Academy — Mary Seacole Programme',
detail: '2018 · 78%',
},
{
degree: 'A-Levels: Mathematics (A*), Chemistry (B), Politics (C)',
detail: 'Highworth Grammar School · 20092011',
},
const { openPanel } = useDetailPanel()
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
// Filter to main education entries in reverse chronological order
const educationDocuments = [
documents.find((d) => d.id === 'doc-mary-seacole')!,
documents.find((d) => d.id === 'doc-mpharm')!,
documents.find((d) => d.id === 'doc-alevels')!,
]
// Look up education extras by document ID
const getExtras = (docId: string) =>
educationExtras.find((e) => e.documentId === docId)
// Build rich inline content for each entry
const getInlineDetails = (doc: (typeof educationDocuments)[0]) => {
const extras = getExtras(doc.id)
switch (doc.id) {
case 'doc-mpharm':
return {
title: 'MPharm (Hons) — 2:1',
institution: 'University of East Anglia',
year: '20112015',
details: [
`Research project: Drug delivery & cocrystals, 75.1% (Distinction)`,
...(extras?.osceScore ? [`4th year OSCE: ${extras.osceScore}`] : []),
],
}
case 'doc-mary-seacole':
return {
title: 'NHS Leadership Academy — Mary Seacole Programme',
institution: 'NHS Leadership Academy',
year: '2018',
details: [
`Programme score: 78%`,
...(extras?.programmeDetail ? [extras.programmeDetail] : []),
],
}
case 'doc-alevels':
return {
title: 'A-Levels',
institution: 'Highworth Grammar School',
year: '20092011',
details: ['Mathematics (A*) · Chemistry (B) · Politics (C)'],
}
default:
return {
title: doc.title,
institution: doc.institution,
year: doc.date,
details: doc.classification ? [doc.classification] : [],
}
}
}
return (
<Card full tileId="education">
<CardHeader dotColor="purple" title="EDUCATION" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{educationEntries.map((entry, index) => (
<div
key={index}
{educationDocuments.map((doc, index) => {
const content = getInlineDetails(doc)
const isHovered = hoveredIndex === index
return (
<button
key={doc.id}
onClick={() => openPanel({ type: 'education', document: doc })}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
style={{
padding: '7px 10px',
padding: '10px 12px',
background: 'var(--surface)',
border: '1px solid var(--border-light)',
border: `1px solid ${isHovered ? 'var(--accent)' : 'var(--border-light)'}`,
borderRadius: 'var(--radius-sm)',
fontSize: '11.5px',
fontSize: '12px',
color: 'var(--text-primary)',
cursor: 'pointer',
textAlign: 'left',
transition: 'border-color 150ms ease-out, box-shadow 150ms ease-out',
boxShadow: isHovered
? '0 2px 8px rgba(26,43,42,0.08)'
: '0 1px 2px rgba(26,43,42,0.05)',
}}
>
<span
<div
style={{
display: 'block',
fontWeight: 600,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '12px',
marginBottom: '4px',
}}
>
{entry.degree}
<span style={{ fontWeight: 600, fontSize: '12.5px' }}>
{content.title}
</span>
<span
style={{
fontFamily: 'var(--font-geist-mono)',
fontSize: '10px',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap',
}}
>
{content.year}
</span>
</div>
<div
style={{
color: 'var(--text-secondary)',
fontSize: '11px',
marginTop: '2px',
display: 'block',
marginBottom: content.details.length > 0 ? '6px' : '0',
}}
>
{entry.detail}
</span>
{content.institution}
</div>
{content.details.length > 0 && (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
{content.details.map((detail, i) => (
<div
key={i}
style={{
color: 'var(--text-tertiary)',
fontSize: '10.5px',
fontFamily: 'var(--font-geist-mono)',
}}
>
{detail}
</div>
))}
</div>
)}
</button>
)
})}
</div>
</Card>
)
}
+62 -1
View File
@@ -1,11 +1,26 @@
import React from 'react'
import { Card, CardHeader } from '../Card'
import { consultations } from '@/data/consultations'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { ChevronRight } from 'lucide-react'
export const LastConsultationTile: React.FC = () => {
const { openPanel } = useDetailPanel()
// Use the most recent consultation (first in array)
const consultation = consultations[0]
const handleOpenPanel = () => {
openPanel({ type: 'consultation', consultation })
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleOpenPanel()
}
}
// Format date to "May 2025" format
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
@@ -33,8 +48,12 @@ export const LastConsultationTile: React.FC = () => {
<Card full tileId="last-consultation">
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
{/* Header info row */}
{/* Header info row - clickable */}
<div
role="button"
tabIndex={0}
onClick={handleOpenPanel}
onKeyDown={handleKeyDown}
style={{
display: 'flex',
flexWrap: 'wrap',
@@ -42,7 +61,19 @@ export const LastConsultationTile: React.FC = () => {
marginBottom: '14px',
paddingBottom: '14px',
borderBottom: '1px solid var(--border-light)',
cursor: 'pointer',
borderRadius: 'var(--radius-sm)',
padding: '8px',
margin: '-8px -8px 14px -8px',
transition: 'background-color 150ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(10,128,128,0.04)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
aria-label={`View full details for ${consultation.role}`}
>
<div>
<div
@@ -158,6 +189,7 @@ export const LastConsultationTile: React.FC = () => {
display: 'flex',
flexDirection: 'column',
gap: '7px',
marginBottom: '16px',
}}
>
{consultation.examination.map((bullet, index) => (
@@ -188,6 +220,35 @@ export const LastConsultationTile: React.FC = () => {
</li>
))}
</ul>
{/* View full record button */}
<button
onClick={handleOpenPanel}
onKeyDown={handleKeyDown}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
fontWeight: 500,
color: 'var(--accent)',
background: 'transparent',
border: 'none',
padding: '6px 0',
cursor: 'pointer',
transition: 'color 150ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--accent)'
}}
aria-label="View full consultation record"
>
<span>View full record</span>
<ChevronRight size={14} strokeWidth={2.5} />
</button>
</Card>
)
}
+67 -3
View File
@@ -1,18 +1,82 @@
import React from 'react'
import { Card, CardHeader } from '../Card'
import { personalStatement } from '@/data/profile'
export function PatientSummaryTile() {
const bodyStyles: React.CSSProperties = {
// Key statistics from CV_v4.md
const highlights = [
{ label: '9+ Years', sublabel: 'Professional Experience' },
{ label: '1.2M', sublabel: 'Population Served' },
{ label: '£220M', sublabel: 'Budget Managed' },
{ label: '£14.6M+', sublabel: 'Savings Identified' },
]
const highlightStripStyles: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '12px',
marginBottom: '20px',
paddingBottom: '20px',
borderBottom: '1px solid var(--border-light)',
}
const highlightItemStyles: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '2px',
}
const highlightValueStyles: React.CSSProperties = {
fontSize: '18px',
fontWeight: 700,
color: 'var(--accent)',
fontFamily: 'var(--font-ui)',
}
const highlightLabelStyles: React.CSSProperties = {
fontSize: '11px',
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.02em',
}
const profileTextStyles: React.CSSProperties = {
fontSize: '13px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}
// Split profile text into structured sections with bold key phrases
const renderProfileWithHierarchy = () => {
return (
<div style={profileTextStyles}>
<strong>Healthcare leader</strong> combining clinical pharmacy expertise with proficiency in{' '}
<strong>Python, SQL, and data analytics</strong>, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently{' '}
<strong>leading population health analytics for NHS Norfolk & Waveney ICB</strong>, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insightsfrom{' '}
<strong>financial scenario modelling</strong> and <strong>pharmaceutical rebate negotiation</strong> to{' '}
<strong>algorithm design</strong> and <strong>population-level pathway development</strong>. Proven track record of identifying and prioritising efficiency programmes worth{' '}
<strong>£14.6M+</strong> through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for{' '}
<strong>executive stakeholders</strong>.
</div>
)
}
return (
<Card full tileId="patient-summary">
<CardHeader dotColor="teal" title="PATIENT SUMMARY" />
<div style={bodyStyles}>{personalStatement}</div>
{/* Highlight strip with key stats */}
<div style={highlightStripStyles}>
{highlights.map((highlight, idx) => (
<div key={idx} style={highlightItemStyles}>
<div style={highlightValueStyles}>{highlight.label}</div>
<div style={highlightLabelStyles}>{highlight.sublabel}</div>
</div>
))}
</div>
{/* Profile text with visual hierarchy through bold key phrases */}
{renderProfileWithHierarchy()}
</Card>
)
}
+21 -155
View File
@@ -1,12 +1,9 @@
import React, { useState, useCallback } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ExternalLink } from 'lucide-react'
import React, { useCallback } from 'react'
import { investigations } from '@/data/investigations'
import { Card, CardHeader } from '../Card'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import type { Investigation } from '@/types/pmr'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const statusColorMap: Record<string, string> = {
Complete: '#059669',
Ongoing: '#0D6E6E',
@@ -15,11 +12,10 @@ const statusColorMap: Record<string, string> = {
interface ProjectItemProps {
project: Investigation
isExpanded: boolean
onToggle: () => void
onClick: () => void
}
function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
function ProjectItem({ project, onClick }: ProjectItemProps) {
const dotColor = statusColorMap[project.status] || '#0D6E6E'
const isLive = project.status === 'Live'
@@ -27,21 +23,17 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && isExpanded) {
e.preventDefault()
onToggle()
onClick()
}
},
[isExpanded, onToggle],
[onClick],
)
return (
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={onToggle}
onClick={onClick}
onKeyDown={handleKeyDown}
style={{
display: 'flex',
@@ -49,30 +41,28 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
background: 'var(--surface)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
padding: '10px 12px',
fontSize: '11.5px',
color: 'var(--text-primary)',
transition: 'border-color 0.15s',
transition: 'border-color 0.15s, box-shadow 0.15s',
cursor: 'pointer',
...(isExpanded && {
borderColor: 'var(--accent-border)',
}),
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
}}
onMouseLeave={(e) => {
if (!isExpanded) {
e.currentTarget.style.borderColor = 'var(--border-light)'
}
e.currentTarget.style.boxShadow = 'none'
}}
>
{/* Item header row */}
{/* Row: status dot + name + year */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
padding: '7px 10px',
marginBottom: '8px',
}}
>
<div
@@ -87,13 +77,12 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
}}
aria-hidden="true"
/>
<span style={{ flex: 1 }}>{project.name}</span>
<span style={{ flex: 1, fontWeight: 500 }}>{project.name}</span>
<span
style={{
fontSize: '10px',
fontFamily: "'Geist Mono', monospace",
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
marginLeft: 'auto',
flexShrink: 0,
}}
>
@@ -101,61 +90,22 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
</span>
</div>
{/* Expanded content */}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={
prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
}
style={{ overflow: 'hidden' }}
>
<div
style={{
borderLeft: '2px solid #D97706',
marginLeft: '14px',
marginRight: '10px',
marginBottom: '10px',
paddingLeft: '12px',
paddingTop: '4px',
}}
>
{/* Methodology */}
{project.methodology && (
<p
style={{
fontSize: '11.5px',
color: 'var(--text-secondary)',
lineHeight: 1.5,
margin: '0 0 10px 0',
}}
>
{project.methodology}
</p>
)}
{/* Tech stack tags */}
{project.techStack && project.techStack.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '5px',
marginBottom: '10px',
gap: '4px',
}}
>
{project.techStack.map((tech) => (
<span
key={tech}
style={{
fontSize: '10px',
fontFamily: 'var(--font-mono)',
padding: '2px 7px',
fontSize: '9px',
fontFamily: 'var(--font-geist-mono)',
padding: '2px 6px',
borderRadius: '3px',
background: 'var(--amber-light)',
color: '#92400E',
@@ -167,95 +117,12 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
))}
</div>
)}
{/* Results */}
{project.results && project.results.length > 0 && (
<ul
style={{
listStyle: 'none',
padding: 0,
margin: '0 0 8px 0',
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
{project.results.map((result, i) => (
<li
key={i}
style={{
display: 'flex',
gap: '8px',
fontSize: '11px',
color: 'var(--text-primary)',
lineHeight: 1.4,
}}
>
<span
style={{
color: '#D97706',
opacity: 0.6,
flexShrink: 0,
marginTop: '1px',
}}
>
</span>
{result}
</li>
))}
</ul>
)}
{/* External link */}
{project.externalUrl && (
<a
href={project.externalUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '5px',
fontSize: '10.5px',
fontWeight: 500,
color: 'var(--accent)',
textDecoration: 'none',
padding: '4px 8px',
borderRadius: '4px',
background: 'var(--accent-light)',
border: '1px solid var(--accent-border)',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(10,128,128,0.14)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--accent-light)'
}}
>
<ExternalLink size={11} />
View Results
</a>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function ProjectsTile() {
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
const handleToggle = useCallback(
(id: string) => {
setExpandedItemId((prev) => (prev === id ? null : id))
},
[],
)
const { openPanel } = useDetailPanel()
return (
<Card tileId="projects">
@@ -266,8 +133,7 @@ export function ProjectsTile() {
<ProjectItem
key={project.id}
project={project}
isExpanded={expandedItemId === project.id}
onToggle={() => handleToggle(project.id)}
onClick={() => openPanel({ type: 'project', investigation: project })}
/>
))}
</div>
+1
View File
@@ -9,6 +9,7 @@ export const educationExtras: EducationExtra[] = [
'Publicity Officer for UEA Alzheimer\'s Society',
],
researchDescription: 'Final year research project investigating cocrystal formation for improved drug delivery properties. Awarded Distinction grade (75.1%).',
osceScore: '80%',
},
{
documentId: 'doc-mary-seacole',
+1
View File
@@ -193,4 +193,5 @@ export interface EducationExtra {
extracurriculars?: string[]
researchDescription?: string
programmeDetail?: string
osceScore?: string
}