Compare commits

..

37 Commits

Author SHA1 Message Date
admin 72c75fd1a9 Depth workflow 2026-02-13 21:45:53 +00:00
admin 7c31ec07ba Update progress: Task 20 completed (accessibility audit) 2026-02-13 18:05:58 +00:00
admin 6a4fc86387 Task 20: Accessibility audit improvements
Semantic HTML:
- Changed Card component from div to article element
- Added id="main-content" to main element for skip link target

Keyboard Navigation & ARIA:
- Added skip link to TopBar (visible only on focus, navigates to #main-content)
- Added aria-label="Active session information" to session info container
- Added aria-hidden="true" to all decorative colored dots (CardHeader, CareerActivity, Projects, Sidebar status badge)
- All expandable items already have role="button", tabIndex={0}, aria-expanded
- All KPI cards already have proper aria-label describing flip state
- Command palette already has full ARIA implementation (combobox, listbox, dialog)

Focus Management:
- Added global focus-visible styles in index.css (2px accent outline, 2px offset)
- Buttons, links, inputs all have proper focus rings with accent color
- Command palette focus trap already implemented

Reduced Motion:
- All components already check prefers-reduced-motion at module scope
- Dashboard entrance, tile expansion, KPI flip, palette animations respect reduced motion
- Added reduced motion override for pulse animation (disables pulse, keeps static dot)

Color Contrast:
- All color tokens already meet WCAG AA standards per ref spec
- Tertiary text (#8DA8A5) used only for supplementary labels where information is conveyed elsewhere

Quality checks: typecheck ✓, lint ✓ (1 pre-existing warning), build ✓
2026-02-13 18:04:52 +00:00
admin 8dc27ff8a9 Update progress: Task 19 completed (responsive design) 2026-02-13 18:01:21 +00:00
admin 29956665ac Task 19: Add responsive design for mobile and tablet
- DashboardLayout: Hide sidebar on <lg (1024px), responsive padding
- Dashboard grid: Mobile-first (1 col → 2 col at md/768px)
- Activity grid: Mobile-first (1 col → 2 col at md/768px)
- TopBar: Truncate brand text on mobile, hide 'Remote' on <md
- TopBar session: Show time-only on <xs (480px)
- CommandPalette: Full-width on mobile with reduced padding
- CommandPalette footer: Hidden on mobile
- Touch targets: All interactive elements 48px+ on mobile

All breakpoints follow Tailwind responsive prefixes (xs/sm/md/lg/xl).
Quality checks: typecheck ✓, lint ✓ (1 pre-existing warning), build ✓

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 18:00:16 +00:00
admin f65bf2ef5c Update progress: Task 18 completed (command palette)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:55:57 +00:00
admin aafdeba93e Task 18: Add command palette (Ctrl+K)
- Create CommandPalette.tsx with overlay, search input, grouped results,
  keyboard navigation (arrows, Enter, Escape), and footer hints
- Rebuild search.ts with PaletteItem model: 24 entries across 6 sections
  (Experience, Core Skills, Active Projects, Achievements, Education,
  Quick Actions) matching concept HTML structure
- Fuzzy search via fuse.js with weighted keys (title, subtitle, keywords)
- Wire into DashboardLayout with global Ctrl+K listener and TopBar click
- Action system: scroll-to-tile, expand-item, external links, download CV
- Add data-tile-id to all Card/tile components for scroll targeting
- CSS animations: palette-overlay-in, palette-modal-in with
  prefers-reduced-motion support
- Maintain backward-compatible legacy exports for ClinicalSidebar
  (will be removed in Task 21)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:54:31 +00:00
admin acee97a579 Update progress: Task 17 completed (KPI flip cards)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:46:26 +00:00
admin 38b8e36fab Task 17: Add KPI flip card interaction
Add click-to-flip interaction on LatestResults metric cards:
- CSS perspective-based 3D flip (400ms ease-in-out)
- Front face shows value/label/sub, back shows explanation text
- Single-card accordion: only one card flipped at a time
- Keyboard accessible: Enter/Space to flip, aria-label with state
- prefers-reduced-motion: instant visibility swap, no 3D animation
- Back face: accent-light background, 12px secondary text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:45:59 +00:00
admin 3ad368f935 Update progress: Task 16 completed (tile expansion system)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:43:09 +00:00
admin d89ae0c64a Task 16: Add tile expansion system
CareerActivity: role items expand to show consultation achievements + coded entries
Projects: items expand to show methodology, tech stack tags, results, external links
CoreSkills: items expand to show prescribing history timeline from medications data

All expansions use:
- Framer Motion AnimatePresence with height-only animation (200ms, ease-out)
- Single-expand accordion (one item at a time per tile)
- Keyboard support (Enter/Space toggle, Escape collapse)
- aria-expanded attributes
- Colored left border on expanded panels
- prefers-reduced-motion support (instant expand/collapse)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:42:21 +00:00
admin 7dae67d954 Update progress: Task 15 completed (ProjectsTile)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:38:07 +00:00
admin 334ea2c02f Task 15: Build ProjectsTile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:37:37 +00:00
admin 2c360176c8 Update progress: Task 14 completed (EducationTile)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:35:24 +00:00
admin 4be1b10137 Task 14: Build EducationTile
- Created EducationTile.tsx with purple CardHeader
- Displays three education entries in vertical stack
- MPharm (Hons) from UEA, NHS Leadership Academy Mary Seacole, A-Levels
- White surface background with light border and 6px radius
- Simple display-only format (no expansion yet)
- Updated DashboardLayout to render EducationTile below CareerActivity

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:34:26 +00:00
admin 905b3d957a Update progress: Task 13 completed (CareerActivity tile) 2026-02-13 17:32:14 +00:00
admin c8032f80df Task 13: Build CareerActivity tile
Created CareerActivityTile component with full timeline merged from multiple data sources:
- Builds 10 activity entries matching the concept HTML spec exactly
- Color-coded dots by type: role (teal), project (amber), cert (green), edu (purple)
- Two-column responsive grid (1 column below 900px)
- Entry types: 4 roles, 2 projects, 3 certifications, 1 education
- Data sources: consultations, investigations, documents
- Sorted newest-first with stable ordering for same-year entries
- Added .activity-grid responsive CSS class to index.css
- Wired into DashboardLayout below LastConsultationTile

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:31:14 +00:00
admin e2409183f3 Update progress: Task 12 completed (LastConsultation tile) 2026-02-13 17:28:16 +00:00
admin d0df9137f9 Task 12: Build LastConsultation tile
Created LastConsultationTile.tsx displaying the most recent role:
- Full-width card with green dot header
- Info row: Date, Organisation, Type (employment), Band
- Role title in accent color
- Bullet list of key achievements from examination array
- Data sourced from consultations[0] (most recent)
- Styling matches ref-06 spec: 8px card radius, border-light,
  info labels 10px uppercase, values 11.5px 600 weight
- Integrated into DashboardLayout in proper sequence

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:27:28 +00:00
admin dec8ec9769 Update progress: Task 11 completed (CoreSkills tile) 2026-02-13 17:25:00 +00:00
admin 6d47f2a948 Task 11: Build CoreSkills tile ("Repeat Medications")
Created CoreSkillsTile component that presents skills as medications
with frequency dosing metaphor:
- Half-width card with amber dot header "REPEAT MEDICATIONS"
- 5 skill items with teal icon containers (lucide-react icons)
- Each item shows: skill name, frequency (e.g., "Twice daily"),
  start year, years of experience, and "Active" status badge
- Uses medication metaphor: "Data Analysis · Twice daily · Since
  2016 · 9 yrs"
- Data from src/data/skills.ts with user-specified frequencies
- Styled to match GP System concept with 6px radius items on
  dashboard background (#F0F5F4)
- Integrated into DashboardLayout in right column next to
  LatestResultsTile

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:24:26 +00:00
admin 5ad67a512f Update progress: Task 10 completed (LatestResults tile)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:22:01 +00:00
admin 040e46cbea Task 10: Build LatestResults tile
- Created LatestResultsTile with 2x2 metric grid displaying four KPIs
- Each MetricCard shows value (22px, colored by variant), label, and sub text
- Metric cards use 6px radius, border-light, dashboard background
- Data sourced from src/data/kpis.ts (Budget £220M, Savings £14.6M, Years 9+, Team 12)
- CardHeader with teal dot + "LATEST RESULTS" + "Updated May 2025" right text
- Added data-kpi-id attributes for Task 17 flip card interaction
- Wired into DashboardLayout as half-width tile (left column)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:21:32 +00:00
admin 6501439cef Update progress: Task 9 completed (PatientSummary tile) 2026-02-13 17:19:17 +00:00
admin 41ddbf6d1d Task 9: Build PatientSummary tile
Created PatientSummaryTile component displaying personal statement from profile.ts.
Full-width card with teal dot header, 13px body text, line-height 1.6.
Wired into DashboardLayout as first tile in grid.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:18:53 +00:00
admin 00a5dd0105 Update progress: Task 8 completed (Card component) 2026-02-13 17:17:02 +00:00
admin 69e322af28 Task 8: Build reusable Card component with CardHeader
- Create Card component with base styling (white bg, 8px radius, shadow-sm)
- Hover state: deepens shadow to shadow-md, strengthens border
- Full-width variant spans both grid columns
- CardHeader sub-component with colored dot, title, optional right text
- Dot colors: teal, amber, green, alert, purple
- Header styling: 12px uppercase title, 10px mono right text
- All styles use CSS custom properties from design tokens
2026-02-13 17:16:06 +00:00
admin a2e01270a1 Update progress: Task 7 completed (DashboardLayout)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:14:23 +00:00
admin adc32b9005 Task 7: Build DashboardLayout and wire up App.tsx
Three-zone layout: TopBar (fixed) + Sidebar (fixed left) + Main
(scrollable card grid). Framer Motion staggered entrance animations
with prefers-reduced-motion support. Card grid responsive at 900px.
Replaces PMRInterface in the pmr phase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:14:18 +00:00
admin a60b0701c2 Update progress: Tasks 5-6 completed (Sidebar component) 2026-02-13 17:10:32 +00:00
admin 670c9cc74c Tasks 5-6: Build Sidebar with PersonHeader, Tags, and Alerts
- Created src/components/Sidebar.tsx:
  - PersonHeader section with 52px avatar, name, title, status badge with pulse animation
  - Details grid: GPhC No. (monospace), Education, Location, Phone (link), Email (link), Registered
  - Tags section with colored pill badges (teal/amber/green variants)
  - Alerts/Highlights section with severity-based styling (alert/amber)
  - Section title component with divider line
  - Custom scrollbar styling (4px, transparent track, border-colored thumb)

- Added animations to src/index.css:
  - @keyframes pulse for status badge dot (opacity 1→0.4→1, 2s infinite)
  - .pmr-scrollbar custom scrollbar styles

Data sources: patient.ts, tags.ts, alerts.ts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:09:56 +00:00
admin 37c08387af Update progress: Task 4 completed (TopBar component)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:07:26 +00:00
admin 62b6718cc3 Task 4: Build TopBar component
Fixed 48px header with three zones — brand (Home icon + name + version),
center search bar (button triggering command palette), and session info
(doctor name + active session pill with live time). Uses GP System
Dashboard tokens. Responsive: search bar hidden on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:07:22 +00:00
admin 2e48cefc6f Update progress: Task 2 completed (data files and types) 2026-02-13 17:04:18 +00:00
admin 2b9a6210ec Task 2: Create new data files and update types
Created five new data files for GP System Dashboard:
- src/data/profile.ts: Personal statement from CV_v4.md
- src/data/tags.ts: Sidebar tags (5 entries with color variants)
- src/data/alerts.ts: Sidebar alert flags (2 entries)
- src/data/kpis.ts: Latest Results metrics (4 KPI entries with explanations)
- src/data/skills.ts: Core technical skills as "medications" (5 entries with user-specified frequencies)

Updated src/types/pmr.ts with new interfaces:
- Tag: label + colorVariant
- Alert: message + severity + icon
- KPI: id + value + label + sub + colorVariant + explanation
- SkillMedication: full medication structure with frequency, years, proficiency, category, status, icon

All CV content matches References/CV_v4.md exactly. All quality checks pass.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:03:45 +00:00
admin c88ceba136 Update progress: Task 1 completed (design tokens)
Also includes manual intervention files: updated CLAUDE.md,
IMPLEMENTATION_PLAN.md, and ref files for GP System Dashboard redesign.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:01:29 +00:00
admin 3176761d9c Task 1: Update design tokens for GP System Dashboard
- Replace old dark PMR palette with light teal GP System tokens
- Add three-tier shadow system (sm/md/lg) with warm green-gray tints
- Update border-radius: cards now 8px, inner elements 6px
- Add layout vars (sidebar-width 272px, topbar-height 48px)
- Fix font-ui/font-ui-alt swap: Elvaro Grotesque is now primary
- Add status color tokens (success, amber, alert, purple) with light/border variants
- Keep legacy --pmr-* aliases for backward compat during transition
- Update pmr Tailwind colors to new palette values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:00:44 +00:00
38 changed files with 7258 additions and 1167 deletions
+241
View File
@@ -0,0 +1,241 @@
---
name: prd
description: "Generate a Product Requirements Document (PRD) for a new feature. Use when planning a feature, starting a new project, or when asked to create a PRD. Triggers on: create a prd, write prd for, plan this feature, requirements for, spec out."
user-invocable: true
---
# PRD Generator
Create detailed Product Requirements Documents that are clear, actionable, and suitable for implementation.
---
## The Job
1. Receive a feature description from the user
2. Ask 3-5 essential clarifying questions (with lettered options)
3. Generate a structured PRD based on answers
4. Save to `tasks/prd-[feature-name].md`
**Important:** Do NOT start implementing. Just create the PRD.
---
## Step 1: Clarifying Questions
Ask only critical questions where the initial prompt is ambiguous. Focus on:
- **Problem/Goal:** What problem does this solve?
- **Core Functionality:** What are the key actions?
- **Scope/Boundaries:** What should it NOT do?
- **Success Criteria:** How do we know it's done?
### Format Questions Like This:
```
1. What is the primary goal of this feature?
A. Improve user onboarding experience
B. Increase user retention
C. Reduce support burden
D. Other: [please specify]
2. Who is the target user?
A. New users only
B. Existing users only
C. All users
D. Admin users only
3. What is the scope?
A. Minimal viable version
B. Full-featured implementation
C. Just the backend/API
D. Just the UI
```
This lets users respond with "1A, 2C, 3B" for quick iteration. Remember to indent the options.
---
## Step 2: PRD Structure
Generate the PRD with these sections:
### 1. Introduction/Overview
Brief description of the feature and the problem it solves.
### 2. Goals
Specific, measurable objectives (bullet list).
### 3. User Stories
Each story needs:
- **Title:** Short descriptive name
- **Description:** "As a [user], I want [feature] so that [benefit]"
- **Acceptance Criteria:** Verifiable checklist of what "done" means
Each story should be small enough to implement in one focused session.
**Format:**
```markdown
### US-001: [Title]
**Description:** As a [user], I want [feature] so that [benefit].
**Acceptance Criteria:**
- [ ] Specific verifiable criterion
- [ ] Another criterion
- [ ] Typecheck/lint passes
- [ ] **[UI stories only]** Verify in browser using dev-browser skill
```
**Important:**
- Acceptance criteria must be verifiable, not vague. "Works correctly" is bad. "Button shows confirmation dialog before deleting" is good.
- **For any story with UI changes:** Always include "Verify in browser using dev-browser skill" as acceptance criteria. This ensures visual verification of frontend work.
### 4. Functional Requirements
Numbered list of specific functionalities:
- "FR-1: The system must allow users to..."
- "FR-2: When a user clicks X, the system must..."
Be explicit and unambiguous.
### 5. Non-Goals (Out of Scope)
What this feature will NOT include. Critical for managing scope.
### 6. Design Considerations (Optional)
- UI/UX requirements
- Link to mockups if available
- Relevant existing components to reuse
### 7. Technical Considerations (Optional)
- Known constraints or dependencies
- Integration points with existing systems
- Performance requirements
### 8. Success Metrics
How will success be measured?
- "Reduce time to complete X by 50%"
- "Increase conversion rate by 10%"
### 9. Open Questions
Remaining questions or areas needing clarification.
---
## Writing for Junior Developers
The PRD reader may be a junior developer or AI agent. Therefore:
- Be explicit and unambiguous
- Avoid jargon or explain it
- Provide enough detail to understand purpose and core logic
- Number requirements for easy reference
- Use concrete examples where helpful
---
## Output
- **Format:** Markdown (`.md`)
- **Location:** `tasks/`
- **Filename:** `prd-[feature-name].md` (kebab-case)
---
## Example PRD
```markdown
# PRD: Task Priority System
## Introduction
Add priority levels to tasks so users can focus on what matters most. Tasks can be marked as high, medium, or low priority, with visual indicators and filtering to help users manage their workload effectively.
## Goals
- Allow assigning priority (high/medium/low) to any task
- Provide clear visual differentiation between priority levels
- Enable filtering and sorting by priority
- Default new tasks to medium priority
## User Stories
### US-001: Add priority field to database
**Description:** As a developer, I need to store task priority so it persists across sessions.
**Acceptance Criteria:**
- [ ] Add priority column to tasks table: 'high' | 'medium' | 'low' (default 'medium')
- [ ] Generate and run migration successfully
- [ ] Typecheck passes
### US-002: Display priority indicator on task cards
**Description:** As a user, I want to see task priority at a glance so I know what needs attention first.
**Acceptance Criteria:**
- [ ] Each task card shows colored priority badge (red=high, yellow=medium, gray=low)
- [ ] Priority visible without hovering or clicking
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
### US-003: Add priority selector to task edit
**Description:** As a user, I want to change a task's priority when editing it.
**Acceptance Criteria:**
- [ ] Priority dropdown in task edit modal
- [ ] Shows current priority as selected
- [ ] Saves immediately on selection change
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
### US-004: Filter tasks by priority
**Description:** As a user, I want to filter the task list to see only high-priority items when I'm focused.
**Acceptance Criteria:**
- [ ] Filter dropdown with options: All | High | Medium | Low
- [ ] Filter persists in URL params
- [ ] Empty state message when no tasks match filter
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
## Functional Requirements
- FR-1: Add `priority` field to tasks table ('high' | 'medium' | 'low', default 'medium')
- FR-2: Display colored priority badge on each task card
- FR-3: Include priority selector in task edit modal
- FR-4: Add priority filter dropdown to task list header
- FR-5: Sort by priority within each status column (high to medium to low)
## Non-Goals
- No priority-based notifications or reminders
- No automatic priority assignment based on due date
- No priority inheritance for subtasks
## Technical Considerations
- Reuse existing badge component with color variants
- Filter state managed via URL search params
- Priority stored in database, not computed
## Success Metrics
- Users can change priority in under 2 clicks
- High-priority tasks immediately visible at top of lists
- No regression in task list performance
## Open Questions
- Should priority affect task ordering within a column?
- Should we add keyboard shortcuts for priority changes?
```
---
## Checklist
Before saving the PRD:
- [ ] Asked clarifying questions with lettered options
- [ ] Incorporated user's answers
- [ ] User stories are small and specific
- [ ] Functional requirements are numbered and unambiguous
- [ ] Non-goals section defines clear boundaries
- [ ] Saved to `tasks/prd-[feature-name].md`
+258
View File
@@ -0,0 +1,258 @@
---
name: ralph
description: "Convert PRDs to prd.json format for the Ralph autonomous agent system. Use when you have an existing PRD and need to convert it to Ralph's JSON format. Triggers on: convert this prd, turn this into ralph format, create prd.json from this, ralph json."
user-invocable: true
---
# Ralph PRD Converter
Converts existing PRDs to the prd.json format that Ralph uses for autonomous execution.
---
## The Job
Take a PRD (markdown file or text) and convert it to `prd.json` in your ralph directory.
---
## Output Format
```json
{
"project": "[Project Name]",
"branchName": "ralph/[feature-name-kebab-case]",
"description": "[Feature description from PRD title/intro]",
"userStories": [
{
"id": "US-001",
"title": "[Story title]",
"description": "As a [user], I want [feature] so that [benefit]",
"acceptanceCriteria": [
"Criterion 1",
"Criterion 2",
"Typecheck passes"
],
"priority": 1,
"passes": false,
"notes": ""
}
]
}
```
---
## Story Size: The Number One Rule
**Each story must be completable in ONE Ralph iteration (one context window).**
Ralph spawns a fresh Amp instance per iteration with no memory of previous work. If a story is too big, the LLM runs out of context before finishing and produces broken code.
### Right-sized stories:
- Add a database column and migration
- Add a UI component to an existing page
- Update a server action with new logic
- Add a filter dropdown to a list
### Too big (split these):
- "Build the entire dashboard" - Split into: schema, queries, UI components, filters
- "Add authentication" - Split into: schema, middleware, login UI, session handling
- "Refactor the API" - Split into one story per endpoint or pattern
**Rule of thumb:** If you cannot describe the change in 2-3 sentences, it is too big.
---
## Story Ordering: Dependencies First
Stories execute in priority order. Earlier stories must not depend on later ones.
**Correct order:**
1. Schema/database changes (migrations)
2. Server actions / backend logic
3. UI components that use the backend
4. Dashboard/summary views that aggregate data
**Wrong order:**
1. UI component (depends on schema that does not exist yet)
2. Schema change
---
## Acceptance Criteria: Must Be Verifiable
Each criterion must be something Ralph can CHECK, not something vague.
### Good criteria (verifiable):
- "Add `status` column to tasks table with default 'pending'"
- "Filter dropdown has options: All, Active, Completed"
- "Clicking delete shows confirmation dialog"
- "Typecheck passes"
- "Tests pass"
### Bad criteria (vague):
- "Works correctly"
- "User can do X easily"
- "Good UX"
- "Handles edge cases"
### Always include as final criterion:
```
"Typecheck passes"
```
For stories with testable logic, also include:
```
"Tests pass"
```
### For stories that change UI, also include:
```
"Verify in browser using dev-browser skill"
```
Frontend stories are NOT complete until visually verified. Ralph will use the dev-browser skill to navigate to the page, interact with the UI, and confirm changes work.
---
## Conversion Rules
1. **Each user story becomes one JSON entry**
2. **IDs**: Sequential (US-001, US-002, etc.)
3. **Priority**: Based on dependency order, then document order
4. **All stories**: `passes: false` and empty `notes`
5. **branchName**: Derive from feature name, kebab-case, prefixed with `ralph/`
6. **Always add**: "Typecheck passes" to every story's acceptance criteria
---
## Splitting Large PRDs
If a PRD has big features, split them:
**Original:**
> "Add user notification system"
**Split into:**
1. US-001: Add notifications table to database
2. US-002: Create notification service for sending notifications
3. US-003: Add notification bell icon to header
4. US-004: Create notification dropdown panel
5. US-005: Add mark-as-read functionality
6. US-006: Add notification preferences page
Each is one focused change that can be completed and verified independently.
---
## Example
**Input PRD:**
```markdown
# Task Status Feature
Add ability to mark tasks with different statuses.
## Requirements
- Toggle between pending/in-progress/done on task list
- Filter list by status
- Show status badge on each task
- Persist status in database
```
**Output prd.json:**
```json
{
"project": "TaskApp",
"branchName": "ralph/task-status",
"description": "Task Status Feature - Track task progress with status indicators",
"userStories": [
{
"id": "US-001",
"title": "Add status field to tasks table",
"description": "As a developer, I need to store task status in the database.",
"acceptanceCriteria": [
"Add status column: 'pending' | 'in_progress' | 'done' (default 'pending')",
"Generate and run migration successfully",
"Typecheck passes"
],
"priority": 1,
"passes": false,
"notes": ""
},
{
"id": "US-002",
"title": "Display status badge on task cards",
"description": "As a user, I want to see task status at a glance.",
"acceptanceCriteria": [
"Each task card shows colored status badge",
"Badge colors: gray=pending, blue=in_progress, green=done",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 2,
"passes": false,
"notes": ""
},
{
"id": "US-003",
"title": "Add status toggle to task list rows",
"description": "As a user, I want to change task status directly from the list.",
"acceptanceCriteria": [
"Each row has status dropdown or toggle",
"Changing status saves immediately",
"UI updates without page refresh",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 3,
"passes": false,
"notes": ""
},
{
"id": "US-004",
"title": "Filter tasks by status",
"description": "As a user, I want to filter the list to see only certain statuses.",
"acceptanceCriteria": [
"Filter dropdown: All | Pending | In Progress | Done",
"Filter persists in URL params",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 4,
"passes": false,
"notes": ""
}
]
}
```
---
## Archiving Previous Runs
**Before writing a new prd.json, check if there is an existing one from a different feature:**
1. Read the current `prd.json` if it exists
2. Check if `branchName` differs from the new feature's branch name
3. If different AND `progress.txt` has content beyond the header:
- Create archive folder: `archive/YYYY-MM-DD-feature-name/`
- Copy current `prd.json` and `progress.txt` to archive
- Reset `progress.txt` with fresh header
**The ralph.sh script handles this automatically** when you run it, but if you are manually updating prd.json between runs, archive first.
---
## Checklist Before Saving
Before writing prd.json, verify:
- [ ] **Previous run archived** (if prd.json exists with different branchName, archive it first)
- [ ] Each story is completable in one iteration (small enough)
- [ ] Stories are ordered by dependency (schema to backend to UI)
- [ ] Every story has "Typecheck passes" as criterion
- [ ] UI stories have "Verify in browser using dev-browser skill" as criterion
- [ ] Acceptance criteria are verifiable (not vague)
- [ ] No story depends on a later story
+588
View File
@@ -0,0 +1,588 @@
{
"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": [
{
"id": "US-001",
"title": "Clean up unused legacy components and hooks",
"description": "As a developer, I need to remove all dead code from the previous PMR interface so the codebase is clean before adding new features. Delete all files listed below and verify no dead imports remain.",
"acceptanceCriteria": [
"Delete src/components/PMRInterface.tsx",
"Delete src/components/PatientBanner.tsx",
"Delete src/components/ClinicalSidebar.tsx",
"Delete src/components/Breadcrumb.tsx",
"Delete src/components/MobileBottomNav.tsx",
"Delete all files in src/components/views/ directory (SummaryView, ConsultationsView, MedicationsView, ProblemsView, InvestigationsView, DocumentsView, ReferralsView) and remove the views/ directory",
"Delete src/components/Contact.tsx, Education.tsx, Experience.tsx, FloatingNav.tsx, Footer.tsx, Hero.tsx, Projects.tsx, Skills.tsx (old portfolio components)",
"Delete src/hooks/useScrollCondensation.ts",
"Delete src/hooks/useActiveSection.ts (will be recreated in a later story)",
"Delete src/hooks/useScrollReveal.ts if unused",
"Verify no remaining files import any of the deleted files (fix any dead imports)",
"npm run build succeeds with zero errors",
"Typecheck passes"
],
"priority": 1,
"passes": false,
"notes": ""
},
{
"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 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 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",
"Add CSS custom properties to :root in src/index.css: --subnav-height: 36px, --panel-narrow: 400px, --panel-wide: 60vw, --backdrop-blur: 4px, --backdrop-bg: rgba(26,43,42,0.15)",
"Add @keyframes panel-slide-in (translateX 100% to 0), panel-slide-out (reverse), backdrop-fade-in (opacity 0 to 1) to src/index.css",
"Add prefers-reduced-motion overrides for all new keyframes (instant, no transform/opacity change)",
"Typecheck passes"
],
"priority": 2,
"passes": false,
"notes": ""
},
{
"id": "US-003",
"title": "Create DetailPanelContext, DetailPanel component, and useFocusTrap hook",
"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)",
"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",
"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",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 3,
"passes": false,
"notes": ""
},
{
"id": "US-004",
"title": "Create SubNav component and useActiveSection hook",
"description": "As a developer, I need a sticky sub-navigation bar below the TopBar for section jumping, plus a hook that tracks which section is visible. Create src/components/SubNav.tsx and src/hooks/useActiveSection.ts (the old one was deleted in cleanup). See Ralph/depth-design.md Section 2.3.",
"acceptanceCriteria": [
"Create src/components/SubNav.tsx with 5 sections: Overview (patient-summary), Skills (core-skills), Experience (career-activity), Projects (projects), Education (education)",
"SubNav is sticky below TopBar (top: 48px, z-index: 99)",
"Height 36px, background var(--surface), bottom border var(--border-light)",
"Tabs: 13px font, weight 500, gap 24px, centered text",
"Active tab: teal underline (2px) with 200ms slide transition, text color var(--accent)",
"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-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": ""
},
{
"id": "US-005",
"title": "Expand skills data from 5 to ~20 with three categories",
"description": "As a developer, I need to expand src/data/skills.ts from 5 skills to ~21 skills across 3 categories. Source content from References/CV_v4.md Core Competencies. Each skill retains the medication metaphor (frequency, status, proficiency). See Ralph/depth-design.md Section 5.1 and Ralph/depth-requirements.md Section 4.4.",
"acceptanceCriteria": [
"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",
"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": ""
},
{
"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.",
"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'}",
"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",
"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",
"Add explanation field to 4th KPI matching the story context",
"Typecheck passes"
],
"priority": 6,
"passes": false,
"notes": ""
},
{
"id": "US-007",
"title": "Create education extras data file",
"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",
"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": ""
},
{
"id": "US-008",
"title": "Restructure DashboardLayout with SubNav, new tile order, and DetailPanel",
"description": "As a developer, I need to update DashboardLayout.tsx to: wrap with DetailPanelProvider, add SubNav between TopBar and content, reorder tiles per the new layout, render DetailPanel, and adjust spacing. See Ralph/depth-design.md Section 3.1.",
"acceptanceCriteria": [
"DashboardLayout (or App.tsx) wraps content with DetailPanelProvider from DetailPanelContext",
"SubNav renders between TopBar and the flex container",
"Content area marginTop accounts for both TopBar and SubNav: calc(var(--topbar-height) + var(--subnav-height))",
"Tile order: PatientSummaryTile (full), LatestResultsTile (half) + ProjectsTile (half) side-by-side, CoreSkillsTile (full), LastConsultationTile (full), CareerActivityTile (full), EducationTile (full)",
"DetailPanel component renders alongside CommandPalette",
"SubNav activeSection state managed via useActiveSection hook",
"All tiles have data-tile-id attributes (Card tileId prop)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 8,
"passes": false,
"notes": ""
},
{
"id": "US-009",
"title": "Create constellation data mapping file",
"description": "As a developer, I need src/data/constellation.ts defining the role-skill mapping for the D3 career constellation graph. Maps 6 career roles to their associated skills with connection strengths. See Ralph/depth-design.md Section 5.3 and 2.4.",
"acceptanceCriteria": [
"Create src/data/constellation.ts with RoleSkillMapping interface (roleId: string, skillIds: string[])",
"Export roleSkillMappings array mapping 6 roles to skill IDs from skills.ts",
"Roles: pre-reg-pharmacist-2015, duty-pharmacy-manager-2016, pharmacy-manager-2017, hcd-pharmacist-2022, deputy-head-2024, interim-head-2025 (IDs should match or reference consultation IDs from consultations.ts)",
"Export constellationNodes array of ConstellationNode objects for all role nodes (with organization, startYear, endYear, orgColor) and skill nodes (with domain)",
"Export constellationLinks array of ConstellationLink objects connecting skills to roles with strength values (0-1)",
"Role orgColors: Paydens gets one color, Tesco another, NHS another (use distinct teal/blue/green tones)",
"Typecheck passes"
],
"priority": 9,
"passes": false,
"notes": ""
},
{
"id": "US-010",
"title": "Modify LatestResultsTile: remove flip, bigger numbers, panel trigger",
"description": "As a developer, I need to redesign the KPI cards in LatestResultsTile.tsx: remove the CSS flip animation, make headline numbers larger and bolder, and make each card clickable to open the detail panel. See Ralph/depth-design.md Section 3.5.",
"acceptanceCriteria": [
"Remove flip card animation entirely (no more .metric-card, .metric-card-inner, .metric-card-front, .metric-card-back CSS classes from index.css if they exist)",
"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",
"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)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 10,
"passes": false,
"notes": ""
},
{
"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.",
"acceptanceCriteria": [
"Card uses full prop (spans both grid columns)",
"Skills grouped by category: Technical, Healthcare Domain (Domain), Strategic & 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 })",
"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": ""
},
{
"id": "US-012",
"title": "Modify ProjectsTile: half width, compact card grid, panel trigger",
"description": "As a developer, I need to change ProjectsTile.tsx from full-width to half-width (positioned alongside LatestResultsTile by the layout reorder in US-008). Compact cards with click to open detail panel. See Ralph/depth-design.md Section 3.6.",
"acceptanceCriteria": [
"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",
"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": ""
},
{
"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.",
"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",
"Make the tile header area also clickable (opens same panel)",
"Keep existing inline content (header info row, achievement bullets)",
"Hover state on clickable areas",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 13,
"passes": false,
"notes": ""
},
{
"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",
"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)",
"Reserve a container/placeholder for CareerConstellation component (will be added later)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 14,
"passes": false,
"notes": ""
},
{
"id": "US-015",
"title": "Modify EducationTile: richer content, panel trigger",
"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",
"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": ""
},
{
"id": "US-016",
"title": "Modify PatientSummaryTile: structured presentation with highlight strip",
"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",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 16,
"passes": false,
"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.",
"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)",
"Graceful fallback if story is undefined (show kpi.explanation instead)",
"Wire into DetailPanel: when content.type === 'kpi', render <KPIDetail kpi={content.kpi} />",
"Styling matches dashboard design system (fonts, colors, spacing)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 17,
"passes": false,
"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.",
"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",
"Styled consistently with dashboard design system",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 18,
"passes": false,
"notes": ""
},
{
"id": "US-019",
"title": "Create ProjectDetail renderer for detail panel",
"description": "As a developer, I need src/components/detail/ProjectDetail.tsx for displaying full project information in the wide detail panel. See Ralph/depth-design.md Section 6.5.",
"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'",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 19,
"passes": false,
"notes": ""
},
{
"id": "US-020",
"title": "Create SkillDetail renderer for detail panel",
"description": "As a developer, I need src/components/detail/SkillDetail.tsx for displaying individual skill detail in the narrow detail panel. See Ralph/depth-design.md Section 6.2.",
"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",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 20,
"passes": false,
"notes": ""
},
{
"id": "US-021",
"title": "Create SkillsAllDetail renderer for detail panel",
"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",
"Category headers styled consistently with CoreSkillsTile category headers",
"Each skill row is clickable → calls openPanel({ type: 'skill', 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",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 21,
"passes": false,
"notes": ""
},
{
"id": "US-022",
"title": "Create EducationDetail renderer for detail panel",
"description": "As a developer, I need src/components/detail/EducationDetail.tsx for displaying full education details including extras. See Ralph/depth-design.md Section 6.6.",
"acceptanceCriteria": [
"Create src/components/detail/EducationDetail.tsx accepting a Document prop",
"Renders: title + institution + dates + classification",
"Imports educationExtras from src/data/educationExtras.ts and finds matching extra by document ID",
"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",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 22,
"passes": false,
"notes": ""
},
{
"id": "US-023",
"title": "Install D3 and scaffold CareerConstellation component",
"description": "As a developer, I need to install d3 as a dependency and create a scaffolded CareerConstellation component with an SVG container. See Ralph/depth-design.md Section 2.4.",
"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>",
"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",
"Subtle radial gradient background from var(--bg-dashboard) center to var(--surface) edge",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 23,
"passes": false,
"notes": ""
},
{
"id": "US-024",
"title": "Build D3 force-directed graph rendering in CareerConstellation",
"description": "As a developer, I need the D3 force simulation to render role and skill nodes with links in the CareerConstellation component. D3 operates imperatively via useEffect on the SVG ref. See Ralph/depth-design.md Section 2.4 for exact force configuration.",
"acceptanceCriteria": [
"D3 force simulation with: forceManyBody(-200), forceLink(distance 80, strength from data), forceX chronological (roles positioned left-to-right by startYear), forceY centered, forceCollide(30)",
"Role nodes: 24px radius circles, filled with orgColor, white text label",
"Skill nodes: 10px radius, color-coded by domain: clinical=var(--success), technical=var(--accent), leadership=var(--amber)",
"Links: thin lines (1px), var(--border) color, opacity 0.3",
"D3 integration: useEffect on SVG ref, no React state for node positions",
"Simulation runs and nodes settle into stable positions",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 24,
"passes": false,
"notes": ""
},
{
"id": "US-025",
"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')",
"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",
"prefers-reduced-motion: disable force simulation animation, render nodes at calculated static positions immediately",
"Typecheck passes"
],
"priority": 25,
"passes": false,
"notes": ""
},
{
"id": "US-026",
"title": "Add hover and click interactions to CareerConstellation",
"description": "As a developer, I need hover highlighting and click-to-panel interactions on the CareerConstellation. This connects the graph to the detail panel system. See Ralph/depth-design.md Section 2.4.",
"acceptanceCriteria": [
"Hover role node: connected skill nodes scale up, links brighten to var(--accent), non-connected nodes fade to 0.15 opacity",
"Hover skill node: all connected role nodes highlight, link paths illuminate",
"Click role node: calls onRoleClick(id) prop",
"Click skill node: calls onSkillClick(id) prop",
"Integrate into CareerActivityTile: wire onRoleClick to open ConsultationDetail panel, onSkillClick to open SkillDetail panel",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 26,
"passes": false,
"notes": ""
},
{
"id": "US-027",
"title": "Restyle LoginScreen with teal accents",
"description": "As a developer, I need to visually refresh the LoginScreen with teal accents replacing the current blue. See Ralph/depth-design.md Section 3.3 and Ralph/depth-requirements.md Section 5.",
"acceptanceCriteria": [
"Replace #005EB8 with #0D6E6E throughout LoginScreen (shield icon bg, active field border, cursor, button)",
"Replace #004D9F with #0A8080 (button hover state)",
"Replace #004494 with #085858 (button pressed state)",
"Background color: keep #1E293B or change to #1A2B2A",
"Login card feels cohesive with the dashboard teal palette",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 27,
"passes": false,
"notes": ""
},
{
"id": "US-028",
"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)",
"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)",
"Text: 10px, font-family var(--font-geist-mono), color var(--text-tertiary)",
"Login button disabled until BOTH typing is complete AND connectionState === 'connected'",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 28,
"passes": false,
"notes": ""
},
{
"id": "US-029",
"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",
"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'",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 29,
"passes": false,
"notes": ""
},
{
"id": "US-030",
"title": "Update CommandPalette for expanded content and panel actions",
"description": "As a developer, I need to update the CommandPalette search index and actions to work with the expanded skills data (~20 skills) and add actions that open the detail panel directly. See Ralph/depth-design.md Section 10, Phase 6.",
"acceptanceCriteria": [
"Search index in src/lib/search.ts includes all ~21 skills (not just the original 5)",
"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",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 30,
"passes": false,
"notes": ""
},
{
"id": "US-031",
"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)",
"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",
"No horizontal overflow at 375px viewport width",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 31,
"passes": false,
"notes": ""
},
{
"id": "US-032",
"title": "Reduced motion audit, final cleanup, and visual review",
"description": "As a developer, I need to verify all new animations respect prefers-reduced-motion, remove any dead code introduced during development, and do a final build verification.",
"acceptanceCriteria": [
"DetailPanel slide animation: instant appear with prefers-reduced-motion",
"Backdrop fade: instant with prefers-reduced-motion",
"SubNav underline transition: instant with prefers-reduced-motion",
"CareerConstellation: static layout (no force simulation animation) with prefers-reduced-motion",
"Connection status dot transition: instant with prefers-reduced-motion",
"Post-login spinner: static indicator with prefers-reduced-motion",
"No dead imports across all files",
"Remove any unused flip card CSS (.metric-card-inner etc.) if still present in index.css",
"npm run build succeeds cleanly",
"npm run typecheck passes with zero errors",
"npm run lint passes (pre-existing AccessibilityContext warning OK)",
"Typecheck passes"
],
"priority": 32,
"passes": false,
"notes": ""
}
]
}
+10
View File
@@ -0,0 +1,10 @@
# Ralph Progress — GP Clinical Record Depth Enhancement
Branch: ralph/depth-enhancement
Stories: 32 (US-001 through US-032)
---
## Status
No iterations completed yet.
+1 -1
View File
@@ -26,7 +26,7 @@ No test framework is configured.
### Four-Phase UI Flow ### Four-Phase UI Flow
`App.tsx` manages a `Phase` state (`'boot'``'ecg'``'login'``'dashboard'`). Each phase renders exclusively: `App.tsx` manages a `Phase` state (`'boot'``'ecg'``'login'``'pmr'`). Each phase renders exclusively:
1. **BootSequence** — Terminal typing animation (~4s), green-on-black aesthetic. Fira Code font, matrix-green palette. **Locked — do not change.** 1. **BootSequence** — Terminal typing animation (~4s), green-on-black aesthetic. Fira Code font, matrix-green palette. **Locked — do not change.**
2. **ECGAnimation** — Canvas-based heartbeat animation with mask-based letter tracing. Background transitions from black to `#1E293B`. **Locked — do not change.** 2. **ECGAnimation** — Canvas-based heartbeat animation with mask-based letter tracing. Background transitions from black to `#1E293B`. **Locked — do not change.**
+105 -105
View File
@@ -2,7 +2,7 @@
## Project Overview ## Project Overview
Replace the "CareerRecord PMR" sidebar-nav + view-switching interface with a tile-based GP System dashboard. Reference design: `References/GPSystemconcept.html`. Replace the "CareerRecord PMR" sidebar-nav + view-switching interface with a tile-based GP System dashboard called "CVMIS" Reference design: `References/GPSystemconcept.html`.
## Quality Checks ## Quality Checks
@@ -22,20 +22,20 @@ Replace the "CareerRecord PMR" sidebar-nav + view-switching interface with a til
#### Task 1: Update design tokens and Tailwind config #### Task 1: Update design tokens and Tailwind config
> Detail: `Ralph/refs/ref-01-design-tokens.md` > Detail: `Ralph/refs/ref-01-design-tokens.md`
- [ ] Update CSS custom properties in `src/index.css` (new palette, shadows, layout vars) - [x] Update CSS custom properties in `src/index.css` (new palette, shadows, layout vars)
- [ ] Update `tailwind.config.js` (colors, shadows, borders, radius) - [x] Update `tailwind.config.js` (colors, shadows, borders, radius)
- [ ] Keep boot/ECG/login tokens unchanged - [x] Keep boot/ECG/login tokens unchanged
- [ ] Run quality checks - [x] Run quality checks
#### Task 2: Create new data files and update types #### Task 2: Create new data files and update types
> Detail: `Ralph/refs/ref-02-data-types.md` > Detail: `Ralph/refs/ref-02-data-types.md`
- [ ] Create `src/data/profile.ts` (personal statement) - [x] Create `src/data/profile.ts` (personal statement)
- [ ] Create `src/data/tags.ts` (sidebar tags) - [x] Create `src/data/tags.ts` (sidebar tags)
- [ ] Create `src/data/alerts.ts` (sidebar alert flags) - [x] Create `src/data/alerts.ts` (sidebar alert flags)
- [ ] Create `src/data/kpis.ts` (Latest Results metrics) - [x] Create `src/data/kpis.ts` (Latest Results metrics)
- [ ] Create `src/data/skills.ts` (skills with medication frequency + years) - [x] Create `src/data/skills.ts` (skills with medication frequency + years)
- [ ] Update `src/types/pmr.ts` (new interfaces) - [x] Update `src/types/pmr.ts` (new interfaces)
- [ ] Run quality checks - [x] Run quality checks
#### Task 3: Update CLAUDE.md for new architecture #### Task 3: Update CLAUDE.md for new architecture
- [x] Already completed during project setup (manual intervention 2026-02-13) - [x] Already completed during project setup (manual intervention 2026-02-13)
@@ -44,153 +44,153 @@ Replace the "CareerRecord PMR" sidebar-nav + view-switching interface with a til
#### Task 4: Build TopBar component #### Task 4: Build TopBar component
> Detail: `Ralph/refs/ref-03-topbar-sidebar.md` (TopBar section) > Detail: `Ralph/refs/ref-03-topbar-sidebar.md` (TopBar section)
- [ ] Create `src/components/TopBar.tsx` - [x] Create `src/components/TopBar.tsx`
- [ ] Brand section (icon + name + version tag) - [x] Brand section (icon + name + version tag)
- [ ] Search bar (triggers command palette, not inline search) - [x] Search bar (triggers command palette, not inline search)
- [ ] Session info (mono font, pill badge) - [x] Session info (mono font, pill badge)
- [ ] Fixed position, 48px height, white bg, bottom border - [x] Fixed position, 48px height, white bg, bottom border
- [ ] Run quality checks - [x] Run quality checks
#### Task 5: Build new Sidebar — PersonHeader #### Task 5: Build new Sidebar — PersonHeader
> Detail: `Ralph/refs/ref-03-topbar-sidebar.md` (Sidebar PersonHeader section) > Detail: `Ralph/refs/ref-03-topbar-sidebar.md` (Sidebar PersonHeader section)
- [ ] Create `src/components/Sidebar.tsx` - [x] Create `src/components/Sidebar.tsx`
- [ ] Avatar circle (52px, teal gradient, initials) - [x] Avatar circle (52px, teal gradient, initials)
- [ ] Name, title, status badge with pulse dot - [x] Name, title, status badge with pulse dot
- [ ] Details grid (GPhC, Education, Location, Phone, Email, Registered) - [x] Details grid (GPhC, Education, Location, Phone, Email, Registered)
- [ ] 272px width, light background, right border - [x] 272px width, light background, right border
- [ ] Run quality checks - [x] Run quality checks
#### Task 6: Build new Sidebar — Tags + Alerts #### Task 6: Build new Sidebar — Tags + Alerts
> Detail: `Ralph/refs/ref-03-topbar-sidebar.md` (Tags and Alerts section) > Detail: `Ralph/refs/ref-03-topbar-sidebar.md` (Tags and Alerts section)
- [ ] Section title component (uppercase, divider line) - [x] Section title component (uppercase, divider line)
- [ ] Tags section (flex wrap pills, color variants) - [x] Tags section (flex wrap pills, color variants)
- [ ] Alerts section (colored flag items with icons) - [x] Alerts section (colored flag items with icons)
- [ ] Run quality checks - [x] Run quality checks
#### Task 7: Build DashboardLayout and wire up App.tsx #### Task 7: Build DashboardLayout and wire up App.tsx
> Detail: `Ralph/refs/ref-04-dashboard-layout.md` > Detail: `Ralph/refs/ref-04-dashboard-layout.md`
- [ ] Create `src/components/DashboardLayout.tsx` - [x] Create `src/components/DashboardLayout.tsx`
- [ ] Three-zone layout: TopBar (fixed) + Sidebar (fixed) + Main (scrollable card grid) - [x] Three-zone layout: TopBar (fixed) + Sidebar (fixed) + Main (scrollable card grid)
- [ ] Card grid: 2 columns desktop, 1 column <900px - [x] Card grid: 2 columns desktop, 1 column <900px
- [ ] Framer Motion entrance animations (topbar → sidebar → content) - [x] Framer Motion entrance animations (topbar → sidebar → content)
- [ ] Update App.tsx: replace PMRInterface with DashboardLayout in PMR phase - [x] Update App.tsx: replace PMRInterface with DashboardLayout in PMR phase
- [ ] Verify boot → ECG → login → dashboard transition works - [x] Verify boot → ECG → login → dashboard transition works
- [ ] Run quality checks - [x] Run quality checks
### Phase 2: Dashboard Tiles ### Phase 2: Dashboard Tiles
#### Task 8: Build reusable Card component #### Task 8: Build reusable Card component
> Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (Card section) > Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (Card section)
- [ ] Create `src/components/Card.tsx` - [x] Create `src/components/Card.tsx`
- [ ] Base card styling (white, border, radius 8px, shadow-sm, hover shadow-md) - [x] Base card styling (white, border, radius 8px, shadow-sm, hover shadow-md)
- [ ] `full` variant (spans both grid columns) - [x] `full` variant (spans both grid columns)
- [ ] CardHeader sub-component (dot + title + optional right text) - [x] CardHeader sub-component (dot + title + optional right text)
- [ ] Run quality checks - [x] Run quality checks
#### Task 9: Build PatientSummary tile #### Task 9: Build PatientSummary tile
> Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (PatientSummary section) > Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (PatientSummary section)
- [ ] Create `src/components/tiles/PatientSummaryTile.tsx` - [x] Create `src/components/tiles/PatientSummaryTile.tsx`
- [ ] Full-width card, first in grid - [x] Full-width card, first in grid
- [ ] Personal statement from `src/data/profile.ts` - [x] Personal statement from `src/data/profile.ts`
- [ ] Run quality checks - [x] Run quality checks
#### Task 10: Build LatestResults tile #### Task 10: Build LatestResults tile
> Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (LatestResults section) > Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (LatestResults section)
- [ ] Create `src/components/tiles/LatestResultsTile.tsx` - [x] Create `src/components/tiles/LatestResultsTile.tsx`
- [ ] Half-width card, 2x2 metric grid - [x] Half-width card, 2x2 metric grid
- [ ] Four KPI metric cards with colored values - [x] Four KPI metric cards with colored values
- [ ] Data from `src/data/kpis.ts` - [x] Data from `src/data/kpis.ts`
- [ ] Run quality checks - [x] Run quality checks
#### Task 11: Build CoreSkills tile ("Repeat Medications") #### Task 11: Build CoreSkills tile ("Repeat Medications")
> Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (CoreSkills section) > Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (CoreSkills section)
- [ ] Create `src/components/tiles/CoreSkillsTile.tsx` - [x] Create `src/components/tiles/CoreSkillsTile.tsx`
- [ ] Half-width card, next to LatestResults - [x] Half-width card, next to LatestResults
- [ ] Skills listed as medications with frequency + years - [x] Skills listed as medications with frequency + years
- [ ] Data from `src/data/skills.ts` - [x] Data from `src/data/skills.ts`
- [ ] Run quality checks - [x] Run quality checks
#### Task 12: Build LastConsultation tile #### Task 12: Build LastConsultation tile
> Detail: `Ralph/refs/ref-06-bottom-tiles.md` (LastConsultation section) > Detail: `Ralph/refs/ref-06-bottom-tiles.md` (LastConsultation section)
- [ ] Create `src/components/tiles/LastConsultationTile.tsx` - [x] Create `src/components/tiles/LastConsultationTile.tsx`
- [ ] Full-width card - [x] Full-width card
- [ ] Header info row (Date, Org, Type, Band) - [x] Header info row (Date, Org, Type, Band)
- [ ] Role title + achievement bullet list - [x] Role title + achievement bullet list
- [ ] Data from first entry in `src/data/consultations.ts` - [x] Data from first entry in `src/data/consultations.ts`
- [ ] Run quality checks - [x] Run quality checks
#### Task 13: Build CareerActivity tile #### Task 13: Build CareerActivity tile
> Detail: `Ralph/refs/ref-06-bottom-tiles.md` (CareerActivity section) > Detail: `Ralph/refs/ref-06-bottom-tiles.md` (CareerActivity section)
- [ ] Create `src/components/tiles/CareerActivityTile.tsx` - [x] Create `src/components/tiles/CareerActivityTile.tsx`
- [ ] Full-width card, two-column activity grid - [x] Full-width card, two-column activity grid
- [ ] Merge roles + projects + certs + education into timeline - [x] Merge roles + projects + certs + education into timeline
- [ ] Color-coded dots by entry type - [x] Color-coded dots by entry type
- [ ] Run quality checks - [x] Run quality checks
#### Task 14: Build Education tile #### Task 14: Build Education tile
> Detail: `Ralph/refs/ref-06-bottom-tiles.md` (Education section) > Detail: `Ralph/refs/ref-06-bottom-tiles.md` (Education section)
- [ ] Create `src/components/tiles/EducationTile.tsx` - [x] Create `src/components/tiles/EducationTile.tsx`
- [ ] Full-width card, below Career Activity - [x] Full-width card, below Career Activity
- [ ] Education entries from documents data - [x] Education entries from documents data
- [ ] Run quality checks - [x] Run quality checks
#### Task 15: Build Projects tile #### Task 15: Build Projects tile
> Detail: `Ralph/refs/ref-06-bottom-tiles.md` (Projects section) > Detail: `Ralph/refs/ref-06-bottom-tiles.md` (Projects section)
- [ ] Create `src/components/tiles/ProjectsTile.tsx` - [x] Create `src/components/tiles/ProjectsTile.tsx`
- [ ] Full-width card, prominent presentation - [x] Full-width card, prominent presentation
- [ ] Status badges, project names, years, descriptions - [x] Status badges, project names, years, descriptions
- [ ] Data from `src/data/investigations.ts` - [x] Data from `src/data/investigations.ts`
- [ ] Run quality checks - [x] Run quality checks
### Phase 3: Interactions ### Phase 3: Interactions
#### Task 16: Tile expansion system #### Task 16: Tile expansion system
> Detail: `Ralph/refs/ref-07-interactions.md` (Tile Expansion section) > Detail: `Ralph/refs/ref-07-interactions.md` (Tile Expansion section)
- [ ] CareerActivity items expand to show full role detail - [x] CareerActivity items expand to show full role detail
- [ ] Projects items expand to show methodology, tech stack, results - [x] Projects items expand to show methodology, tech stack, results
- [ ] CoreSkills items expand to show prescribing history - [x] CoreSkills items expand to show prescribing history
- [ ] Height-only animation (200ms, no opacity fade) - [x] Height-only animation (200ms, no opacity fade)
- [ ] Single-expand accordion - [x] Single-expand accordion
- [ ] Keyboard: Enter/Space to expand, Escape to collapse - [x] Keyboard: Enter/Space to expand, Escape to collapse
- [ ] Run quality checks - [x] Run quality checks
#### Task 17: KPI flip card interaction #### Task 17: KPI flip card interaction
> Detail: `Ralph/refs/ref-07-interactions.md` (KPI Flip section) > Detail: `Ralph/refs/ref-07-interactions.md` (KPI Flip section)
- [ ] LatestResults metrics flip on click - [x] LatestResults metrics flip on click
- [ ] Front: value + label. Back: explanation text - [x] Front: value + label. Back: explanation text
- [ ] CSS perspective flip (400ms) or instant swap with reduced motion - [x] CSS perspective flip (400ms) or instant swap with reduced motion
- [ ] One card flipped at a time - [x] One card flipped at a time
- [ ] Run quality checks - [x] Run quality checks
#### Task 18: Build Command Palette #### Task 18: Build Command Palette
> Detail: `Ralph/refs/ref-07-interactions.md` (Command Palette section) > Detail: `Ralph/refs/ref-07-interactions.md` (Command Palette section)
- [ ] Create `src/components/CommandPalette.tsx` - [x] Create `src/components/CommandPalette.tsx`
- [ ] Ctrl+K trigger + search bar click trigger - [x] Ctrl+K trigger + search bar click trigger
- [ ] Overlay with backdrop blur, ESC to close - [x] Overlay with backdrop blur, ESC to close
- [ ] Fuzzy search via fuse.js (adapt `src/lib/search.ts`) - [x] Fuzzy search via fuse.js (adapt `src/lib/search.ts`)
- [ ] Grouped results by section + Quick Actions - [x] Grouped results by section + Quick Actions
- [ ] Keyboard navigation (arrows, Enter, Escape) - [x] Keyboard navigation (arrows, Enter, Escape)
- [ ] Run quality checks - [x] Run quality checks
### Phase 4: Polish ### Phase 4: Polish
#### Task 19: Responsive design #### Task 19: Responsive design
> Detail: `Ralph/refs/ref-08-polish.md` (Responsive section) > Detail: `Ralph/refs/ref-08-polish.md` (Responsive section)
- [ ] Desktop (>1024px): full sidebar + 2-column grid - [x] Desktop (>1024px): full sidebar + 2-column grid
- [ ] Tablet (7681024px): collapsed/hidden sidebar + adapted grid - [x] Tablet (7681024px): collapsed/hidden sidebar + adapted grid
- [ ] Mobile (<768px): no sidebar, single-column tiles, simplified topbar - [x] Mobile (<768px): no sidebar, single-column tiles, simplified topbar
- [ ] Touch-friendly targets (48px+) - [x] Touch-friendly targets (48px+)
- [ ] Run quality checks - [x] Run quality checks
#### Task 20: Accessibility audit #### Task 20: Accessibility audit
> Detail: `Ralph/refs/ref-08-polish.md` (Accessibility section) > Detail: `Ralph/refs/ref-08-polish.md` (Accessibility section)
- [ ] Semantic HTML (header, nav, main, article, section) - [x] Semantic HTML (header, nav, main, article, section)
- [ ] Keyboard navigation (Tab, Enter/Space, Escape, Ctrl+K, arrows) - [x] Keyboard navigation (Tab, Enter/Space, Escape, Ctrl+K, arrows)
- [ ] ARIA (expanded, controls, labels, live regions, dialog) - [x] ARIA (expanded, controls, labels, live regions, dialog)
- [ ] Focus management (trap in palette, visible rings, return focus) - [x] Focus management (trap in palette, visible rings, return focus)
- [ ] `prefers-reduced-motion` on all animations - [x] `prefers-reduced-motion` on all animations
- [ ] Color contrast verification - [x] Color contrast verification
- [ ] Run quality checks - [x] Run quality checks
#### Task 21: Clean up and final polish #### Task 21: Clean up and final polish
> Detail: `Ralph/refs/ref-08-polish.md` (Cleanup section) > Detail: `Ralph/refs/ref-08-polish.md` (Cleanup section)
+982
View File
@@ -0,0 +1,982 @@
# Component Architecture Design: Adding Depth
> Design document — Feb 2026
> Follows requirements in `Ralph/depth-requirements.md`
> Based on audit of current codebase architecture
---
## 1. Architecture Overview
### Current Component Tree
```
App.tsx (Phase: boot → ecg → login → pmr)
└── AccessibilityProvider
├── BootSequence (locked)
├── ECGAnimation (locked)
├── LoginScreen
└── DashboardLayout
├── TopBar
├── Sidebar
├── Main Content Grid
│ ├── PatientSummaryTile
│ ├── LatestResultsTile + CoreSkillsTile
│ ├── LastConsultationTile
│ ├── CareerActivityTile
│ ├── EducationTile
│ └── ProjectsTile
└── CommandPalette
```
### Proposed Component Tree
```
App.tsx (Phase: boot → ecg → login → pmr)
└── AccessibilityProvider
├── BootSequence (locked)
├── ECGAnimation (locked)
├── LoginScreen ← MODIFIED (visual refresh, a.recruiter, connection status)
└── DetailPanelProvider ← NEW (context for panel state)
└── DashboardLayout ← MODIFIED (sub-nav, new tile order)
├── TopBar ← MODIFIED (session shows a.recruiter)
├── SubNav ← NEW (section jump bar)
├── Sidebar (unchanged)
├── Main Content Grid ← REORDERED
│ ├── PatientSummaryTile ← MODIFIED (CV_v4.md profile)
│ ├── LatestResultsTile ← MODIFIED (bigger numbers, panel trigger)
│ ├── ProjectsTile ← MOVED UP (card grid with thumbnails)
│ ├── CoreSkillsTile ← MOVED, FULL WIDTH (categorised groups)
│ ├── LastConsultationTile ← MODIFIED (panel trigger)
│ ├── CareerActivityTile ← MODIFIED (constellation embedded)
│ │ └── CareerConstellation ← NEW (D3.js force graph)
│ └── EducationTile ← MODIFIED (richer content, panel trigger)
├── DetailPanel ← NEW (slide-in from right)
└── CommandPalette ← UPDATED (new panel actions)
```
---
## 2. New Components
### 2.1 DetailPanel (`src/components/DetailPanel.tsx`)
The primary mechanism for depth. A slide-in panel from the right edge.
**Props Interface:**
```typescript
interface DetailPanelProps {
isOpen: boolean
onClose: () => void
width: 'narrow' | 'wide' // narrow: 400px, wide: 60vw
title: string // Header text
dotColor: CardHeaderProps['dotColor'] // Matches tile dot color
children: React.ReactNode // Content rendered inside
}
```
**Behaviour:**
- Renders a full-screen backdrop (`rgba(26,43,42,0.15)` + `backdrop-filter: blur(4px)`) and a panel div
- Panel slides in from `translateX(100%)``translateX(0)` over 250ms ease-out
- Backdrop fades in over 150ms
- Close: click backdrop, press Escape, or click X button
- Focus trap: first focusable element receives focus on open; Tab cycles within panel; focus returns to trigger element on close
- `aria-modal="true"`, `role="dialog"`, `aria-labelledby` pointing to title
- `prefers-reduced-motion`: skip slide animation, instant appear
**Layout:**
```
┌─────────────────────────────────────────────────┐
│ Blurred backdrop (click to close) │
│ ┌────────────────────────────┐│
│ │ ── X close button ──────── ││
│ │ ││
│ │ [dot] SECTION TITLE ││
│ │ ││
│ │ {children} ││
│ │ ││
│ │ (scrollable) ││
│ │ ││
│ └────────────────────────────┘│
└─────────────────────────────────────────────────┘
```
**CSS Custom Properties to add (index.css):**
```css
--panel-narrow: 400px;
--panel-wide: 60vw;
--backdrop-blur: 4px;
--backdrop-bg: rgba(26,43,42,0.15);
```
**Responsive:**
- On mobile (< 768px), both `narrow` and `wide` become full-width (100vw)
---
### 2.2 DetailPanelContext (`src/contexts/DetailPanelContext.tsx`)
Manages what content is displayed in the detail panel. Any tile can trigger it.
**Interface:**
```typescript
// Union type for all possible detail panel content
type DetailPanelContent =
| { type: 'kpi'; kpi: KPI }
| { type: 'skill'; skill: SkillMedication }
| { type: 'skills-all'; category?: SkillCategory }
| { type: 'consultation'; consultation: Consultation }
| { type: 'project'; investigation: Investigation }
| { type: 'education'; document: Document }
| { type: 'career-role'; consultation: Consultation } // from constellation click
interface DetailPanelContextValue {
content: DetailPanelContent | null
openPanel: (content: DetailPanelContent) => void
closePanel: () => void
isOpen: boolean
}
```
**Width mapping** (deterministic from content type):
```typescript
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
'kpi': 'narrow',
'skill': 'narrow',
'skills-all': 'narrow',
'consultation': 'wide',
'project': 'wide',
'education': 'narrow',
'career-role': 'wide',
}
```
**Title mapping** (from content type + data):
```typescript
function getPanelTitle(content: DetailPanelContent): string {
switch (content.type) {
case 'kpi': return content.kpi.label
case 'skill': return content.skill.name
case 'skills-all': return 'All Medications'
case 'consultation': return content.consultation.role
case 'project': return content.investigation.name
case 'education': return content.document.title
case 'career-role': return content.consultation.role
}
}
```
**Integration:** Wraps `DashboardLayout` in `App.tsx`. The `DetailPanel` component reads from this context and renders the appropriate content.
---
### 2.3 SubNav (`src/components/SubNav.tsx`)
Section jump bar positioned between TopBar and content.
**Props Interface:**
```typescript
interface SubNavProps {
activeSection: string
onSectionClick: (sectionId: string) => void
}
interface NavSection {
id: string
label: string
tileId: string // data-tile-id to scroll to
}
```
**Sections:**
```typescript
const sections: NavSection[] = [
{ id: 'overview', label: 'Overview', tileId: 'patient-summary' },
{ id: 'skills', label: 'Skills', tileId: 'core-skills' },
{ id: 'experience', label: 'Experience', tileId: 'career-activity' },
{ id: 'projects', label: 'Projects', tileId: 'projects' },
{ id: 'education', label: 'Education', tileId: 'education' },
]
```
**Behaviour:**
- Fixed/sticky position below TopBar (top: 48px)
- Click → smooth-scroll to `[data-tile-id="${tileId}"]`
- Active section determined by `useActiveSection` hook (IntersectionObserver on tile elements)
- Active tab: teal underline (2px), text colour shifts to `var(--accent)`
- Inactive tabs: `var(--text-secondary)`
**Style:**
- Height: 36px
- Background: `var(--surface)` with bottom border `var(--border-light)`
- Tabs: 13px, font-weight 500, horizontal gap 24px, centred text
- Teal underline on active (2px, slides with 200ms transition)
- z-index: 99 (below TopBar at 100, above content)
**Existing hook to extend:** `src/hooks/useActiveSection.ts` — currently exists but may need updating to observe the correct tile IDs.
**CSS to add (index.css):**
```css
--subnav-height: 36px;
```
**Layout impact:** `marginTop` on the flex container below TopBar changes from `var(--topbar-height)` to `calc(var(--topbar-height) + var(--subnav-height))`.
---
### 2.4 CareerConstellation (`src/components/CareerConstellation.tsx`)
D3.js force-directed network graph embedded in the CareerActivityTile.
**Props Interface:**
```typescript
interface CareerConstellationProps {
onRoleClick: (consultationId: string) => void
onSkillClick: (skillId: string) => void
}
```
**Data Model:**
```typescript
interface ConstellationNode {
id: string
type: 'role' | 'skill'
label: string
// Role-specific:
organization?: string
startYear?: number
endYear?: number
orgColor?: string
// Skill-specific:
domain?: 'clinical' | 'technical' | 'leadership'
}
interface ConstellationLink {
source: string // node id
target: string // node id
strength: number // 0-1, how strongly connected
}
```
**Node Data (from consultations + skills + new mapping data):**
Role nodes (6, positioned chronologically):
1. Pre-Reg Pharmacist, Paydens (2015-2016)
2. Duty Pharmacy Manager, Tesco (2016-2017)
3. Pharmacy Manager, Tesco (2017-2022)
4. High-Cost Drugs Pharmacist, NHS (2022-2024)
5. Deputy Head, NHS (2024-present)
6. Interim Head, NHS (2025)
Skill nodes (drawn from skills.ts + new expanded skills data):
- Technical: Python, SQL, Power BI, JavaScript/TypeScript, Data Analysis, Algorithm Design, Excel
- Clinical: Medicines Optimisation, Clinical Pathways, Controlled Drugs, NICE TAs, Patient Safety
- Leadership: Budget Management, Team Development, Stakeholder Engagement, Change Management
Links connect skills to the roles where they were used/developed.
**D3 Integration Pattern:**
- Use a `useRef<SVGSVGElement>` to get the SVG container
- D3 operates on the SVG imperatively via `useEffect`
- React handles the wrapper container, D3 handles the graph rendering
- No React state for individual node positions (performance)
- Tooltip/hover state managed via D3 event handlers dispatching to React state for the detail panel
**Force Simulation Configuration:**
```typescript
d3.forceSimulation(nodes)
.force('charge', d3.forceManyBody().strength(-200))
.force('link', d3.forceLink(links).distance(80).strength(d => d.strength))
.force('x', d3.forceX(d => xScale(d.startYear)).strength(0.3)) // chronological
.force('y', d3.forceY(height / 2).strength(0.1))
.force('collision', d3.forceCollide(30))
```
The `forceX` with a time scale ensures roles flow left-to-right chronologically. Skill nodes cluster around their associated roles.
**Visual Design:**
- Role nodes: 24px radius circles, filled with `orgColor`, white text label
- Skill nodes: 10px radius, colour-coded by domain (clinical=`var(--success)`, technical=`var(--accent)`, leadership=`var(--amber)`)
- Links: thin lines (1px), `var(--border)` colour, opacity 0.3
- Hover role: connected skill nodes scale up, links brighten to `var(--accent)`, non-connected nodes fade to 0.15 opacity
- Hover skill: all connected role nodes highlight, link paths illuminate
- Click: dispatches to `onRoleClick` / `onSkillClick` → opens detail panel
**Container:**
- Full width of the CareerActivityTile
- Height: 400px (desktop), 300px (tablet), 250px (mobile)
- Background: subtle radial gradient from `var(--bg-dashboard)` centre to `var(--surface)` edge
- SVG fills the container with viewBox for responsiveness
**New dependency:** `d3` (specifically `d3-force`, `d3-selection`, `d3-scale`, `d3-transition`)
**New data file:** `src/data/constellation.ts` — defines the role-skill mapping:
```typescript
export interface RoleSkillMapping {
roleId: string // matches consultation.id
skillIds: string[] // matches skill IDs
}
export const roleSkillMappings: RoleSkillMapping[] = [
{
roleId: 'duty-pharmacist-2016',
skillIds: ['patient-care', 'medicines-optimisation', 'team-development'],
},
{
roleId: 'pharmacy-manager-2017',
skillIds: ['patient-care', 'medicines-optimisation', 'team-development', 'data-analysis', 'excel', 'change-management', 'budget-management'],
},
// ... etc for all roles
]
```
**Accessibility:**
- `role="img"` on SVG with `aria-label="Career constellation showing roles and skills across career timeline"`
- Screen-reader-only text description of the graph structure
- Keyboard navigation: Tab through role nodes, Enter to open detail panel
- `prefers-reduced-motion`: disable force simulation animation, render static layout
---
## 3. Modified Components
### 3.1 DashboardLayout — Modifications
**Changes:**
1. **Import and render SubNav** between TopBar and content flex container
2. **Reorder tiles:** PatientSummary → LatestResults + Projects → CoreSkills → LastConsultation → CareerActivity → Education
3. **Wrap in DetailPanelProvider** (or this wraps from App.tsx)
4. **Render DetailPanel** alongside CommandPalette
5. **Adjust marginTop** to account for SubNav height
**Updated grid in DashboardLayout:**
```tsx
<div className="dashboard-grid">
<PatientSummaryTile /> {/* full width */}
<LatestResultsTile /> {/* half width (left) */}
<ProjectsTile /> {/* half width (right) — MOVED UP */}
<CoreSkillsTile /> {/* full width — MOVED, was half */}
<LastConsultationTile /> {/* full width */}
<CareerActivityTile /> {/* full width — now includes constellation */}
<EducationTile /> {/* full width */}
</div>
```
**SubNav integration:**
```tsx
<motion.div initial="hidden" animate="visible" variants={topbarVariants}>
<TopBar onSearchClick={handleSearchClick} />
</motion.div>
<SubNav activeSection={activeSection} onSectionClick={handleSectionClick} />
{/* ... rest of layout with adjusted marginTop */}
```
**New CSS variable reference:**
- Content area `marginTop`: `calc(var(--topbar-height) + var(--subnav-height))`
- Content area `height`: `calc(100vh - var(--topbar-height) - var(--subnav-height))`
---
### 3.2 TopBar — Modifications
**Changes:**
1. Session user name: `Dr. A.CHARLWOOD``A.RECRUITER`
2. No structural changes otherwise
**Specific change:**
```tsx
// Line ~172: Change display name
<span ...>A.RECRUITER</span>
```
---
### 3.3 LoginScreen — Modifications
**Changes:**
1. **Username:** `A.CHARLWOOD``A.RECRUITER`
2. **Visual refresh:** Teal accents replacing NHS blue (`#005EB8``var(--accent)` / `#0D6E6E`)
3. **Connection status indicator:** New state machine below the login button
4. **Post-login loading state:** Brief "System loading..." before dashboard materialises
5. **Background colour:** `#1E293B` → consider matching `var(--bg-dashboard)` or a darker variant
**New state additions:**
```typescript
type ConnectionState = 'connecting' | 'connected'
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting')
const [isLoading, setIsLoading] = useState(false) // post-click loading state
```
**Connection status flow:**
1. On mount + 400ms: start typing animation (existing)
2. After ~2000ms: `connectionState` transitions to `'connected'`
3. Login button is disabled until BOTH `typingComplete` AND `connectionState === 'connected'`
4. On login click: `isLoading = true`, show "System loading..." state for ~600ms, then `onComplete()`
**Connection indicator JSX (below login button, above footer):**
```tsx
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '12px' }}>
<div style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: connectionState === 'connected' ? 'var(--success)' : 'var(--alert)',
transition: 'background-color 300ms ease-out',
}} />
<span style={{ fontSize: '10px', fontFamily: 'var(--font-geist-mono)', color: 'var(--text-tertiary)' }}>
{connectionState === 'connected' ? 'Secure connection established' : 'Awaiting secure connection...'}
</span>
</div>
```
**Loading state (replaces card content after click):**
```tsx
{isLoading && (
<div className="flex flex-col items-center gap-3">
<div className="loading-spinner" /> {/* CSS animated spinner */}
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
Loading clinical records...
</span>
</div>
)}
```
**Colour changes throughout LoginScreen:**
- `#005EB8``#0D6E6E` (accent colour for shield icon bg, active field border, cursor, button)
- `#004D9F``#0A8080` (button hover)
- `#004494``#085858` (button pressed)
- Background: `#1E293B` → keep as-is or lighten slightly to `#1A2B2A` (matches `--text-primary`)
---
### 3.4 CoreSkillsTile — Modifications (now full-width, categorised)
**Changes:**
1. **Full width** (add `full` prop to Card)
2. **Categorised display** with 3 groups: Technical, Healthcare Domain, Strategic & Leadership
3. **Show top 3-5 per category** on the dashboard
4. **"View all" button** triggers detail panel with full list
5. **Individual skill click** → detail panel for that skill
**New internal structure:**
```tsx
<Card full tileId="core-skills">
<CardHeader dotColor="amber" title="REPEAT MEDICATIONS" rightText="Active prescriptions" />
{/* Category tabs or grouped sections */}
{categories.map(category => (
<div key={category.id}>
<CategoryHeader label={category.label} count={category.skills.length} />
{category.skills.slice(0, 4).map(skill => (
<SkillItem
key={skill.id}
skill={skill}
onClick={() => openPanel({ type: 'skill', skill })}
/>
))}
{category.skills.length > 4 && (
<ViewMoreButton
count={category.skills.length - 4}
onClick={() => openPanel({ type: 'skills-all', category: category.id })}
/>
)}
</div>
))}
</Card>
```
**CategoryHeader sub-component (inline):**
- Thin divider line with category label
- Styled like sidebar section dividers: 10px, uppercase, tertiary, with extending line
---
### 3.5 LatestResultsTile — Modifications
**Changes:**
1. **Bigger headline numbers** — increase value font size from 22px to 28-32px
2. **Remove flip animation** — replace with click → detail panel
3. **Each KPI card is clickable**`openPanel({ type: 'kpi', kpi })`
4. **Visual enhancement:** stronger contrast, bolder presentation
**KPI card redesign (no more flip):**
```tsx
<button
onClick={() => openPanel({ type: 'kpi', kpi })}
className="text-left w-full"
style={{
padding: '16px',
background: 'var(--surface)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'border-color 150ms, box-shadow 150ms',
}}
>
<div style={{ fontSize: '28px', fontWeight: 700, color: colorMap[kpi.colorVariant] }}>
{kpi.value}
</div>
<div style={{ fontSize: '12px', fontWeight: 500, color: 'var(--text-primary)', marginTop: '4px' }}>
{kpi.label}
</div>
<div style={{ fontSize: '10px', fontFamily: 'var(--font-geist-mono)', color: 'var(--text-tertiary)', marginTop: '2px' }}>
{kpi.sub}
</div>
</button>
```
**CSS cleanup:** Remove `.metric-card`, `.metric-card-inner`, `.metric-card-front`, `.metric-card-back` classes from `index.css` (no longer needed once flip is removed).
---
### 3.6 ProjectsTile — Modifications (now half-width, card grid)
**Changes:**
1. **Half width** (remove `full` prop) — positioned in right column alongside LatestResults
2. **Card grid layout** with thumbnails, title, status, tech tags
3. **Click → detail panel (wide)** for full project info
4. **Compact display** to fit in half-width tile
**New layout:**
```tsx
<Card tileId="projects">
<CardHeader dotColor="amber" title="ACTIVE PROJECTS" rightText="Investigations" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{investigations.map(inv => (
<ProjectCard
key={inv.id}
investigation={inv}
onClick={() => openPanel({ type: 'project', investigation: inv })}
/>
))}
</div>
</Card>
```
**ProjectCard sub-component:**
- Compact row: status dot + name + year (right-aligned)
- Tech stack as small inline tags
- Hover: border colour shift, shadow deepens
- Click: opens wide detail panel
---
### 3.7 CareerActivityTile — Modifications
**Changes:**
1. **Embed CareerConstellation** component within the tile
2. **Timeline items click → detail panel** (instead of in-place accordion)
3. **Extended timeline** back to school (2009)
4. **Hover preview** on timeline items (slight expand with preview text)
**New structure:**
```tsx
<Card full tileId="career-activity">
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
{/* Career Constellation D3 graph */}
<CareerConstellation
onRoleClick={(id) => {
const consultation = consultations.find(c => c.id === id)
if (consultation) openPanel({ type: 'career-role', consultation })
}}
onSkillClick={(id) => {
const skill = allSkills.find(s => s.id === id)
if (skill) openPanel({ type: 'skill', skill })
}}
/>
{/* Existing timeline below */}
<div className="activity-grid" style={{ marginTop: '24px' }}>
{/* ... timeline items, now with click → panel instead of accordion */}
</div>
</Card>
```
---
### 3.8 EducationTile — Modifications
**Changes:**
1. **Richer inline content** — show research project score, OSCE score, A-level grades
2. **Click → detail panel (narrow)** for full education detail
3. Each education entry is a clickable row
---
### 3.9 LastConsultationTile — Modifications
**Changes:**
1. **Click → detail panel (wide)** for full role details
2. Add a "View full record" link/button at the bottom
---
### 3.10 PatientSummaryTile — Modifications
**Changes:**
1. **Content:** Replace current personalStatement with the exact profile text from CV_v4.md
2. **Structured presentation:** Consider pulling highlight stats into a visual strip
The profile.ts data is already the CV_v4.md text, so this may just be a presentation change.
---
## 4. Type System Extensions
### 4.1 New types (`src/types/pmr.ts` additions)
```typescript
// Skill categories for grouped display
export type SkillCategory = 'Technical' | 'Domain' | 'Leadership'
// Extended KPI with story content for detail panel
export interface KPIStory {
context: string // What this number covers
role: string // Your role / what you did
outcomes: string[] // Key decisions or results
period?: string // Time period
}
// Extended KPI type (augment existing)
export interface KPI {
id: string
value: string
label: string
sub: string
colorVariant: 'green' | 'amber' | 'teal'
explanation: string
story?: KPIStory // NEW: rich detail for panel
}
// Constellation-specific types
export interface ConstellationNode {
id: string
type: 'role' | 'skill'
label: string
shortLabel?: string // abbreviated for small nodes
organization?: string
startYear?: number
endYear?: number | null
orgColor?: string
domain?: 'clinical' | 'technical' | 'leadership'
}
export interface ConstellationLink {
source: string
target: string
strength: number
}
// Detail panel content union
export type DetailPanelContent =
| { type: 'kpi'; kpi: KPI }
| { type: 'skill'; skill: SkillMedication }
| { type: 'skills-all'; category?: SkillCategory }
| { type: 'consultation'; consultation: Consultation }
| { type: 'project'; investigation: Investigation }
| { type: 'education'; document: Document }
| { type: 'career-role'; consultation: Consultation }
// Education extras (for detail panel)
export interface EducationExtra {
documentId: string
extracurriculars?: string[]
researchDescription?: string
programmeDetail?: string
}
```
---
## 5. Data Extensions
### 5.1 Extended Skills (`src/data/skills.ts`)
Expand from 5 → ~20 skills across 3 categories. Source: CV_v4.md Core Competencies.
```typescript
// Technical (8 skills)
'data-analysis', 'python', 'sql', 'power-bi', 'javascript-typescript',
'excel', 'algorithm-design', 'data-pipelines'
// Healthcare Domain (6 skills)
'medicines-optimisation', 'population-health', 'nice-ta',
'health-economics', 'clinical-pathways', 'controlled-drugs'
// Strategic & Leadership (7 skills)
'budget-management', 'stakeholder-engagement', 'pharma-negotiation',
'team-development', 'change-management', 'financial-modelling', 'executive-comms'
```
Each retains the medication metaphor: frequency, startYear, yearsOfExperience, proficiency, status.
### 5.2 KPI Stories (`src/data/kpis.ts`)
Add `story` field to each existing KPI:
```typescript
{
id: 'budget',
value: '£220M',
// ... existing fields ...
story: {
context: 'Total prescribing budget for NHS Norfolk & Waveney ICB, covering primary care prescriptions for a population of 1.2 million across the integrated care system.',
role: 'Managed with sophisticated forecasting models, identifying cost pressures and enabling proactive financial planning. Full analytical accountability to ICB board.',
outcomes: [
'Sophisticated forecasting models identifying cost pressures',
'Proactive financial planning enabled across the system',
'Interactive dashboard tracking expenditure in real-time',
],
period: 'Jul 2024 — Present',
},
}
```
### 5.3 Constellation Mapping (`src/data/constellation.ts`)
New file mapping roles to skills for the D3 graph. Defines which skills connect to which roles.
### 5.4 Education Extras (`src/data/educationExtras.ts`)
New file with expanded detail for the education detail panel:
```typescript
export const educationExtras: EducationExtra[] = [
{
documentId: 'doc-mpharm',
extracurriculars: [
'President of UEA Pharmacy Society',
'Secretary & Vice-President of UEA Ultimate Frisbee',
'Publicity Officer for UEA Alzheimer\'s Society',
],
researchDescription: 'Final year research project investigating cocrystal formation for improved drug delivery properties.',
},
{
documentId: 'doc-mary-seacole',
programmeDetail: 'Formal NHS leadership qualification providing theoretical grounding in healthcare leadership approaches, change management, and system-level thinking.',
},
]
```
---
## 6. Detail Panel Content Renderers
The `DetailPanel` component delegates rendering to content-specific sub-components based on `content.type`:
### 6.1 KPIDetail (`src/components/detail/KPIDetail.tsx`)
- Headline number (large, coloured)
- Context paragraph
- "Your role" paragraph
- Outcome bullets
- Period badge
### 6.2 SkillDetail (`src/components/detail/SkillDetail.tsx`)
- Skill name + frequency + status badge
- Proficiency bar
- Years of experience
- Prescribing history timeline (reuse existing pattern from CoreSkillsTile)
- "Used in" section: list of roles that used this skill (from constellation mapping)
### 6.3 SkillsAllDetail (`src/components/detail/SkillsAllDetail.tsx`)
- Full categorised list of all skills
- Grouped by Technical / Healthcare Domain / Strategic & Leadership
- Each skill clickable to switch panel to individual skill detail
### 6.4 ConsultationDetail (`src/components/detail/ConsultationDetail.tsx`)
- Role title + organisation + dates
- History paragraph (from `consultation.history`)
- Achievement bullets (from `consultation.examination`)
- Plan/outcomes (from `consultation.plan`)
- Coded entries badges (from `consultation.codedEntries`)
- Technical environment list
### 6.5 ProjectDetail (`src/components/detail/ProjectDetail.tsx`)
- Project name + year + status
- Methodology description
- Tech stack tags
- Results bullets
- External link button (if available)
### 6.6 EducationDetail (`src/components/detail/EducationDetail.tsx`)
- Title + institution + dates + classification
- Research project description (if MPharm)
- Extracurricular activities
- Programme detail (if Mary Seacole)
- Notes
---
## 7. Hook Modifications
### 7.1 `useActiveSection` (existing, to update)
Currently may observe legacy view IDs. Update to observe the new tile `data-tile-id` attributes and map them to SubNav section IDs:
```typescript
const sectionTileMap: Record<string, string> = {
'patient-summary': 'overview',
'core-skills': 'skills',
'career-activity': 'experience',
'projects': 'projects',
'education': 'education',
}
```
### 7.2 `useFocusTrap` (new hook, `src/hooks/useFocusTrap.ts`)
For the DetailPanel. Traps Tab key focus within the panel when open.
```typescript
export function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean): void
```
---
## 8. New Dependency
```bash
npm install d3 @types/d3
```
Only `d3-force`, `d3-selection`, `d3-scale`, `d3-transition` are needed. Can import selectively:
```typescript
import { forceSimulation, forceManyBody, forceLink, forceX, forceY, forceCollide } from 'd3-force'
import { select } from 'd3-selection'
import { scaleLinear } from 'd3-scale'
```
---
## 9. CSS Additions (`src/index.css`)
```css
/* Sub-nav bar */
--subnav-height: 36px;
/* Detail panel */
--panel-narrow: 400px;
--panel-wide: 60vw;
--backdrop-blur: 4px;
--backdrop-bg: rgba(26,43,42,0.15);
/* Detail panel slide animation */
@keyframes panel-slide-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes panel-slide-out {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
@keyframes backdrop-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes panel-slide-in { from { transform: none; } to { transform: none; } }
@keyframes panel-slide-out { from { transform: none; } to { transform: none; } }
@keyframes backdrop-fade-in { from { opacity: 1; } to { opacity: 1; } }
}
```
---
## 10. Implementation Phases
### Phase 1: Core Infrastructure
1. `DetailPanelContext` + `DetailPanel` component
2. `SubNav` component + `useActiveSection` update
3. `DashboardLayout` restructure (new tile order, SubNav, DetailPanel)
4. `useFocusTrap` hook
5. CSS additions (panel animations, sub-nav height)
### Phase 2: Tile Depth (iterative, per tile)
6. `LatestResultsTile` — remove flip, bigger numbers, panel trigger
7. `CoreSkillsTile` — full width, categorised, expanded data, "view all"
8. `ProjectsTile` — half width, card grid, panel trigger
9. `LastConsultationTile` — panel trigger
10. `CareerActivityTile` — timeline items → panel, hover preview
11. `EducationTile` — richer content, panel trigger
12. `PatientSummaryTile` — structured presentation
### Phase 3: Detail Panel Content
13. `KPIDetail` renderer + KPI stories data
14. `ConsultationDetail` renderer
15. `ProjectDetail` renderer
16. `SkillDetail` + `SkillsAllDetail` renderers
17. `EducationDetail` renderer + extras data
18. Update CommandPalette actions to use detail panel
### Phase 4: Career Constellation
19. Install d3, create `constellation.ts` data mapping
20. Build `CareerConstellation` component (D3 force graph)
21. Integrate into `CareerActivityTile`
22. Hover/click interactions → detail panel
23. Accessibility (keyboard nav, screen reader, reduced-motion)
### Phase 5: Login Refresh
24. Visual restyle (teal accents, fonts, shadows)
25. Username change to `a.recruiter`
26. Connection status indicator (red → green dot)
27. Post-login loading state
28. TopBar session name update
### Phase 6: Polish
29. Responsive testing (mobile: full-width panels, collapsed sub-nav)
30. `prefers-reduced-motion` audit across all new components
31. Command palette updates for new content/actions
32. Search index update for expanded skills data
---
## 11. File Inventory
### New Files (13)
```
src/contexts/DetailPanelContext.tsx
src/components/DetailPanel.tsx
src/components/SubNav.tsx
src/components/CareerConstellation.tsx
src/components/detail/KPIDetail.tsx
src/components/detail/SkillDetail.tsx
src/components/detail/SkillsAllDetail.tsx
src/components/detail/ConsultationDetail.tsx
src/components/detail/ProjectDetail.tsx
src/components/detail/EducationDetail.tsx
src/data/constellation.ts
src/data/educationExtras.ts
src/hooks/useFocusTrap.ts
```
### Modified Files (14)
```
src/App.tsx — wrap DashboardLayout with DetailPanelProvider
src/components/DashboardLayout.tsx — SubNav, tile reorder, DetailPanel render
src/components/TopBar.tsx — session name → A.RECRUITER
src/components/LoginScreen.tsx — visual refresh, connection status, username
src/components/Card.tsx — no changes needed (already supports full prop)
src/components/tiles/LatestResultsTile.tsx — remove flip, bigger numbers, panel
src/components/tiles/CoreSkillsTile.tsx — full width, categorised, view all
src/components/tiles/ProjectsTile.tsx — half width, card grid, panel
src/components/tiles/LastConsultationTile.tsx — add panel trigger
src/components/tiles/CareerActivityTile.tsx — constellation embed, panel triggers
src/components/tiles/EducationTile.tsx — richer content, panel trigger
src/components/tiles/PatientSummaryTile.tsx — structured presentation
src/data/skills.ts — expand to ~20 skills with categories
src/data/kpis.ts — add story fields
src/types/pmr.ts — new types
src/index.css — new CSS vars, animations
src/hooks/useActiveSection.ts — update for new tile IDs
src/lib/search.ts — update palette for new panel actions
package.json — add d3 dependency
```
### Unchanged (locked)
```
src/components/BootSequence.tsx
src/components/ECGAnimation.tsx
```
+300
View File
@@ -0,0 +1,300 @@
# Requirements Specification: Adding Depth to the GP Clinical Record
> Brainstorm session output — Feb 2026
> Source of truth for content: `References/CV_v4.md`
> ATS PDF is supplementary context only, not to be included wholesale.
---
## 1. Problem Statement
The current dashboard feels flat and light on information. Content is thin, sections feel like footnotes rather than showcases, and there's no mechanism to drill into detail. Projects are buried at the bottom. KPI numbers don't hit hard enough. Skills show only 5 items with no way to see more. The whole experience lacks depth, interactivity, and the sense that there's rich content behind every surface.
---
## 2. Core UX Patterns
### 2.1 Right-Side Detail Panel
A slide-in panel from the right edge of the screen — the primary mechanism for depth.
- **Trigger:** "View more" buttons, clickable career items, skills, KPIs, projects
- **Entrance:** Slides in from the right. Dashboard content blurs slightly behind via backdrop-filter.
- **Adaptive width:**
- **Narrow (~400px):** For simple items — individual skills, education entries, single KPI stories
- **Wide (~60% viewport):** For complex items — career roles with achievement lists, projects with screenshots/outcomes/tech stacks
- **Close:** Click outside the panel, press Escape, or click a close button
- **Animation:** 250ms ease-out slide, 150ms backdrop blur transition
- **Accessibility:** Focus trap when open, Escape to close, ARIA role="dialog"
### 2.2 Sub-Navigation Bar
A fixed navigation strip below the TopBar for section jumping.
- **Position:** Immediately below TopBar, above the card grid content area
- **Labels:** `Overview` | `Skills` | `Experience` | `Projects` | `Education`
- **Behaviour:**
- Click → smooth-scroll to that section
- Active tab highlights based on scroll position (IntersectionObserver)
- Sticky — stays visible as user scrolls
- **Style:** Clean, understated. Matches GP system tab row aesthetic. Teal underline for active tab.
### 2.3 Hover + Click Interaction Model
Everything should feel alive and reactive:
- **Hover:** Items lift slightly — shadow deepens, subtle border colour shift. Cursor changes to pointer.
- **Click:** Opens the detail panel with full content for that item.
- **Career activity items:** Expand a small amount inline on hover (preview), then full detail via click → panel.
---
## 3. Revised Dashboard Layout
Tile order changes to prioritise what matters. Projects move up to be prominent. Skills get full width above career history.
```
┌───────────────────────────────────────────────────────┐
│ TopBar (fixed, 48px) │
│ Brand | Search (Ctrl+K) | Session: a.recruiter │
├───────────────────────────────────────────────────────┤
│ Sub-Nav Bar (fixed/sticky) │
│ Overview | Skills | Experience | Projects | Education│
├───────────┬───────────────────────────────────────────┤
│ │ │
│ Sidebar │ 1. Patient Summary (full width) │
│ (272px) │ CV_v4.md profile text │
│ │ │
│ Person │ 2. Latest Results | Projects │
│ Header │ (KPIs, left) | (card grid, right) │
│ │ │
│ Tags │ 3. Repeat Medications (full width) │
│ │ Categorised skill groups │
│ Alerts │ │
│ │ 4. Last Consultation (full width) │
│ │ │
│ │ 5. Career Activity (full width) │
│ │ Timeline + Career Constellation │
│ │ │
│ │ 6. Education (full width) │
│ │ │
└───────────┴───────────────────────────────────────────┘
```
### Changes from current:
- **Projects move from bottom → half-width right column** (row 2, alongside KPIs)
- **Skills move from half-width right → full-width** (row 3, above career history)
- **Sub-nav bar added** between TopBar and content
- **TopBar session info** shows `a.recruiter` post-login
---
## 4. Section Requirements
### 4.1 Patient Summary (Profile)
**Content source:** The `## Profile` section from `CV_v4.md`:
> "Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics, 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 leading population health analytics for NHS Norfolk & Waveney ICB, serving a population of 1.2 million..."
**Presentation:**
- Use the full profile paragraph from CV_v4.md
- Structured — consider pulling out key highlights (years of experience, population served, budget managed) as a visual strip alongside the narrative
- Not a wall of text — break it up with hierarchy
---
### 4.2 Latest Results (KPIs) — Left Column
**Problem:** Current flip cards feel like footnotes. £220M should be a headline.
**Dashboard display:**
- 4 KPI cards with **bold, large headline numbers** — visually dominant
- Stronger contrast, larger type than current implementation
- Each card is clearly clickable
**Click → Detail Panel (narrow):**
- Opens with the **story behind the number**
- Structure per KPI:
- Headline number + label
- Context: what it covers, scope, significance
- Your role: what you did with this number
- Key decisions or outcomes
- Optional: supporting visual (mini chart, comparison, timeline)
**KPIs from CV_v4.md:**
1. **£220M** — Prescribing budget managed with sophisticated forecasting models
2. **£14.6M** — Efficiency programme identified through data analysis; over-target by Oct 2025
3. **9+ Years** — Professional experience (Aug 2016present)
4. **1.2M** — Population served (Norfolk & Waveney ICS)
---
### 4.3 Projects (Investigations) — Right Column (NEW POSITION)
**Problem:** Currently buried at the bottom as small expandable items.
**Dashboard display:**
- Card grid with **thumbnails/screenshots**, title, status badge, tech tags
- Prominent placement alongside KPIs draws immediate attention
- Each card is clickable
**Click → Detail Panel (wide):**
- Full project description
- Outcome metrics and results
- Tech stack with tags
- Live link / GitHub link where available
- Screenshot or demo visual
**Projects from current data:**
1. PharMetrics Interactive Platform (2024, Live) — with external link
2. Patient Switching Algorithm (2025, Complete)
3. Blueteq Generator (2023, Complete)
4. CD Monitoring System (2024, Complete)
5. Sankey Chart Analysis Tool (2023, Complete)
---
### 4.4 Skills / Repeat Medications — Full Width (NEW POSITION)
**Problem:** Only 5 skills, no categorisation, no view more.
**Dashboard display:**
- **Categorised groups** (like BNF chapters in a real formulary):
- **Technical:** Python, SQL, Power BI, JavaScript/TypeScript, Real-world data analysis, Dashboard/tool development, Algorithm design, Data pipeline development
- **Healthcare Domain:** Medicines optimisation, Population health analytics, NICE TA implementation, Health economics & outcomes, Clinical pathway development, Controlled drug assurance
- **Strategic & Leadership:** Budget management (£220M), Stakeholder engagement, Pharmaceutical negotiation, Team development & training, Change management, Financial scenario modelling, Executive communication
- **Display:** Top 3-5 per category visible on the dashboard tile, with medication-style frequency/dosing metaphor
- **"View all" button** per category or for the whole section
**Click → Detail Panel (narrow):**
- Full categorised list of all skills
- Each skill with: proficiency level, years of experience, frequency metaphor (daily, twice daily, when required, etc.)
- Skills are interactive — clicking a skill could show which roles/projects used it
---
### 4.5 Last Consultation — Full Width
**Dashboard display:**
- Most recent role with headline info (title, organisation, dates, type)
- Brief preview of key achievements (2-3 bullets)
**Click → Detail Panel (wide):**
- Full role description from CV_v4.md
- All achievement bullets
- Technical environment
- Coded entries (if applicable)
---
### 4.6 Career Activity + Career Constellation — Full Width
#### Timeline (existing, enhanced)
- Colour-coded timeline: teal (roles), amber (projects), green (certifications), purple (education)
- **Extended back to school (2009)** — Highworth Grammar through university through career
- Role entries expand slightly on hover with preview text
- Click → detail panel for full role information
#### Career Constellation (NEW — D3.js)
- **Embedded within the Career Activity section** as a large visual (not a separate view)
- **Force-directed network graph** built with D3.js:
- **Role nodes:** Large, positioned chronologically left-to-right
- Pre-Registration Pharmacist, Paydens (2015-2016)
- Duty Pharmacy Manager, Tesco (2016-2017)
- Pharmacy Manager, Tesco (2017-2022)
- High-Cost Drugs & Interface Pharmacist, NHS (2022-2024)
- Deputy Head, Population Health & Data Analysis, NHS (2024-present)
- Interim Head, Population Health & Data Analysis, NHS (2025)
- **Skill nodes:** Smaller, orbit around the roles they belong to
- Colour-coded by domain: clinical (green), technical (teal), leadership (amber)
- **Bridge connections:** Skills spanning multiple roles create visible links between eras
- e.g., Python connects Tesco-era self-teaching → NHS data work
- e.g., Clinical pathway knowledge bridges community pharmacy → NHS HCD role
- **Interactions:**
- Hover role → its skill cluster highlights and radiates outward
- Hover skill → all roles that used it illuminate, showing the through-line
- Click role/skill → detail panel
- **Purpose:** Demonstrates D3.js/data-vis capability as portfolio content itself
---
### 4.7 Education — Full Width
**Dashboard display:**
- Education entries with more detail than current:
1. MPharm (Hons) 2:1 — University of East Anglia, 2011-2015
- Research project: Drug delivery & cocrystals, 75.1% (Distinction)
- 4th year OSCE: 80%
2. Mary Seacole Programme — NHS Leadership Academy, 2018, 78%
3. A-Levels — Highworth Grammar School, 2009-2011
- Mathematics (A*), Chemistry (B), Politics (C)
**Click → Detail Panel (narrow):**
- Full education detail including extracurriculars (Pharmacy Society President, Ultimate Frisbee VP, Alzheimer's Society)
- Research project description
- Mary Seacole programme detail (change management, healthcare leadership, system-level thinking)
---
## 5. Login Page Refresh
### 5.1 Visual Overhaul
- Restyle the login card to match the GP dashboard aesthetic:
- Teal accents (not the current colour scheme)
- Elvaro Grotesque font
- Refined shadows matching the three-tier system
- Warm palette cohesive with the dashboard it leads into
- Background should feel like the system's pre-authenticated state
### 5.2 Username Change
- **Username typed:** `a.recruiter` (the recruiter is logging into your clinical records)
- **TopBar post-login:** Session shows `a.recruiter` as the logged-in user
- Password typing remains as dots
### 5.3 "Awaiting Secure Connection" Polish
- Below the login button: a status indicator area
- **Initial state:** Red dot + "Awaiting secure connection..."
- **After ~2 seconds:** Dot transitions to green + "Secure connection established"
- **Login button** becomes clearly interactive only after the green state (was previously greyed/inactive)
- Optional: subtle smart card or security authentication visual cue (e.g., a small chip card icon or lock icon animating)
### 5.4 Post-Login Transition
- On button click: brief "System loading..." state with a clinical-style progress indicator
- Slight delay (500-800ms) to feel purposeful
- Then dashboard materialises with the existing staggered entrance animation (TopBar → Sidebar → Content)
---
## 6. Technical Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Career Constellation | D3.js | Industry standard. Most impressive as portfolio piece. Demonstrates serious data-vis skill. |
| Detail Panel | Custom React component | Slide-in panel with backdrop blur. Adaptive width based on content type. |
| Sub-Nav | IntersectionObserver + scroll | Scroll-spy for active state, smooth scroll on click. |
| Content source | CV_v4.md | Primary source of truth for all factual content. |
---
## 7. Content Source Hierarchy
1. **`References/CV_v4.md`** — Primary source of truth for all roles, dates, achievements, numbers
2. **`References/Andy_Charlwood_CV_ATS_Optimised.pdf`** — Supplementary context only. Do NOT include wholesale. Use only when CV_v4.md lacks specific detail.
3. **`cv-website` data** — Reference for interactivity patterns and content structure, not content itself
---
## 8. What This Specification Does NOT Cover
Per `/sc:brainstorm` boundaries, this document covers requirements only:
- **No architecture decisions** — use `/sc:design` for component architecture
- **No implementation code** — use `/sc:implement` for building
- **No database schemas or API contracts** — N/A (static SPA)
- **No technical specifications beyond requirements** — implementation details deferred
### Recommended Next Steps
1. `/sc:design` — Design component architecture for detail panel, sub-nav, constellation
2. `/sc:workflow` — Generate implementation task breakdown
3. Implementation — Build in phases (core UX patterns → section depth → constellation → login refresh)
+541 -950
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -90,6 +90,27 @@ borderRadius: {
} }
``` ```
## Existing Tokens to Replace/Update
The Tailwind config and CSS already have tokens from the old PMR design. Task 1 needs to UPDATE these, not just add new ones alongside:
**Existing Tailwind shadow tokens (replace with new three-tier system):**
- `pmr`: `'0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03)'` → replace with `pmr-sm`
- `pmr-hover`: `'0 2px 4px rgba(0,0,0,0.06), 0 8px 16px rgba(0,0,0,0.04)'` → replace with `pmr-md`
- `pmr-banner`: `'0 2px 8px rgba(0,0,0,0.12)'` → remove (no banner in new design)
**Existing Tailwind color tokens (keep during transition, Task 21 cleans up):**
- `pmr-nhsblue: '#005EB8'` — keep for login screen (still uses NHS blue)
- `pmr-content: '#F5F7FA'` → update to `pmr-content: '#F0F5F4'` (new bg color)
- `pmr-sidebar: '#1E293B'` → update to `pmr-sidebar: '#F7FAFA'` (light sidebar)
**Existing CSS custom properties (in `--pmr-*` namespace):**
- Previous iterations added `--pmr-*` variables. The new tokens use shorter names (e.g., `--bg`, `--surface`, `--accent`). Add the new tokens AND keep `--pmr-*` aliases during transition so existing components don't break before they're rebuilt.
**Existing border-radius tokens:**
- `card: '4px'` → update to `card: '8px'`
- `login: '12px'` — keep unchanged
## What NOT to Change ## What NOT to Change
- Boot phase variables (`--matrix-*`, `--terminal-*`) - Boot phase variables (`--matrix-*`, `--terminal-*`)
+27
View File
@@ -134,3 +134,30 @@ The login screen has background `#1E293B`. The dashboard has background `#F0F5F4
3. Handle it in App.tsx with a state-based background 3. Handle it in App.tsx with a state-based background
The simplest approach is option 1 — the dashboard's entrance animation effectively replaces the dark login background with the light dashboard. The simplest approach is option 1 — the dashboard's entrance animation effectively replaces the dark login background with the light dashboard.
---
## Established Patterns (from previous iterations)
These patterns were established across 16 iterations of the old PMR build. Reuse them:
### Phase name is `'pmr'`
The Phase type in `src/types/index.ts` is `'boot' | 'ecg' | 'login' | 'pmr'`. The `'pmr'` case renders the dashboard. Do NOT rename the phase — just change what it renders.
### Module-scope `prefersReducedMotion`
All animation components should compute this once at module level, not per render:
```typescript
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
```
This is the established pattern across all existing view components.
### Pre-existing ESLint warning
`AccessibilityContext.tsx` has 1 pre-existing ESLint warning. This is expected — do not attempt to fix it. Quality checks pass with this warning present.
### Callback ref pattern for Framer Motion
If you need a ref to a `motion.*` element (e.g., for scroll detection), use `useState` + callback ref instead of `useRef`. Framer Motion elements may not be in the DOM when `useEffect` first runs:
```typescript
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null)
// On the element: ref={el => { if (el) setScrollContainer(el) }}
```
This avoids null ref issues with animated mount timing.
+14 -3
View File
@@ -68,7 +68,7 @@ When a project item is expanded:
When a skill item is expanded: When a skill item is expanded:
- Show "prescribing history" — a timeline of skill development - Show "prescribing history" — a timeline of skill development
- Source: Can use the existing `medications` data which has `prescribingHistory` entries - **Data source:** `import { medications } from '@/data/medications'` (NOT `skills.ts`). The `medications.ts` file has 18 entries, each with a `prescribingHistory` array of `{ year, description }` entries. Map from `skills.ts` to `medications.ts` by matching skill name to medication name (e.g., "Data Analysis" in skills.ts → find the medication with `name: "Data Analysis"` in medications.ts to get its `prescribingHistory`).
- Format: vertical timeline with year markers and descriptions - Format: vertical timeline with year markers and descriptions
- Timeline dots: accent color, 6px, with connecting line - Timeline dots: accent color, 6px, with connecting line
- Year: mono font, 12px, semibold - Year: mono font, 12px, semibold
@@ -205,8 +205,19 @@ Section label: 10px, 600 weight, uppercase, `letter-spacing: 0.08em`, text-terti
### Fuzzy Search ### Fuzzy Search
Adapt existing `src/lib/search.ts` (fuse.js integration): Adapt existing `src/lib/search.ts` (fuse.js v7.0.0, already installed):
- Rebuild search index to include new data (skills from skills.ts, KPIs, etc.)
**Existing code:** `src/lib/search.ts` has `buildSearchIndex()` which creates a Fuse index from consultations, medications, problems, investigations, and documents. It groups results by `sectionLabel` via `groupResultsBySection()`. The `SearchResult` interface has `{ id, title, section: ViewId, sectionLabel, highlight }`.
**What needs changing:**
- The `section: ViewId` field is designed for view-switching navigation (navigating to `#consultations`, `#medications`, etc.). The new dashboard has no views — it's a single scrollable page. Results should either scroll to the relevant tile or expand an item within a tile.
- Add `skills.ts` data to the index (currently only `medications.ts` is indexed, not the new 5-skill entries)
- Add `kpis.ts` data to the index
- Add Quick Actions (Download CV, Send Email, View LinkedIn, View Projects)
- Update section labels to match palette grouping: "Experience", "Core Skills", "Active Projects", "Achievements", "Education", "Quick Actions"
- Add an `action` field to `SearchResult` so each result knows what to do when selected (scroll to tile, expand item, open link, etc.)
**Config (keep existing):**
- `threshold: 0.3`, weighted keys (title: 2, content: 1) - `threshold: 0.3`, weighted keys (title: 2, content: 1)
- `minMatchCharLength: 2` - `minMatchCharLength: 2`
- Group results by section - Group results by section
+9 -1
View File
@@ -133,8 +133,16 @@ The `src/components/views/` directory contains the old view components. Some may
**Rule: Only remove files that are confirmed unused.** Run a grep for imports before deleting. **Rule: Only remove files that are confirmed unused.** Run a grep for imports before deleting.
### Hooks to Remove ### Hooks to Assess
- `src/hooks/useScrollCondensation.ts` — only used by PatientBanner. If PatientBanner is removed, this can go too. - `src/hooks/useScrollCondensation.ts` — only used by PatientBanner. If PatientBanner is removed, this can go too.
- `src/hooks/useBreakpoint.ts` — may still be useful for responsive tile layouts. Check if any new dashboard component uses it. If not, remove.
### Context to Simplify
- `src/contexts/AccessibilityContext.tsx` — the existing context has `activeView`, `setActiveView`, `expandedItemId`, `setExpandedItem` designed for the old view-switching navigation. With the new single-page dashboard:
- `activeView` / `setActiveView` are no longer relevant (no view switching)
- `expandedItemId` / `setExpandedItem` may still be useful if tiles report their expanded item for accessibility announcements
- Assess whether to simplify the context or remove it entirely and manage expansion state locally in each tile
- **Note:** This context has 1 pre-existing ESLint warning — that's expected.
### Verification Checklist ### Verification Checklist
- [ ] No dead imports (run `npm run lint` — ESLint catches unused imports) - [ ] No dead imports (run `npm run lint` — ESLint catches unused imports)
+773
View File
@@ -0,0 +1,773 @@
# Implementation Workflow: Adding Depth to the GP Clinical Record
> Generated: Feb 2026
> Source: `Ralph/depth-requirements.md` + `Ralph/depth-design.md`
> Prerequisite: Task 21 (cleanup) from current plan should be completed first
---
## Dependency Graph
```
Phase 1: Core Infrastructure
T1 ─── Types & CSS foundations
T2 ─── DetailPanelContext + DetailPanel component ──── depends on T1
T3 ─── useFocusTrap hook ─────────────────────────── depends on T1
T4 ─── SubNav + useActiveSection update ──────────── depends on T1
T5 ─── DashboardLayout restructure ──────────────── depends on T2, T3, T4
Phase 2: Data Expansion
T6 ─── Expand skills.ts (5 → ~20, categorised) ──── depends on T1
T7 ─── Add KPI stories to kpis.ts ────────────────── depends on T1
T8 ─── Create constellation.ts data ──────────────── depends on T6
T9 ─── Create educationExtras.ts ─────────────────── depends on T1
Phase 3: Tile Modifications (parallel where possible)
T10 ─── LatestResultsTile (bigger numbers, panel) ─── depends on T2, T7
T11 ─── CoreSkillsTile (full width, categorised) ──── depends on T2, T6
T12 ─── ProjectsTile (half width, card grid) ──────── depends on T2
T13 ─── LastConsultationTile (panel trigger) ──────── depends on T2
T14 ─── CareerActivityTile (panel triggers, hover) ── depends on T2
T15 ─── EducationTile (richer content, panel) ─────── depends on T2, T9
T16 ─── PatientSummaryTile (structured presentation)─ depends on T5
Phase 4: Detail Panel Renderers
T17 ─── KPIDetail renderer ────────────────────────── depends on T10
T18 ─── ConsultationDetail renderer ───────────────── depends on T13, T14
T19 ─── ProjectDetail renderer ────────────────────── depends on T12
T20 ─── SkillDetail + SkillsAllDetail renderers ───── depends on T11
T21 ─── EducationDetail renderer ──────────────────── depends on T15
Phase 5: Career Constellation (D3.js)
T22 ─── Install d3, scaffold CareerConstellation ──── depends on T8
T23 ─── D3 force graph rendering ──────────────────── depends on T22
T24 ─── Hover/click interactions → detail panel ───── depends on T23, T18, T20
T25 ─── Constellation accessibility ───────────────── depends on T23
Phase 6: Login Refresh
T26 ─── LoginScreen visual restyle ────────────────── independent
T27 ─── Username → a.recruiter + connection status ── depends on T26
T28 ─── Post-login loading state ──────────────────── depends on T27
T29 ─── TopBar session name update ────────────────── depends on T28
Phase 7: Polish & Integration
T30 ─── CommandPalette updates for new content ────── depends on T17-T21
T31 ─── Responsive testing (panels, sub-nav) ──────── depends on T5, T2
T32 ─── prefers-reduced-motion audit ──────────────── depends on all
T33 ─── Final visual review + cleanup ─────────────── depends on all
```
---
## Pre-Flight: Complete Task 21 (Cleanup)
Before starting depth work, the current plan's final task must be done:
- [ ] Remove unused old components (PatientBanner, ClinicalSidebar, Breadcrumb, MobileBottomNav, PMRInterface)
- [ ] Remove old view files (`src/components/views/*.tsx`)
- [ ] Remove old portfolio components (Contact, Education, Experience, FloatingNav, Footer, Hero, Projects, Skills)
- [ ] Remove unused hooks (useScrollCondensation if unused)
- [ ] Verify no dead imports
- [ ] `npm run build` clean
**Checkpoint:** Clean build with zero unused components.
---
## Phase 1: Core Infrastructure
### Task 1: Types & CSS Foundations
**Files:** `src/types/pmr.ts`, `src/index.css`
**Effort:** Small
Add all new TypeScript types and CSS custom properties needed by subsequent tasks.
**Types to add (`src/types/pmr.ts`):**
- `SkillCategory``'Technical' | 'Domain' | 'Leadership'`
- `KPIStory` — context, role, outcomes[], period
- Augment `KPI` with optional `story?: KPIStory`
- `ConstellationNode` — id, type, label, domain, org data
- `ConstellationLink` — source, target, strength
- `DetailPanelContent` — discriminated union (kpi | skill | skills-all | consultation | project | education | career-role)
- `EducationExtra` — documentId, extracurriculars, researchDescription, programmeDetail
- Add `category?: SkillCategory` field to `SkillMedication`
**CSS to add (`src/index.css`):**
```css
--subnav-height: 36px;
--panel-narrow: 400px;
--panel-wide: 60vw;
--backdrop-blur: 4px;
--backdrop-bg: rgba(26,43,42,0.15);
```
Plus panel animation keyframes (`panel-slide-in`, `panel-slide-out`, `backdrop-fade-in`) with `prefers-reduced-motion` overrides.
**Validation:** `npm run typecheck` passes.
---
### Task 2: DetailPanelContext + DetailPanel Component
**New files:** `src/contexts/DetailPanelContext.tsx`, `src/components/DetailPanel.tsx`
**Depends on:** T1
**Effort:** Medium
**DetailPanelContext (`src/contexts/DetailPanelContext.tsx`):**
- `DetailPanelContextValue`: `{ content, openPanel, closePanel, isOpen }`
- `DetailPanelProvider` wraps children, manages state
- Width mapping: deterministic from `content.type` (narrow for kpi/skill/education, wide for consultation/project/career-role)
- Title mapping: derived from content data
**DetailPanel (`src/components/DetailPanel.tsx`):**
- Full-screen backdrop (`backdrop-filter: blur(4px)`, click to close)
- Panel slides from right (`translateX(100%)``translateX(0)`, 250ms ease-out)
- Adaptive width: `var(--panel-narrow)` or `var(--panel-wide)` based on content type
- Header: close button (X, lucide `X` icon) + dot + section title
- Scrollable content area renders `{children}` (or delegates to content renderers)
- Close triggers: backdrop click, Escape key, X button
- `aria-modal="true"`, `role="dialog"`, `aria-labelledby`
- Mobile: both widths become 100vw
- `prefers-reduced-motion`: instant appear, no slide
**Integration:** Initially renders placeholder content ("Detail panel for {type}"). Real content renderers come in Phase 4.
**Validation:** Panel opens/closes correctly with keyboard and mouse. `npm run typecheck` + `npm run build`.
---
### Task 3: useFocusTrap Hook
**New file:** `src/hooks/useFocusTrap.ts`
**Depends on:** T1
**Effort:** Small
- `useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean): void`
- When active: Tab/Shift+Tab cycle within container, first focusable element receives focus
- When deactivated: focus returns to the element that was focused before trap activated
- Used by DetailPanel (and already used by CommandPalette — consider if CommandPalette can share this hook)
**Validation:** Tab cycling confirmed in DetailPanel. Focus returns correctly on close.
---
### Task 4: SubNav + useActiveSection Update
**New file:** `src/components/SubNav.tsx`
**Modified file:** `src/hooks/useActiveSection.ts`
**Depends on:** T1
**Effort:** Medium
**SubNav component:**
- Fixed/sticky below TopBar (`top: 48px`, `z-index: 99`)
- 5 sections: Overview | Skills | Experience | Projects | Education
- Click → smooth-scroll to `[data-tile-id="${tileId}"]`
- Active tab: teal underline (2px), text colour `var(--accent)`
- Inactive: `var(--text-secondary)`
- Height: 36px, background `var(--surface)`, bottom border
- Tabs: 13px, font-weight 500, gap 24px
- Teal underline slides with 200ms transition
**useActiveSection update:**
- Observe `data-tile-id` attributes on tile elements
- Map tile IDs to section IDs (patient-summary→overview, core-skills→skills, etc.)
- Use IntersectionObserver with appropriate thresholds
**Tile `data-tile-id` attributes:** Ensure each tile's Card has this attribute. May need to add `tileId` prop to Card if not already present.
**Validation:** Scroll triggers correct active tab. Click scrolls to correct section. `npm run build`.
---
### Task 5: DashboardLayout Restructure
**Modified file:** `src/components/DashboardLayout.tsx`
**Depends on:** T2, T3, T4
**Effort:** Medium
**Changes:**
1. Wrap with `DetailPanelProvider` (in App.tsx or DashboardLayout)
2. Add `SubNav` between TopBar and content
3. Reorder tiles:
- PatientSummaryTile (full width)
- LatestResultsTile (half) + ProjectsTile (half) — side by side
- CoreSkillsTile (full width) — was half, now full
- LastConsultationTile (full width)
- CareerActivityTile (full width)
- EducationTile (full width)
4. Render `DetailPanel` alongside CommandPalette
5. Adjust margin-top: `calc(var(--topbar-height) + var(--subnav-height))`
6. Add `data-tile-id` attributes to tile wrappers
**Validation:** Layout renders correctly with new tile order. SubNav visible. Detail panel renders. No visual regressions. `npm run build`.
**Checkpoint:** Core infrastructure complete. Detail panel opens (with placeholder content), sub-nav works, new tile order in place.
---
## Phase 2: Data Expansion
### Task 6: Expand Skills Data
**Modified file:** `src/data/skills.ts`
**Depends on:** T1
**Effort:** Medium
Expand from 5 → ~20 skills across 3 categories. Each skill retains the medication metaphor.
**Categories:**
- **Technical (8):** Data Analysis, Python, SQL, Power BI, JavaScript/TypeScript, Excel, Algorithm Design, Data Pipelines
- **Healthcare Domain (6):** Medicines Optimisation, Population Health, NICE TA Implementation, Health Economics, Clinical Pathways, Controlled Drugs
- **Strategic & Leadership (7):** Budget Management, Stakeholder Engagement, Pharmaceutical Negotiation, Team Development, Change Management, Financial Modelling, Executive Communication
Each skill: `id`, `name`, `genericName`, `frequency`, `startYear`, `yearsOfExperience`, `status`, `proficiency`, `category`
**Source:** CV_v4.md Core Competencies section.
**Validation:** Types check. Existing CoreSkillsTile still renders (it will show all skills or first 5 depending on current implementation).
---
### Task 7: Add KPI Stories
**Modified file:** `src/data/kpis.ts`
**Depends on:** T1
**Effort:** Small
Add `story` field to each of the 4 KPIs:
1. **£220M** — prescribing budget, forecasting models, ICB board accountability
2. **£14.6M** — efficiency programme, data analysis identification, over-target by Oct 2025
3. **9+ Years** — career span Aug 2016present, progression narrative
4. **1.2M** — population served, Norfolk & Waveney ICS scope
**Source:** CV_v4.md role descriptions.
**Validation:** Types check. Existing tile unaffected (story field is optional).
---
### Task 8: Create Constellation Data
**New file:** `src/data/constellation.ts`
**Depends on:** T6 (needs skill IDs)
**Effort:** Medium
Define role-skill mapping for the D3 graph:
- 6 role nodes (Paydens → Tesco Duty → Tesco Manager → NHS HCD → NHS Deputy → NHS Interim)
- Skill nodes (from expanded skills data)
- Links connecting skills to roles with strength values
- Colour assignments: role nodes get org colours, skill nodes get domain colours
**Validation:** Types check. Data importable.
---
### Task 9: Create Education Extras
**New file:** `src/data/educationExtras.ts`
**Depends on:** T1
**Effort:** Small
Expanded detail for education entries:
- MPharm: extracurriculars (Pharmacy Society President, Ultimate Frisbee VP, Alzheimer's Society), research project description
- Mary Seacole: programme detail (change management, healthcare leadership, system-level thinking)
- A-Levels: no extras needed
**Source:** CV_v4.md Education section.
**Validation:** Types check. Data importable.
**Checkpoint:** All data expanded and ready for consumption by tiles and detail renderers.
---
## Phase 3: Tile Modifications
Tasks in this phase can be done in parallel where dependencies allow.
### Task 10: LatestResultsTile — Remove Flip, Add Panel
**Modified file:** `src/components/tiles/LatestResultsTile.tsx`
**Modified file:** `src/index.css` (remove flip CSS if dedicated)
**Depends on:** T2, T7
**Effort:** Medium
**Changes:**
1. Remove CSS perspective flip animation entirely
2. Remove `.metric-card`, `.metric-card-inner`, `.metric-card-front`, `.metric-card-back` CSS classes
3. Replace with clickable KPI cards:
- Headline number at 28-32px, bold (700), coloured by variant
- Label at 12px, weight 500
- Sub-text at 10px, Geist Mono, tertiary
4. Click → `openPanel({ type: 'kpi', kpi })`
5. Hover: border colour shift + shadow deepens
6. Keyboard: Enter/Space triggers panel
**Validation:** KPIs display with bigger numbers. Click opens detail panel (placeholder). No flip remnants. `npm run build`.
---
### Task 11: CoreSkillsTile — Full Width, Categorised
**Modified file:** `src/components/tiles/CoreSkillsTile.tsx`
**Depends on:** T2, T6
**Effort:** Large
**Changes:**
1. Change from half-width to full-width (`full` prop on Card)
2. Display skills grouped by category (Technical, Healthcare Domain, Strategic & Leadership)
3. Category headers: thin divider line + label (styled like sidebar section dividers)
4. Show top 3-4 skills per category on the dashboard
5. "View all" button per category → `openPanel({ type: 'skills-all', category })`
6. Individual skill click → `openPanel({ type: 'skill', skill })`
7. Retain medication metaphor (frequency, status badge)
8. Remove single-expand accordion for skills (replaced by panel interaction)
**Validation:** Skills display in 3 categories. View all opens panel. Individual click opens panel. `npm run build`.
---
### Task 12: ProjectsTile — Half Width, Card Grid
**Modified file:** `src/components/tiles/ProjectsTile.tsx`
**Depends on:** T2
**Effort:** Medium
**Changes:**
1. Change from full-width to half-width (remove `full` prop)
2. Position alongside LatestResultsTile in the grid (handled by T5 layout reorder)
3. Compact card layout: status dot + name + year (right-aligned)
4. Tech stack as small inline tags
5. Click → `openPanel({ type: 'project', investigation })`
6. Remove in-place expansion (replaced by panel)
7. Hover: border shift, shadow deepens
**Validation:** Projects render in half-width alongside KPIs. Click opens panel. `npm run build`.
---
### Task 13: LastConsultationTile — Panel Trigger
**Modified file:** `src/components/tiles/LastConsultationTile.tsx`
**Depends on:** T2
**Effort:** Small
**Changes:**
1. Add "View full record" button/link at the bottom
2. Click → `openPanel({ type: 'consultation', consultation })`
3. Make the tile header area clickable too
4. Keep existing inline content (header info row, achievements preview)
**Validation:** Click opens panel. Existing content unchanged. `npm run build`.
---
### Task 14: CareerActivityTile — Panel Triggers, Hover
**Modified file:** `src/components/tiles/CareerActivityTile.tsx`
**Depends on:** T2
**Effort:** Medium
**Changes:**
1. Timeline items: click → `openPanel({ type: 'career-role', consultation })` (for role entries)
2. Remove in-place accordion expansion (replaced by panel)
3. Hover preview: items lift slightly on hover, show 1-2 lines of preview text
4. Keep colour-coded dots and entry type styling
5. Reserve space for CareerConstellation embed (Phase 5)
**Note:** Extended timeline back to school (2009) — add education entries (Highworth Grammar, UEA) to the timeline data if not already present.
**Validation:** Click opens panel for role items. Hover shows preview. No accordion. `npm run build`.
---
### Task 15: EducationTile — Richer Content, Panel
**Modified file:** `src/components/tiles/EducationTile.tsx`
**Depends on:** T2, T9
**Effort:** Small
**Changes:**
1. Show richer inline content: research project score (75.1%), OSCE score (80%), A-level grades
2. Each education entry clickable → `openPanel({ type: 'education', document })`
3. Hover: border shift
**Validation:** Richer content visible. Click opens panel. `npm run build`.
---
### Task 16: PatientSummaryTile — Structured Presentation
**Modified file:** `src/components/tiles/PatientSummaryTile.tsx`
**Depends on:** T5
**Effort:** Small
**Changes:**
1. Use full profile paragraph from CV_v4.md (verify `profile.ts` has complete text)
2. Pull out key highlights as a visual strip (years of experience, population served, budget)
3. Break up wall of text with hierarchy (bold key phrases, structured paragraphs)
**Validation:** Profile reads well, not a wall of text. Highlight strip visible. `npm run build`.
**Checkpoint:** All tiles modified. Dashboard shows new layout with panel triggers on all interactive elements. Detail panel opens with placeholder content for each type.
---
## Phase 4: Detail Panel Renderers
### Task 17: KPIDetail Renderer
**New file:** `src/components/detail/KPIDetail.tsx`
**Depends on:** T10
**Effort:** Medium
**Content:**
- Headline number (large, coloured by variant)
- Context paragraph (from `kpi.story.context`)
- "Your role" paragraph (from `kpi.story.role`)
- Outcome bullets (from `kpi.story.outcomes`)
- Period badge (from `kpi.story.period`)
**Wire into DetailPanel:** When `content.type === 'kpi'`, render `<KPIDetail kpi={content.kpi} />`.
**Validation:** Panel renders full KPI story. Content matches CV_v4.md. `npm run build`.
---
### Task 18: ConsultationDetail Renderer
**New file:** `src/components/detail/ConsultationDetail.tsx`
**Depends on:** T13, T14
**Effort:** Medium
**Content:**
- Role title + organisation + dates
- History paragraph (from `consultation.history`)
- Achievement bullets (from `consultation.examination`)
- Plan/outcomes (from `consultation.plan`)
- Coded entries badges (from `consultation.codedEntries`)
**Validation:** Panel renders full role detail. `npm run build`.
---
### Task 19: ProjectDetail Renderer
**New file:** `src/components/detail/ProjectDetail.tsx`
**Depends on:** T12
**Effort:** Medium
**Content:**
- Project name + year + status badge
- Methodology description
- Tech stack tags
- Results bullets
- External link button (if `investigation.link` exists)
**Validation:** Panel renders full project detail. External link works. `npm run build`.
---
### Task 20: SkillDetail + SkillsAllDetail Renderers
**New files:** `src/components/detail/SkillDetail.tsx`, `src/components/detail/SkillsAllDetail.tsx`
**Depends on:** T11
**Effort:** Medium
**SkillDetail:**
- Skill name + frequency + status badge
- Proficiency bar (visual)
- Years of experience
- "Used in" section: roles that used this skill (from constellation mapping, or hardcoded until T8 data available)
**SkillsAllDetail:**
- Full categorised list grouped by Technical / Domain / Leadership
- Each skill row clickable → switches panel to individual SkillDetail
- Category headers matching tile styling
**Validation:** Both renderers work. Skill click within SkillsAll switches to SkillDetail. `npm run build`.
---
### Task 21: EducationDetail Renderer
**New file:** `src/components/detail/EducationDetail.tsx`
**Depends on:** T15
**Effort:** Small
**Content:**
- Title + institution + dates + classification
- Research project description (if MPharm, from `educationExtras`)
- Extracurricular activities (from `educationExtras`)
- Programme detail (if Mary Seacole, from `educationExtras`)
- Notes from document data
**Validation:** Panel renders education detail with extras. `npm run build`.
**Checkpoint:** All detail panel content renderers complete. Every interactive element in the dashboard opens its corresponding rich detail view.
---
## Phase 5: Career Constellation (D3.js)
### Task 22: Install D3, Scaffold CareerConstellation
**Modified file:** `package.json` (add `d3`, `@types/d3`)
**New file:** `src/components/CareerConstellation.tsx` (scaffold)
**Depends on:** T8
**Effort:** Small
- `npm install d3 @types/d3`
- Create component with `useRef<SVGSVGElement>` for the SVG container
- Render an empty SVG with viewBox, correct container sizing
- Import constellation data
**Validation:** Component renders empty SVG. d3 imports resolve. `npm run build`.
---
### Task 23: D3 Force Graph Rendering
**Modified file:** `src/components/CareerConstellation.tsx`
**Depends on:** T22
**Effort:** Large
**Implement the force-directed graph:**
- `d3.forceSimulation` with charge, link, x (chronological), y (centred), collision forces
- Role nodes: 24px radius, org colour fill, white text
- Skill nodes: 10px radius, domain colour-coded (clinical=green, technical=teal, leadership=amber)
- Links: thin lines (1px), `var(--border)`, opacity 0.3
- Container: full width of CareerActivityTile, 400px desktop / 300px tablet / 250px mobile
- SVG with responsive viewBox
- Subtle radial gradient background
**D3 integration pattern:**
- D3 operates imperatively via `useEffect` on the SVG ref
- React handles wrapper, D3 handles graph
- No React state for node positions (performance)
**Validation:** Graph renders with nodes and links. Nodes positioned chronologically. `npm run build`.
---
### Task 24: Constellation Interactions → Detail Panel
**Modified file:** `src/components/CareerConstellation.tsx`
**Depends on:** T23, T18, T20
**Effort:** Medium
**Hover interactions:**
- Hover role → connected skill nodes scale up, links brighten to `var(--accent)`, non-connected nodes fade to 0.15 opacity
- Hover skill → all connected role nodes highlight, link paths illuminate
- Tooltip with node name on hover
**Click interactions:**
- Click role → `onRoleClick(id)` → opens ConsultationDetail panel
- Click skill → `onSkillClick(id)` → opens SkillDetail panel
**Validation:** Hover highlighting works correctly. Click opens correct detail panels.
---
### Task 25: Constellation Accessibility
**Modified file:** `src/components/CareerConstellation.tsx`
**Depends on:** T23
**Effort:** Medium
- `role="img"` on SVG with `aria-label`
- Screen-reader-only text description of graph structure
- Keyboard navigation: Tab through role nodes, Enter to open detail
- `prefers-reduced-motion`: disable force simulation animation, render static final layout
- Focus indicators on nodes when keyboard-navigating
**Validation:** Screen reader describes graph. Keyboard nav works. Reduced motion shows static layout. `npm run build`.
**Checkpoint:** Career Constellation complete and integrated into CareerActivityTile. Interactive, accessible, visually impressive.
---
## Phase 6: Login Refresh
### Task 26: LoginScreen Visual Restyle
**Modified file:** `src/components/LoginScreen.tsx`
**Depends on:** None (independent)
**Effort:** Medium
**Colour changes:**
- `#005EB8``#0D6E6E` (shield icon bg, active field border, cursor, button)
- `#004D9F``#0A8080` (button hover)
- `#004494``#085858` (button pressed)
- Background: `#1E293B` → keep or lighten to `#1A2B2A`
**Typography:**
- Ensure Elvaro Grotesque is used (not DM Sans or system defaults)
- Shadows should match three-tier system
**Validation:** Login looks cohesive with dashboard. Teal accents throughout. `npm run build`.
---
### Task 27: Username → a.recruiter + Connection Status
**Modified file:** `src/components/LoginScreen.tsx`
**Depends on:** T26
**Effort:** Medium
**Username change:**
- Typed username: `a.recruiter` (not `A.CHARLWOOD`)
- Password typing remains as dots
**Connection status indicator (below login button):**
- New state: `ConnectionState = 'connecting' | 'connected'`
- Initial: red dot + "Awaiting secure connection..."
- After ~2000ms: green dot + "Secure connection established"
- Dot: 6px circle, colour transitions with 300ms ease-out
- Text: 10px, Geist Mono, tertiary colour
- Login button disabled until BOTH `typingComplete` AND `connectionState === 'connected'`
**Validation:** Username types as `a.recruiter`. Connection dot transitions red→green. Button enables correctly.
---
### Task 28: Post-Login Loading State
**Modified file:** `src/components/LoginScreen.tsx`
**Depends on:** T27
**Effort:** Small
- On login click: `isLoading = true`
- Card content replaces with: spinner + "Loading clinical records..."
- Duration: ~600ms
- Then calls `onComplete()` → dashboard materialises
**Validation:** Brief loading state visible between login click and dashboard. Feels purposeful, not slow.
---
### Task 29: TopBar Session Name Update
**Modified file:** `src/components/TopBar.tsx`
**Depends on:** T28
**Effort:** Tiny
- Change session display: `Dr. A.CHARLWOOD``A.RECRUITER`
- Geist Mono font (should already be the case)
**Validation:** TopBar shows `A.RECRUITER`. `npm run build`.
**Checkpoint:** Login flow refreshed with teal aesthetic, recruiter narrative, connection status, and loading state.
---
## Phase 7: Polish & Integration
### Task 30: CommandPalette Updates
**Modified file:** `src/components/CommandPalette.tsx`, `src/lib/search.ts`
**Depends on:** T17-T21
**Effort:** Medium
- Update search index to include expanded skills (20 skills vs 5)
- Add "View [X] detail" actions that open the detail panel directly
- Ensure palette results link to panel opens, not just scroll-to-section
- Update grouping if new content types warrant it
**Validation:** Search finds all 20 skills. Selecting a result opens the detail panel. `npm run build`.
---
### Task 31: Responsive Testing
**Modified file:** Various
**Depends on:** T5, T2
**Effort:** Medium
- DetailPanel: both `narrow` and `wide` become 100vw on mobile (<768px)
- SubNav: test on tablet/mobile (may need horizontal scroll or hamburger)
- Constellation: test at 300px/250px heights on smaller screens
- Projects + KPIs: stack vertically on mobile (grid fallback)
- Touch targets: all interactive elements ≥48px
**Validation:** Test at 375px, 768px, 1024px, 1440px breakpoints. No overflow, no hidden content.
---
### Task 32: prefers-reduced-motion Audit
**Modified file:** Various
**Depends on:** All phases
**Effort:** Small
Verify every new animation respects `prefers-reduced-motion: reduce`:
- DetailPanel slide → instant appear
- Backdrop fade → instant
- SubNav underline transition → instant
- Constellation force simulation → static layout
- Connection status dot transition → instant
- Post-login spinner → static indicator
- Hover shadows/borders → can keep (non-motion)
**Validation:** Enable `prefers-reduced-motion` in browser. No animations visible except hover state changes.
---
### Task 33: Final Visual Review + Cleanup
**Depends on:** All phases
**Effort:** Medium
- Visual review against `References/GPSystemconcept.html` (where applicable)
- Content verification against `References/CV_v4.md`
- Dead import cleanup
- Unused CSS removal (old flip card styles)
- Console warning check
- `npm run typecheck` — zero errors
- `npm run lint` — pass (pre-existing warning OK)
- `npm run build` — clean
**Final checkpoint:** Complete depth enhancement. All features working, accessible, responsive, and polished.
---
## Summary
| Phase | Tasks | New Files | Modified Files | Effort |
|-------|-------|-----------|----------------|--------|
| 1. Core Infrastructure | T1-T5 | 3 | 3 | Medium-Large |
| 2. Data Expansion | T6-T9 | 2 | 2 | Medium |
| 3. Tile Modifications | T10-T16 | 0 | 7 | Large |
| 4. Detail Renderers | T17-T21 | 6 | 1 | Medium |
| 5. Career Constellation | T22-T25 | 1 | 1 | Large |
| 6. Login Refresh | T26-T29 | 0 | 2 | Medium |
| 7. Polish | T30-T33 | 0 | Several | Medium |
| **Total** | **33 tasks** | **12 new files** | **~16 modified** | |
### Parallelisation Opportunities
- **T2, T3, T4** can be built in parallel (all depend only on T1)
- **T6, T7, T9** can be built in parallel (all depend only on T1)
- **T10-T15** can be built in parallel (all depend on T2 + their data task)
- **T17-T21** can be built in parallel (each depends on its tile task)
- **T26-T29** (login refresh) is independent of Phases 2-5, can run in parallel
### Critical Path
T1 → T2 → T5 → T10 → T17 (shortest path to first visible depth feature)
T1 → T6 → T8 → T22 → T23 → T24 (path to constellation)
### New Dependency
```bash
npm install d3 @types/d3
```
---
## Next Step
Use `/sc:implement` or begin manual implementation following this workflow phase by phase.
Binary file not shown.
+2 -2
View File
@@ -3,7 +3,7 @@ import type { Phase } from './types'
import { BootSequence } from './components/BootSequence' import { BootSequence } from './components/BootSequence'
import { ECGAnimation } from './components/ECGAnimation' import { ECGAnimation } from './components/ECGAnimation'
import { LoginScreen } from './components/LoginScreen' import { LoginScreen } from './components/LoginScreen'
import { PMRInterface } from './components/PMRInterface' import { DashboardLayout } from './components/DashboardLayout'
import { AccessibilityProvider } from './contexts/AccessibilityContext' import { AccessibilityProvider } from './contexts/AccessibilityContext'
function SkipButton({ onSkip }: { onSkip: () => void }) { function SkipButton({ onSkip }: { onSkip: () => void }) {
@@ -76,7 +76,7 @@ function App() {
<LoginScreen onComplete={() => setPhase('pmr')} /> <LoginScreen onComplete={() => setPhase('pmr')} />
)} )}
{phase === 'pmr' && <PMRInterface />} {phase === 'pmr' && <DashboardLayout />}
{(phase === 'boot' || phase === 'ecg') && ( {(phase === 'boot' || phase === 'ecg') && (
<SkipButton onSkip={skipToLogin} /> <SkipButton onSkip={skipToLogin} />
+93
View File
@@ -0,0 +1,93 @@
import React from 'react'
interface CardProps {
children: React.ReactNode
full?: boolean // spans both grid columns
className?: string
tileId?: string // data-tile-id for command palette scroll targeting
}
export function Card({ children, full, className, tileId }: CardProps) {
const [isHovered, setIsHovered] = React.useState(false)
const baseStyles: React.CSSProperties = {
background: 'var(--surface)',
border: isHovered
? '1px solid var(--border)'
: '1px solid var(--border-light)',
borderRadius: 'var(--radius)',
padding: '20px',
boxShadow: isHovered ? 'var(--shadow-md)' : 'var(--shadow-sm)',
transition: 'box-shadow 0.2s, border-color 0.2s',
gridColumn: full ? '1 / -1' : undefined,
}
return (
<article
style={baseStyles}
className={className}
data-tile-id={tileId}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
</article>
)
}
interface CardHeaderProps {
dotColor: 'teal' | 'amber' | 'green' | 'alert' | 'purple'
title: string
rightText?: string
}
const dotColorMap: Record<CardHeaderProps['dotColor'], string> = {
teal: '#0D6E6E',
amber: '#D97706',
green: '#059669',
alert: '#DC2626',
purple: '#7C3AED',
}
export function CardHeader({ dotColor, title, rightText }: CardHeaderProps) {
const headerStyles: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
}
const dotStyles: React.CSSProperties = {
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: dotColorMap[dotColor],
flexShrink: 0,
}
const titleStyles: React.CSSProperties = {
fontSize: '12px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-secondary)',
}
const rightTextStyles: React.CSSProperties = {
fontSize: '10px',
fontWeight: 400,
textTransform: 'none',
letterSpacing: 'normal',
color: 'var(--text-tertiary)',
fontFamily: "'Geist Mono', monospace",
marginLeft: 'auto',
}
return (
<div style={headerStyles}>
<div style={dotStyles} aria-hidden="true" />
<span style={titleStyles}>{title}</span>
{rightText && <span style={rightTextStyles}>{rightText}</span>}
</div>
)
}
+2 -2
View File
@@ -12,7 +12,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import type { ViewId } from '../types/pmr' import type { ViewId } from '../types/pmr'
import { useAccessibility } from '../contexts/AccessibilityContext' import { useAccessibility } from '../contexts/AccessibilityContext'
import { buildSearchIndex, groupResultsBySection, type SearchResult } from '../lib/search' import { buildLegacySearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
import type { FuseResult } from 'fuse.js' import type { FuseResult } from 'fuse.js'
interface NavItem { interface NavItem {
@@ -55,7 +55,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
const { focusAfterLoginRef, setExpandedItem } = useAccessibility() const { focusAfterLoginRef, setExpandedItem } = useAccessibility()
// Build search index once on mount // Build search index once on mount
const searchIndex = useMemo(() => buildSearchIndex(), []) const searchIndex = useMemo(() => buildLegacySearchIndex(), [])
const handleNavClick = useCallback( const handleNavClick = useCallback(
(view: ViewId) => { (view: ViewId) => {
+430
View File
@@ -0,0 +1,430 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import {
Search,
User,
Activity,
Monitor,
Award,
GraduationCap,
Zap,
type LucideIcon,
} from 'lucide-react'
import {
buildPaletteData,
buildSearchIndex,
groupBySection,
} from '@/lib/search'
import type { PaletteItem, PaletteAction, IconColorVariant } from '@/lib/search'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
interface CommandPaletteProps {
isOpen: boolean
onClose: () => void
onAction?: (action: PaletteAction) => void
}
// Icon mapping by type
const iconByType: Record<string, LucideIcon> = {
role: User,
skill: Activity,
project: Monitor,
achievement: Award,
edu: GraduationCap,
action: Zap,
}
// Color variant → CSS variable mapping for icon containers
const iconColorStyles: Record<IconColorVariant, { background: string; color: string }> = {
teal: { background: 'var(--accent-light)', color: 'var(--accent)' },
green: { background: 'var(--success-light)', color: 'var(--success)' },
amber: { background: 'var(--amber-light)', color: 'var(--amber)' },
purple: { background: 'rgba(124,58,237,0.08)', color: '#7C3AED' },
}
export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const resultsRef = useRef<HTMLDivElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
// Build data and search index once
const paletteData = useMemo(() => buildPaletteData(), [])
const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData])
// Compute visible items based on query
const visibleItems = useMemo(() => {
if (!query.trim()) {
return paletteData
}
return searchIndex.search(query).map(result => result.item)
}, [query, paletteData, searchIndex])
// Group visible items by section
const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems])
// Flat list for keyboard navigation
const flatItems = useMemo(() => {
const flat: PaletteItem[] = []
for (const group of groupedResults) {
for (const item of group.items) {
flat.push(item)
}
}
return flat
}, [groupedResults])
// Reset state when opening/closing
useEffect(() => {
if (isOpen) {
setQuery('')
setSelectedIndex(-1)
// Focus input on next frame
requestAnimationFrame(() => {
inputRef.current?.focus()
})
}
}, [isOpen])
// Reset selection when query changes
useEffect(() => {
setSelectedIndex(-1)
}, [query])
// Global Ctrl+K listener
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
if (!isOpen) {
// Parent controls isOpen, so we need onAction or an onOpen callback
// For now, the parent will handle Ctrl+K via its own listener
}
}
}
document.addEventListener('keydown', handleGlobalKeyDown)
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
}, [isOpen])
// Execute action for a palette item
const executeAction = useCallback((item: PaletteItem) => {
onClose()
if (onAction) {
onAction(item.action)
} else {
// Fallback: handle link and download actions directly
const { action } = item
if (action.type === 'link') {
window.open(action.url, '_blank', 'noopener,noreferrer')
}
}
}, [onClose, onAction])
// Keyboard navigation within the palette
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
setSelectedIndex(prev => {
const next = prev + 1
return next >= flatItems.length ? 0 : next
})
break
}
case 'ArrowUp': {
e.preventDefault()
setSelectedIndex(prev => {
const next = prev - 1
return next < 0 ? flatItems.length - 1 : next
})
break
}
case 'Enter': {
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < flatItems.length) {
executeAction(flatItems[selectedIndex])
}
break
}
case 'Escape': {
e.preventDefault()
onClose()
break
}
}
}, [flatItems, selectedIndex, executeAction, onClose])
// Auto-scroll selected item into view
useEffect(() => {
if (selectedIndex < 0 || !resultsRef.current) return
const selectedEl = resultsRef.current.querySelector(`[data-palette-index="${selectedIndex}"]`)
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' })
}
}, [selectedIndex])
// Click on overlay (outside modal) to close
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
onClose()
}
}, [onClose])
if (!isOpen) return null
// Track flat index across groups
let flatIndex = 0
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-label="Command palette"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(26,43,42,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
padding: '8px',
paddingTop: 'max(8px, 10vh)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
animation: prefersReducedMotion ? 'none' : 'palette-overlay-in 0.2s ease-out forwards',
}}
onKeyDown={handleKeyDown}
>
{/* Palette modal */}
<div
className="w-full max-w-[calc(100vw-16px)] md:max-w-[calc(100vw-32px)] md:w-[580px]"
style={{
maxHeight: 'calc(100vh - 24vh)',
background: 'var(--surface)',
borderRadius: '12px',
boxShadow: '0 20px 60px rgba(26,43,42,0.2), 0 0 0 1px rgba(26,43,42,0.08)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
animation: prefersReducedMotion ? 'none' : 'palette-modal-in 0.2s cubic-bezier(0.4,0,0.2,1) forwards',
}}
>
{/* Search input row */}
<div
className="px-3 py-3 md:px-[18px] md:py-[14px]"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
borderBottom: '1px solid var(--border-light)',
}}
>
<Search
size={18}
style={{ color: 'var(--accent)', flexShrink: 0 }}
aria-hidden="true"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search records, experience, skills..."
autoComplete="off"
className="font-ui"
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
fontSize: '15px',
color: 'var(--text-primary)',
}}
aria-label="Search"
aria-activedescendant={
selectedIndex >= 0 ? `palette-item-${flatItems[selectedIndex]?.id}` : undefined
}
role="combobox"
aria-expanded="true"
aria-controls="palette-results"
aria-autocomplete="list"
/>
<kbd
className="font-geist"
style={{
fontSize: '10px',
color: 'var(--text-tertiary)',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border)',
padding: '2px 7px',
borderRadius: '4px',
flexShrink: 0,
lineHeight: 1,
}}
>
ESC
</kbd>
</div>
{/* Results area */}
<div
id="palette-results"
ref={resultsRef}
role="listbox"
aria-label="Search results"
className="pmr-scrollbar p-2 md:p-[8px]"
style={{
overflowY: 'auto',
flex: 1,
}}
>
{flatItems.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '32px 16px',
color: 'var(--text-tertiary)',
fontSize: '13px',
}}
>
No results found for &ldquo;{query}&rdquo;
</div>
) : (
groupedResults.map((group) => {
const sectionItems = group.items.map((item) => {
const currentIndex = flatIndex
flatIndex++
const isSelected = currentIndex === selectedIndex
const IconComponent = iconByType[item.iconType]
const colorStyle = iconColorStyles[item.iconVariant]
return (
<div
key={item.id}
id={`palette-item-${item.id}`}
role="option"
aria-selected={isSelected}
data-palette-index={currentIndex}
onClick={() => executeAction(item)}
onMouseEnter={() => setSelectedIndex(currentIndex)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '9px 10px',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'background 0.1s',
fontSize: '13px',
color: 'var(--text-primary)',
background: isSelected ? 'var(--accent-light)' : 'transparent',
outline: isSelected ? '1.5px solid var(--accent-border)' : 'none',
}}
>
{/* Icon container */}
<div
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: colorStyle.background,
color: colorStyle.color,
}}
>
{IconComponent && <IconComponent size={14} />}
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{item.title}</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
marginTop: '1px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.subtitle}
</div>
</div>
</div>
)
})
return (
<div key={group.section}>
{/* Section label */}
<div
style={{
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-tertiary)',
padding: '8px 10px 5px',
}}
>
{group.section}
</div>
{sectionItems}
</div>
)
})
)}
</div>
{/* Footer with keyboard hints */}
<div
className="hidden md:flex px-3 py-2 md:px-[18px] md:py-[10px]"
style={{
alignItems: 'center',
gap: '12px',
borderTop: '1px solid var(--border-light)',
fontSize: '11px',
color: 'var(--text-tertiary)',
}}
>
<span>
<Kbd>\u2191</Kbd> <Kbd>\u2193</Kbd> Navigate
</span>
<span>
<Kbd>Enter</Kbd> Select
</span>
<span>
<Kbd>Esc</Kbd> Close
</span>
</div>
</div>
</div>
)
}
// Small kbd element for the footer
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd
className="font-geist"
style={{
fontSize: '10px',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border)',
padding: '1px 5px',
borderRadius: '3px',
color: 'var(--text-secondary)',
}}
>
{children}
</kbd>
)
}
+181
View File
@@ -0,0 +1,181 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { TopBar } from './TopBar'
import Sidebar from './Sidebar'
import { CommandPalette } from './CommandPalette'
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
import { LatestResultsTile } from './tiles/LatestResultsTile'
import { CoreSkillsTile } from './tiles/CoreSkillsTile'
import { LastConsultationTile } from './tiles/LastConsultationTile'
import { CareerActivityTile } from './tiles/CareerActivityTile'
import { EducationTile } from './tiles/EducationTile'
import { ProjectsTile } from './tiles/ProjectsTile'
import type { PaletteAction } from '@/lib/search'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const topbarVariants = {
hidden: prefersReducedMotion ? { y: 0, opacity: 1 } : { y: -48, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' },
},
}
const sidebarVariants = {
hidden: prefersReducedMotion ? { x: 0, opacity: 1 } : { x: -272, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.25, ease: 'easeOut', delay: 0.05 },
},
}
const contentVariants = {
hidden: prefersReducedMotion ? { opacity: 1 } : { opacity: 0 },
visible: {
opacity: 1,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.3, delay: 0.15 },
},
}
export function DashboardLayout() {
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const handleSearchClick = () => {
setCommandPaletteOpen(true)
}
const handlePaletteClose = useCallback(() => {
setCommandPaletteOpen(false)
}, [])
// Global Ctrl+K listener to open command palette
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
setCommandPaletteOpen(prev => !prev)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
// Handle palette actions (scroll to tile, expand item, open link, download)
const handlePaletteAction = useCallback((action: PaletteAction) => {
switch (action.type) {
case 'scroll': {
const tileEl = document.querySelector(`[data-tile-id="${action.tileId}"]`)
if (tileEl) {
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
break
}
case 'expand': {
const tileEl = document.querySelector(`[data-tile-id="${action.tileId}"]`)
if (tileEl) {
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Dispatch a custom event that the tile can listen for to expand the item
const expandEvent = new CustomEvent('palette-expand', {
detail: { tileId: action.tileId, itemId: action.itemId },
})
document.dispatchEvent(expandEvent)
}
break
}
case 'link': {
window.open(action.url, '_blank', 'noopener,noreferrer')
break
}
case 'download': {
// For now, open the CV file or trigger a download
// This can be wired to an actual PDF when available
window.open('/References/CV_v4.md', '_blank')
break
}
}
}, [])
return (
<div
className="font-ui"
style={{ background: 'var(--bg-dashboard)', minHeight: '100vh' }}
>
{/* TopBar — fixed at top */}
<motion.div initial="hidden" animate="visible" variants={topbarVariants}>
<TopBar onSearchClick={handleSearchClick} />
</motion.div>
{/* Layout below TopBar: Sidebar + Main */}
<div
style={{
display: 'flex',
marginTop: 'var(--topbar-height)',
height: 'calc(100vh - var(--topbar-height))',
}}
>
{/* Sidebar — hidden on mobile/tablet, visible on desktop */}
<motion.div
initial="hidden"
animate="visible"
variants={sidebarVariants}
className="hidden lg:block"
style={{ flexShrink: 0 }}
>
<Sidebar />
</motion.div>
{/* Main content — scrollable card grid */}
<motion.main
id="main-content"
initial="hidden"
animate="visible"
variants={contentVariants}
aria-label="Dashboard content"
className="pmr-scrollbar p-4 pb-8 md:p-6 md:pb-10 lg:px-7 lg:pt-6 lg:pb-10"
style={{
flex: 1,
overflowY: 'auto',
}}
>
<div className="dashboard-grid">
{/* PatientSummaryTile — full width */}
<PatientSummaryTile />
{/* LatestResultsTile — half width (left) */}
<LatestResultsTile />
{/* CoreSkillsTile — half width (right) */}
<CoreSkillsTile />
{/* LastConsultationTile — full width */}
<LastConsultationTile />
{/* CareerActivityTile — full width */}
<CareerActivityTile />
{/* EducationTile — full width */}
<EducationTile />
{/* ProjectsTile — full width */}
<ProjectsTile />
</div>
</motion.main>
</div>
{/* Command palette overlay */}
<CommandPalette
isOpen={commandPaletteOpen}
onClose={handlePaletteClose}
onAction={handlePaletteAction}
/>
</div>
)
}
+433
View File
@@ -0,0 +1,433 @@
import { AlertTriangle, AlertCircle } from 'lucide-react'
import { patient } from '@/data/patient'
import { tags } from '@/data/tags'
import { alerts } from '@/data/alerts'
import type { Tag, Alert } from '@/types/pmr'
interface SectionTitleProps {
children: React.ReactNode
}
function SectionTitle({ children }: SectionTitleProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-tertiary)',
marginBottom: '10px',
}}
>
<span>{children}</span>
<div
style={{
flex: 1,
height: '1px',
background: 'var(--border-light)',
}}
/>
</div>
)
}
interface TagPillProps {
tag: Tag
}
function TagPill({ tag }: TagPillProps) {
const styles: Record<Tag['colorVariant'], React.CSSProperties> = {
teal: {
background: 'var(--accent-light)',
color: 'var(--accent)',
border: '1px solid var(--accent-border)',
},
amber: {
background: 'var(--amber-light)',
color: 'var(--amber)',
border: '1px solid var(--amber-border)',
},
green: {
background: 'var(--success-light)',
color: 'var(--success)',
border: '1px solid var(--success-border)',
},
}
return (
<span
style={{
display: 'inline-flex',
fontSize: '10.5px',
fontWeight: 500,
padding: '3px 8px',
borderRadius: '4px',
lineHeight: 1.3,
...styles[tag.colorVariant],
}}
>
{tag.label}
</span>
)
}
interface AlertFlagProps {
alert: Alert
}
function AlertFlag({ alert }: AlertFlagProps) {
const Icon = alert.icon === 'AlertTriangle' ? AlertTriangle : AlertCircle
const styles: Record<Alert['severity'], React.CSSProperties> = {
alert: {
background: 'var(--alert-light)',
color: 'var(--alert)',
border: '1px solid var(--alert-border)',
},
amber: {
background: 'var(--amber-light)',
color: 'var(--amber)',
border: '1px solid var(--amber-border)',
},
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '11px',
fontWeight: 700,
padding: '7px 10px',
borderRadius: 'var(--radius-sm)',
letterSpacing: '0.02em',
...styles[alert.severity],
}}
>
<div
style={{
width: '16px',
height: '16px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon size={14} strokeWidth={2.5} />
</div>
<span>{alert.message}</span>
</div>
)
}
export default function Sidebar() {
return (
<aside
style={{
width: 'var(--sidebar-width)',
minWidth: 'var(--sidebar-width)',
background: 'var(--sidebar-bg)',
borderRight: '1px solid var(--border)',
overflowY: 'auto',
padding: '20px 16px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
className="pmr-scrollbar"
>
{/* PersonHeader Section */}
<div
style={{
borderBottom: '2px solid var(--accent)',
paddingBottom: '16px',
marginBottom: '6px',
}}
>
{/* Avatar */}
<div
style={{
width: '52px',
height: '52px',
borderRadius: '50%',
background: 'linear-gradient(135deg, var(--accent), #0A8080)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#FFFFFF',
fontSize: '18px',
fontWeight: 700,
boxShadow: '0 2px 8px rgba(13,110,110,0.25)',
marginBottom: '12px',
}}
>
AC
</div>
{/* Name */}
<div
style={{
fontSize: '15px',
fontWeight: 700,
color: 'var(--text-primary)',
letterSpacing: '-0.01em',
}}
>
CHARLWOOD, Andrew
</div>
{/* Title */}
<div
style={{
fontSize: '11.5px',
fontFamily: 'Geist Mono, monospace',
fontWeight: 400,
color: 'var(--text-secondary)',
marginTop: '2px',
}}
>
Pharmacy Data Technologist
</div>
{/* Status badge */}
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '5px',
marginTop: '8px',
fontSize: '11px',
fontWeight: 500,
color: 'var(--success)',
background: 'var(--success-light)',
border: '1px solid var(--success-border)',
padding: '3px 9px',
borderRadius: '20px',
}}
>
<span
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: 'var(--success)',
animation: 'pulse 2s infinite',
}}
aria-hidden="true"
/>
<span>{patient.badge}</span>
</div>
{/* Details grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: '6px',
marginTop: '12px',
}}
>
{/* GPhC No. */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '11.5px',
padding: '2px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>
GPhC No.
</span>
<span
style={{
color: 'var(--text-primary)',
fontWeight: 500,
fontFamily: 'Geist Mono, monospace',
fontSize: '11px',
letterSpacing: '0.12em',
}}
>
{patient.nhsNumber.replace(/\s/g, '')}
</span>
</div>
{/* Education */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '11.5px',
padding: '2px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>
Education
</span>
<span
style={{
color: 'var(--text-primary)',
fontWeight: 500,
textAlign: 'right',
}}
>
{patient.qualification}
</span>
</div>
{/* Location */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '11.5px',
padding: '2px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>
Location
</span>
<span
style={{
color: 'var(--text-primary)',
fontWeight: 500,
textAlign: 'right',
}}
>
{patient.address}
</span>
</div>
{/* Phone */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '11.5px',
padding: '2px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>
Phone
</span>
<a
href={`tel:${patient.phone}`}
style={{
color: 'var(--accent)',
fontWeight: 500,
textDecoration: 'none',
textAlign: 'right',
}}
onMouseEnter={(e) =>
(e.currentTarget.style.textDecoration = 'underline')
}
onMouseLeave={(e) =>
(e.currentTarget.style.textDecoration = 'none')
}
>
{patient.phone.replace(/(\d{5})(\d{6})/, '$1 $2')}
</a>
</div>
{/* Email */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '11.5px',
padding: '2px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>
Email
</span>
<a
href={`mailto:${patient.email}`}
style={{
color: 'var(--accent)',
fontWeight: 500,
textDecoration: 'none',
textAlign: 'right',
}}
onMouseEnter={(e) =>
(e.currentTarget.style.textDecoration = 'underline')
}
onMouseLeave={(e) =>
(e.currentTarget.style.textDecoration = 'none')
}
>
{patient.email}
</a>
</div>
{/* Registered */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '11.5px',
padding: '2px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>
Registered
</span>
<span
style={{
color: 'var(--text-primary)',
fontWeight: 500,
textAlign: 'right',
}}
>
{patient.registrationYear}
</span>
</div>
</div>
</div>
{/* Tags Section */}
<div style={{ padding: '14px 0 6px' }}>
<SectionTitle>Tags</SectionTitle>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '5px',
}}
>
{tags.map((tag) => (
<TagPill key={tag.label} tag={tag} />
))}
</div>
</div>
{/* Alerts / Highlights Section */}
<div style={{ padding: '14px 0 6px' }}>
<SectionTitle>Alerts / Highlights</SectionTitle>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{alerts.map((alert, index) => (
<AlertFlag key={index} alert={alert} />
))}
</div>
</div>
</aside>
)
}
+211
View File
@@ -0,0 +1,211 @@
import { useState, useEffect } from 'react'
import { Home, Search } from 'lucide-react'
interface TopBarProps {
onSearchClick?: () => void
}
export function TopBar({ onSearchClick }: TopBarProps) {
const [currentTime, setCurrentTime] = useState(() => formatTime(new Date()))
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(formatTime(new Date()))
}, 60_000)
return () => clearInterval(interval)
}, [])
return (
<header
className="fixed top-0 left-0 right-0 flex items-center justify-between font-ui"
style={{
height: 'var(--topbar-height)',
background: 'var(--surface)',
borderBottom: '1px solid var(--border)',
padding: '0 20px',
zIndex: 100,
}}
>
{/* Skip to main content link (only visible on focus) */}
<a
href="#main-content"
className="skip-link"
style={{
position: 'absolute',
top: '-40px',
left: '0',
background: 'var(--accent)',
color: '#FFFFFF',
padding: '8px 16px',
textDecoration: 'none',
zIndex: 101,
borderRadius: '0 0 4px 0',
fontSize: '13px',
fontWeight: 600,
}}
onFocus={(e) => {
e.currentTarget.style.top = '0'
}}
onBlur={(e) => {
e.currentTarget.style.top = '-40px'
}}
>
Skip to main content
</a>
{/* Brand */}
<div className="flex items-center gap-2 shrink-0">
<Home
size={18}
style={{ color: 'var(--accent)' }}
aria-hidden="true"
/>
<span
className="font-ui hidden sm:inline"
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
Headhunt Medical Center
</span>
<span
className="font-ui sm:hidden"
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
HMC
</span>
<span
className="hidden md:inline"
style={{
fontSize: '11px',
fontWeight: 400,
color: 'var(--text-tertiary)',
marginLeft: '2px',
}}
>
Remote
</span>
</div>
{/* Search bar (center) — triggers command palette, no inline search */}
<button
type="button"
onClick={onSearchClick}
className="hidden md:flex items-center gap-2 cursor-pointer font-ui"
style={{
maxWidth: '560px',
minWidth: '400px',
height: '42px',
border: '1.5px solid var(--border)',
borderRadius: 'var(--radius-card)',
padding: '0 14px',
background: 'var(--surface)',
transition: 'border-color 150ms, box-shadow 150ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
}}
onMouseLeave={(e) => {
if (document.activeElement !== e.currentTarget) {
e.currentTarget.style.borderColor = 'var(--border)'
}
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--accent)'
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(13,110,110,0.12)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
e.currentTarget.style.boxShadow = 'none'
}}
aria-label="Search records, experience, skills. Press Control plus K"
>
<Search
size={16}
style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
aria-hidden="true"
/>
<span
className="flex-1 text-left"
style={{
fontSize: '13px',
color: 'var(--text-tertiary)',
fontFamily: 'var(--font-ui)',
}}
>
Search records, experience, skills...
</span>
<kbd
className="font-geist"
style={{
fontSize: '10px',
color: 'var(--text-tertiary)',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border)',
padding: '2px 6px',
borderRadius: '4px',
lineHeight: 1,
}}
>
Ctrl+K
</kbd>
</button>
{/* Session info (right) */}
<div
className="flex items-center gap-2 sm:gap-3 shrink-0"
aria-label="Active session information"
>
<span
className="hidden sm:inline"
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
fontFamily: 'var(--font-ui)',
}}
>
Dr. A.CHARLWOOD
</span>
<span
className="font-geist hidden xs:inline"
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
background: 'var(--accent-light)',
padding: '3px 10px',
borderRadius: '4px',
border: '1px solid var(--accent-border)',
}}
>
Active Session · {currentTime}
</span>
<span
className="font-geist xs:hidden"
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
background: 'var(--accent-light)',
padding: '3px 8px',
borderRadius: '4px',
border: '1px solid var(--accent-border)',
}}
>
{currentTime}
</span>
</div>
</header>
)
}
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
+393
View File
@@ -0,0 +1,393 @@
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
type ActivityType = 'role' | 'project' | 'cert' | 'edu'
interface ActivityEntry {
id: string
type: ActivityType
title: string
meta: string
date: string
sortYear: number
/** ID of the corresponding consultation in consultations.ts (role entries only) */
consultationId?: string
}
/**
* Build timeline from multiple data sources
* Matches the concept HTML entries exactly
*/
function buildTimeline(): ActivityEntry[] {
const entries: ActivityEntry[] = []
// Roles from consultations
entries.push({
id: 'interim-head-2025',
type: 'role',
title: 'Interim Head, Population Health & Data Analysis',
meta: 'NHS Norfolk & Waveney ICB',
date: '2024 2025',
sortYear: 2024,
consultationId: 'interim-head-2025',
})
entries.push({
id: 'deputy-head-2024',
type: 'role',
title: 'Senior Data Analyst — Medicines Optimisation',
meta: 'NHS Norfolk & Waveney ICB',
date: '2021 2024',
sortYear: 2021,
consultationId: 'deputy-head-2024',
})
entries.push({
id: 'high-cost-drugs-2022',
type: 'role',
title: 'Prescribing Data Pharmacist',
meta: 'NHS Norwich CCG',
date: '2018 2021',
sortYear: 2018,
consultationId: 'pharmacy-manager-2017',
})
entries.push({
id: 'community-pharmacist-2016',
type: 'role',
title: 'Community Pharmacist',
meta: 'Boots UK',
date: '2016 2018',
sortYear: 2016,
consultationId: 'duty-pharmacist-2016',
})
// Projects
entries.push({
id: 'inv-budget',
type: 'project',
title: '£220M Prescribing Budget Oversight',
meta: 'Lead analyst & budget owner',
date: '2024',
sortYear: 2024,
})
entries.push({
id: 'inv-sql-transform',
type: 'project',
title: 'SQL Analytics Transformation',
meta: 'Legacy migration project lead',
date: '2025',
sortYear: 2025,
})
// Certifications
entries.push({
id: 'cert-powerbi',
type: 'cert',
title: 'Power BI Data Analyst Associate',
meta: 'Microsoft Certified',
date: '2023',
sortYear: 2023,
})
entries.push({
id: 'cert-diploma',
type: 'cert',
title: 'Clinical Pharmacy Diploma',
meta: 'Professional development',
date: '2019',
sortYear: 2019,
})
entries.push({
id: 'doc-gphc',
type: 'cert',
title: 'GPhC Registration',
meta: 'General Pharmaceutical Council',
date: 'August 2016',
sortYear: 2016,
})
// Education
const mpharm = documents.find((d) => d.id === 'doc-mpharm')
if (mpharm) {
entries.push({
id: mpharm.id,
type: 'edu',
title: 'MPharm (Hons) — 2:1',
meta: 'University of East Anglia',
date: '2011 2015',
sortYear: 2011,
})
}
return entries.sort((a, b) => {
if (b.sortYear !== a.sortYear) return b.sortYear - a.sortYear
return 0
})
}
const dotColorMap: Record<ActivityType, string> = {
role: '#0D6E6E',
project: '#D97706',
cert: '#059669',
edu: '#7C3AED',
}
const borderColorMap: Record<ActivityType, string> = {
role: '#0D6E6E',
project: '#D97706',
cert: '#059669',
edu: '#7C3AED',
}
interface ActivityItemProps {
entry: ActivityEntry
isExpanded: boolean
onToggle: () => void
}
const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle }) => {
const dotColor = dotColorMap[entry.type]
const isExpandable = entry.type === 'role' && entry.consultationId
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isExpandable) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && isExpanded) {
e.preventDefault()
onToggle()
}
},
[isExpandable, isExpanded, onToggle],
)
// Get consultation data for expanded content
const consultation = isExpandable
? consultations.find((c) => c.id === entry.consultationId)
: null
return (
<div
role={isExpandable ? 'button' : undefined}
tabIndex={isExpandable ? 0 : undefined}
aria-expanded={isExpandable ? isExpanded : undefined}
onClick={isExpandable ? onToggle : undefined}
onKeyDown={isExpandable ? handleKeyDown : undefined}
style={{
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-dashboard)',
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)'
}
}}
>
{/* Item header row */}
<div style={{ display: 'flex', gap: '10px', padding: '10px 12px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: dotColor,
flexShrink: 0,
marginTop: '2px',
}}
aria-hidden="true"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
}}
>
{entry.title}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-secondary)',
marginTop: '2px',
}}
>
{entry.meta}
</div>
<div
style={{
fontSize: '10px',
fontFamily: 'var(--font-mono)',
color: 'var(--text-tertiary)',
marginTop: '3px',
}}
>
{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' }}
>
<div
style={{
borderLeft: `2px solid ${borderColorMap[entry.type]}`,
marginLeft: '16px',
marginRight: '12px',
marginBottom: '12px',
paddingLeft: '14px',
paddingTop: '4px',
}}
>
{/* 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>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export const CareerActivityTile: React.FC = () => {
const timeline = buildTimeline()
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
const handleToggle = useCallback(
(id: string) => {
setExpandedItemId((prev) => (prev === id ? null : id))
},
[],
)
return (
<Card full tileId="career-activity">
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
<div className="activity-grid">
{timeline.map((entry) => (
<ActivityItem
key={entry.id}
entry={entry}
isExpanded={expandedItemId === entry.id}
onToggle={() => handleToggle(entry.id)}
/>
))}
</div>
</Card>
)
}
+250
View File
@@ -0,0 +1,250 @@
import React, { useState, useCallback } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { BarChart3, Code2, Database, PieChart, FileCode2 } from 'lucide-react'
import { Card, CardHeader } from '../Card'
import { skills } from '@/data/skills'
import { medications } from '@/data/medications'
import type { SkillMedication } from '@/types/pmr'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const iconMap = {
BarChart3,
Code2,
Database,
PieChart,
FileCode2,
}
interface SkillItemProps {
skill: SkillMedication
isExpanded: boolean
onToggle: () => void
}
function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
const IconComponent = iconMap[skill.icon as keyof typeof iconMap]
// Find matching medication for prescribing history
const medication = medications.find((m) => m.name === skill.name)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && isExpanded) {
e.preventDefault()
onToggle()
}
},
[isExpanded, onToggle],
)
return (
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={onToggle}
onKeyDown={handleKeyDown}
style={{
display: 'flex',
flexDirection: 'column',
fontSize: '12.5px',
background: 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'border-color 0.15s',
...(isExpanded && {
borderColor: 'var(--accent-border)',
}),
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
}}
onMouseLeave={(e) => {
if (!isExpanded) {
e.currentTarget.style.borderColor = 'var(--border-light)'
}
}}
>
{/* Item header row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 12px',
}}
>
{/* Icon container */}
<div
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
background: 'var(--accent-light)',
color: 'var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{IconComponent && <IconComponent size={14} />}
</div>
{/* Text block */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: '2px',
}}
>
{skill.name}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
fontFamily: '"Geist Mono", monospace',
}}
>
{skill.frequency} · Since {skill.startYear} · {skill.yearsOfExperience} yrs
</div>
</div>
{/* Status badge */}
<div
style={{
fontSize: '10px',
fontWeight: 500,
padding: '3px 8px',
borderRadius: '20px',
background: 'var(--success-light)',
color: 'var(--success)',
border: '1px solid var(--success-border)',
flexShrink: 0,
}}
>
{skill.status}
</div>
</div>
{/* Expanded content: prescribing history timeline */}
<AnimatePresence initial={false}>
{isExpanded && medication && medication.prescribingHistory && (
<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={{
marginLeft: '12px',
marginRight: '12px',
marginBottom: '12px',
paddingLeft: '14px',
paddingTop: '4px',
borderLeft: '2px solid var(--accent)',
}}
>
{/* Timeline entries */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{medication.prescribingHistory.map((entry, i) => (
<div
key={i}
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
}}
>
{/* Timeline dot */}
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: 'var(--accent)',
flexShrink: 0,
marginTop: '4px',
}}
/>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '12px',
fontWeight: 600,
fontFamily: '"Geist Mono", monospace',
color: 'var(--text-primary)',
marginBottom: '2px',
}}
>
{entry.year}
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
lineHeight: 1.4,
}}
>
{entry.description}
</div>
</div>
</div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function CoreSkillsTile() {
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
const handleToggle = useCallback(
(id: string) => {
setExpandedItemId((prev) => (prev === id ? null : id))
},
[],
)
return (
<Card tileId="core-skills">
<CardHeader dotColor="amber" title="REPEAT MEDICATIONS" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{skills.map((skill) => (
<SkillItem
key={skill.id}
skill={skill}
isExpanded={expandedItemId === skill.id}
onToggle={() => handleToggle(skill.id)}
/>
))}
</div>
</Card>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { Card, CardHeader } from '../Card'
/**
* Education tile - displays academic qualifications
* Full-width card below Career Activity
*/
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',
},
]
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}
style={{
padding: '7px 10px',
background: 'var(--surface)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
fontSize: '11.5px',
color: 'var(--text-primary)',
}}
>
<span
style={{
display: 'block',
fontWeight: 600,
}}
>
{entry.degree}
</span>
<span
style={{
color: 'var(--text-secondary)',
fontSize: '11px',
marginTop: '2px',
display: 'block',
}}
>
{entry.detail}
</span>
</div>
))}
</div>
</Card>
)
}
@@ -0,0 +1,193 @@
import React from 'react'
import { Card, CardHeader } from '../Card'
import { consultations } from '@/data/consultations'
export const LastConsultationTile: React.FC = () => {
// Use the most recent consultation (first in array)
const consultation = consultations[0]
// Format date to "May 2025" format
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
}
// Extract employment type from duration string (e.g., "May 2025 — Nov 2025")
const getEmploymentType = (): string => {
// All ICB roles are Permanent · Full-time (from CV context)
if (consultation.organization.includes('ICB')) {
return 'Permanent · Full-time'
}
return 'Permanent'
}
// Extract band from role context - ICB senior roles are typically Band 8a
const getBand = (): string => {
if (consultation.role.includes('Head')) {
return '8a'
}
return '—'
}
return (
<Card full tileId="last-consultation">
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
{/* Header info row */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '16px',
marginBottom: '14px',
paddingBottom: '14px',
borderBottom: '1px solid var(--border-light)',
}}
>
<div>
<div
style={{
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
marginBottom: '3px',
}}
>
Date
</div>
<div
style={{
fontSize: '11.5px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
{formatDate(consultation.date)}
</div>
</div>
<div>
<div
style={{
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
marginBottom: '3px',
}}
>
Organisation
</div>
<div
style={{
fontSize: '11.5px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
{consultation.organization}
</div>
</div>
<div>
<div
style={{
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
marginBottom: '3px',
}}
>
Type
</div>
<div
style={{
fontSize: '11.5px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
{getEmploymentType()}
</div>
</div>
<div>
<div
style={{
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
marginBottom: '3px',
}}
>
Band
</div>
<div
style={{
fontSize: '11.5px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
{getBand()}
</div>
</div>
</div>
{/* Role title */}
<div
style={{
fontSize: '13.5px',
fontWeight: 600,
color: 'var(--accent)',
marginBottom: '12px',
}}
>
{consultation.role}
</div>
{/* Bullet list */}
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '7px',
}}
>
{consultation.examination.map((bullet, index) => (
<li
key={index}
style={{
fontSize: '12.5px',
color: 'var(--text-primary)',
paddingLeft: '16px',
lineHeight: '1.5',
position: 'relative',
}}
>
{/* Bullet dot */}
<span
style={{
position: 'absolute',
left: '0',
top: '7px',
width: '5px',
height: '5px',
borderRadius: '50%',
backgroundColor: 'var(--accent)',
opacity: 0.5,
}}
/>
{bullet}
</li>
))}
</ul>
</Card>
)
}
+126
View File
@@ -0,0 +1,126 @@
import React, { useState, useCallback } from 'react'
import { Card, CardHeader } from '../Card'
import { kpis } from '@/data/kpis'
import type { KPI } from '@/types/pmr'
const colorMap: Record<KPI['colorVariant'], string> = {
green: '#059669',
amber: '#D97706',
teal: '#0D6E6E',
}
interface MetricCardProps {
kpi: KPI
isFlipped: boolean
onFlip: (id: string) => void
}
function MetricCard({ kpi, isFlipped, onFlip }: MetricCardProps) {
const handleClick = () => {
onFlip(kpi.id)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onFlip(kpi.id)
}
}
const outerStyles: React.CSSProperties = {
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
background: 'var(--bg-dashboard)',
overflow: 'hidden',
}
const innerStyles: React.CSSProperties = {
padding: '14px',
}
const valueStyles: React.CSSProperties = {
fontSize: '22px',
fontWeight: 700,
letterSpacing: '-0.02em',
lineHeight: 1.2,
color: colorMap[kpi.colorVariant],
}
const labelStyles: React.CSSProperties = {
fontSize: '11px',
fontWeight: 500,
color: 'var(--text-secondary)',
marginTop: '3px',
}
const subStyles: React.CSSProperties = {
fontSize: '10px',
color: 'var(--text-tertiary)',
fontFamily: "'Geist Mono', monospace",
marginTop: '4px',
}
const backStyles: React.CSSProperties = {
padding: '14px',
background: 'var(--accent-light)',
fontSize: '12px',
color: 'var(--text-secondary)',
lineHeight: 1.5,
fontFamily: 'var(--font-ui)',
display: 'flex',
alignItems: 'center',
}
return (
<div
className="metric-card"
style={outerStyles}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
role="button"
aria-label={`${kpi.label}: ${kpi.value}. ${isFlipped ? 'Showing explanation. Click to show value.' : 'Click to show explanation.'}`}
>
<div className={`metric-card-inner${isFlipped ? ' flipped' : ''}`}>
<div className="metric-card-front" style={innerStyles}>
<div style={valueStyles}>{kpi.value}</div>
<div style={labelStyles}>{kpi.label}</div>
<div style={subStyles}>{kpi.sub}</div>
</div>
<div className="metric-card-back" style={backStyles}>
{kpi.explanation}
</div>
</div>
</div>
)
}
export function LatestResultsTile() {
const [flippedCardId, setFlippedCardId] = useState<string | null>(null)
const handleFlip = useCallback((id: string) => {
setFlippedCardId((prev) => (prev === id ? null : id))
}, [])
const gridStyles: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
}
return (
<Card tileId="latest-results">
<CardHeader dotColor="teal" title="LATEST RESULTS" rightText="Updated May 2025" />
<div style={gridStyles}>
{kpis.map((kpi) => (
<MetricCard
key={kpi.id}
kpi={kpi}
isFlipped={flippedCardId === kpi.id}
onFlip={handleFlip}
/>
))}
</div>
</Card>
)
}
@@ -0,0 +1,18 @@
import React from 'react'
import { Card, CardHeader } from '../Card'
import { personalStatement } from '@/data/profile'
export function PatientSummaryTile() {
const bodyStyles: React.CSSProperties = {
fontSize: '13px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}
return (
<Card full tileId="patient-summary">
<CardHeader dotColor="teal" title="PATIENT SUMMARY" />
<div style={bodyStyles}>{personalStatement}</div>
</Card>
)
}
+276
View File
@@ -0,0 +1,276 @@
import React, { useState, useCallback } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ExternalLink } from 'lucide-react'
import { investigations } from '@/data/investigations'
import { Card, CardHeader } from '../Card'
import type { Investigation } from '@/types/pmr'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const statusColorMap: Record<string, string> = {
Complete: '#059669',
Ongoing: '#0D6E6E',
Live: '#059669',
}
interface ProjectItemProps {
project: Investigation
isExpanded: boolean
onToggle: () => void
}
function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
const dotColor = statusColorMap[project.status] || '#0D6E6E'
const isLive = project.status === 'Live'
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && isExpanded) {
e.preventDefault()
onToggle()
}
},
[isExpanded, onToggle],
)
return (
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={onToggle}
onKeyDown={handleKeyDown}
style={{
display: 'flex',
flexDirection: 'column',
background: 'var(--surface)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
fontSize: '11.5px',
color: 'var(--text-primary)',
transition: 'border-color 0.15s',
cursor: 'pointer',
...(isExpanded && {
borderColor: 'var(--accent-border)',
}),
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
}}
onMouseLeave={(e) => {
if (!isExpanded) {
e.currentTarget.style.borderColor = 'var(--border-light)'
}
}}
>
{/* Item header row */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
padding: '7px 10px',
}}
>
<div
style={{
width: '7px',
height: '7px',
borderRadius: '50%',
backgroundColor: dotColor,
flexShrink: 0,
marginTop: '4px',
animation: isLive ? 'pulse 2s infinite' : undefined,
}}
aria-hidden="true"
/>
<span style={{ flex: 1 }}>{project.name}</span>
<span
style={{
fontSize: '10px',
fontFamily: "'Geist Mono', monospace",
color: 'var(--text-tertiary)',
marginLeft: 'auto',
flexShrink: 0,
}}
>
{project.requestedYear}
</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',
}}
>
{project.techStack.map((tech) => (
<span
key={tech}
style={{
fontSize: '10px',
fontFamily: 'var(--font-mono)',
padding: '2px 7px',
borderRadius: '3px',
background: 'var(--amber-light)',
color: '#92400E',
border: '1px solid var(--amber-border)',
}}
>
{tech}
</span>
))}
</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))
},
[],
)
return (
<Card full tileId="projects">
<CardHeader dotColor="amber" title="ACTIVE PROJECTS" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{investigations.map((project) => (
<ProjectItem
key={project.id}
project={project}
isExpanded={expandedItemId === project.id}
onToggle={() => handleToggle(project.id)}
/>
))}
</div>
</Card>
)
}
+14
View File
@@ -0,0 +1,14 @@
import type { Alert } from '@/types/pmr'
export const alerts: Alert[] = [
{
message: '£14.6M SAVINGS IDENTIFIED',
severity: 'alert',
icon: 'AlertTriangle',
},
{
message: '£220M BUDGET OVERSIGHT',
severity: 'amber',
icon: 'AlertCircle',
},
]
+36
View File
@@ -0,0 +1,36 @@
import type { KPI } from '@/types/pmr'
export const kpis: KPI[] = [
{
id: 'budget',
value: '£220M',
label: 'Budget Oversight',
sub: 'NHS prescribing',
colorVariant: 'green',
explanation: 'Managed the ICB\'s total prescribing budget with sophisticated forecasting models identifying cost pressures and enabling proactive financial planning across Norfolk & Waveney.',
},
{
id: 'savings',
value: '£14.6M',
label: 'Efficiency Savings',
sub: 'Identified & tracked',
colorVariant: 'amber',
explanation: 'Identified and prioritised a £14.6M efficiency programme through comprehensive data analysis; achieved over-target performance through targeted, evidence-based interventions across the integrated care system.',
},
{
id: 'years',
value: '9+',
label: 'Years in NHS',
sub: 'Since 2016',
colorVariant: 'teal',
explanation: 'Continuous NHS service since August 2016, progressing from community pharmacy through prescribing data analysis to system-level population health data leadership.',
},
{
id: 'team',
value: '12',
label: 'Team Size Led',
sub: 'Cross-functional',
colorVariant: 'green',
explanation: 'Led a cross-functional team of 12 spanning data analysts, population health specialists, and pharmacists across data, analytics, and population health workstreams.',
},
]
+1
View File
@@ -0,0 +1 @@
export const personalStatement = `Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics, 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 leading population health analytics for NHS Norfolk & Waveney ICB, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights—from financial scenario modelling and pharmaceutical rebate negotiation to algorithm design and population-level pathway development. Proven track record of identifying and prioritising efficiency programmes worth £14.6M+ through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for executive stakeholders.`
+59
View File
@@ -0,0 +1,59 @@
import type { SkillMedication } from '@/types/pmr'
export const skills: SkillMedication[] = [
{
id: 'data-analysis',
name: 'Data Analysis',
frequency: 'Twice daily',
startYear: 2016,
yearsOfExperience: 9,
proficiency: 95,
category: 'Technical',
status: 'Active',
icon: 'BarChart3',
},
{
id: 'python',
name: 'Python',
frequency: 'Daily',
startYear: 2019,
yearsOfExperience: 6,
proficiency: 90,
category: 'Technical',
status: 'Active',
icon: 'Code2',
},
{
id: 'sql',
name: 'SQL',
frequency: 'Daily',
startYear: 2018,
yearsOfExperience: 7,
proficiency: 88,
category: 'Technical',
status: 'Active',
icon: 'Database',
},
{
id: 'power-bi',
name: 'Power BI',
frequency: 'Once weekly',
startYear: 2020,
yearsOfExperience: 5,
proficiency: 92,
category: 'Technical',
status: 'Active',
icon: 'PieChart',
},
{
id: 'javascript-typescript',
name: 'JavaScript / TypeScript',
frequency: 'When required',
startYear: 2022,
yearsOfExperience: 3,
proficiency: 70,
category: 'Technical',
status: 'Active',
icon: 'FileCode2',
},
]
+9
View File
@@ -0,0 +1,9 @@
import type { Tag } from '@/types/pmr'
export const tags: Tag[] = [
{ label: 'Pharmacist', colorVariant: 'teal' },
{ label: 'Data Lead', colorVariant: 'teal' },
{ label: 'NHS', colorVariant: 'teal' },
{ label: 'Population Health', colorVariant: 'amber' },
{ label: 'BI & Analytics', colorVariant: 'green' },
]
+224 -16
View File
@@ -87,37 +87,70 @@
--coral: #FF6B6B; --coral: #FF6B6B;
--coral-light: rgba(255, 107, 107, 0.08); --coral-light: rgba(255, 107, 107, 0.08);
--muted: #94A3B8; --muted: #94A3B8;
--border: #E2E8F0;
--card-bg: #FFFFFF; --card-bg: #FFFFFF;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.1);
--radius: 16px; --radius: 16px;
--font-primary: 'Plus Jakarta Sans', system-ui, sans-serif; --font-primary: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-secondary: 'Inter Tight', system-ui, sans-serif; --font-secondary: 'Inter Tight', system-ui, sans-serif;
/* PMR-specific tokens */ /* Typography — Elvaro Grotesque primary, Blumir alternative */
--pmr-content: #F5F7FA; --font-ui: 'Elvaro Grotesque', system-ui, sans-serif;
--font-ui-alt: 'Blumir', system-ui, sans-serif;
--font-geist-mono: 'Geist Mono', 'Fira Code', monospace;
/* GP System Dashboard tokens */
--bg-dashboard: #F0F5F4;
--surface: #FFFFFF;
--sidebar-bg: #F7FAFA;
--text-primary: #1A2B2A;
--text-secondary: #5B7A78;
--text-tertiary: #8DA8A5;
--accent: #0D6E6E;
--accent-hover: #0A8080;
--accent-light: rgba(10,128,128,0.08);
--accent-border: rgba(10,128,128,0.18);
--amber: #D97706;
--amber-light: rgba(217,119,6,0.08);
--amber-border: rgba(217,119,6,0.18);
--success: #059669;
--success-light: rgba(5,150,105,0.08);
--success-border: rgba(5,150,105,0.18);
--alert: #DC2626;
--alert-light: rgba(220,38,38,0.08);
--alert-border: rgba(220,38,38,0.18);
--purple: #7C3AED;
--purple-light: rgba(124,58,237,0.08);
--purple-border: rgba(124,58,237,0.18);
--border: #D4E0DE;
--border-light: #E4EDEB;
--sidebar-width: 272px;
--topbar-height: 48px;
--radius-card: 8px;
--radius-sm: 6px;
--shadow-sm: 0 1px 2px rgba(26,43,42,0.05);
--shadow-md: 0 2px 8px rgba(26,43,42,0.08);
--shadow-lg: 0 8px 32px rgba(26,43,42,0.12);
--font-body: var(--font-ui);
--font-mono-dashboard: 'Geist Mono', 'Fira Code', monospace;
/* Legacy PMR tokens — kept for backward compat during transition (cleaned up in Task 21) */
--pmr-content: #F0F5F4;
--pmr-card: #FFFFFF; --pmr-card: #FFFFFF;
--pmr-sidebar: #1E293B; --pmr-sidebar: #F7FAFA;
--pmr-banner: #334155; --pmr-banner: #334155;
--pmr-nhs-blue: #005EB8; --pmr-nhs-blue: #005EB8;
--pmr-green: #22C55E; --pmr-green: #22C55E;
--pmr-amber: #F59E0B; --pmr-amber: #F59E0B;
--pmr-red: #EF4444; --pmr-red: #EF4444;
--pmr-text-primary: #111827; --pmr-text-primary: #1A2B2A;
--pmr-text-secondary: #6B7280; --pmr-text-secondary: #5B7A78;
--pmr-border: #E5E7EB; --pmr-border: #D4E0DE;
--pmr-border-dark: #D1D5DB; --pmr-border-dark: #D1D5DB;
--pmr-selected: #EFF6FF; --pmr-selected: #EFF6FF;
--pmr-alert-bg: #FEF3C7; --pmr-alert-bg: #FEF3C7;
--pmr-alert-border: #F59E0B; --pmr-alert-border: #F59E0B;
--pmr-alert-text: #92400E; --pmr-alert-text: #92400E;
--pmr-radius: 4px; --pmr-radius: 8px;
--pmr-radius-login: 12px; --pmr-radius-login: 12px;
--font-ui: 'Blumir', system-ui, sans-serif;
--font-ui-alt: 'Elvaro Grotesque', system-ui, sans-serif;
--font-geist-mono: 'Geist Mono', 'Fira Code', monospace;
} }
* { * {
@@ -154,8 +187,8 @@ body {
font-family: var(--font-geist-mono); font-family: var(--font-geist-mono);
} }
.pmr-theme { .pmr-theme {
background-color: var(--pmr-content); background-color: var(--bg-dashboard);
color: var(--pmr-text-primary); color: var(--text-primary);
font-family: var(--font-ui); font-family: var(--font-ui);
} }
} }
@@ -199,3 +232,178 @@ body {
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
/* Pulse animation for status badge dot */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Custom scrollbar for sidebar */
.pmr-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.pmr-scrollbar::-webkit-scrollbar {
width: 4px;
}
.pmr-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.pmr-scrollbar::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.pmr-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Dashboard card grid responsive — mobile-first */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
/* Tablet: 2 columns on wider screens */
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
}
/* Desktop: maintain 2 columns with generous gap */
@media (min-width: 1024px) {
.dashboard-grid {
gap: 16px;
}
}
/* KPI flip cards */
.metric-card {
perspective: 1000px;
cursor: pointer;
}
.metric-card-inner {
transition: transform 0.4s ease-in-out;
transform-style: preserve-3d;
position: relative;
}
.metric-card-inner.flipped {
transform: rotateY(180deg);
}
.metric-card-front,
.metric-card-back {
backface-visibility: hidden;
}
.metric-card-back {
transform: rotateY(180deg);
position: absolute;
inset: 0;
}
@media (prefers-reduced-motion: reduce) {
.metric-card-inner {
transition: none;
}
.metric-card-inner.flipped .metric-card-front {
visibility: hidden;
}
.metric-card-inner .metric-card-back {
visibility: hidden;
}
.metric-card-inner.flipped .metric-card-back {
visibility: visible;
transform: none;
position: absolute;
inset: 0;
}
}
/* Activity grid responsive — mobile-first (used in CareerActivityTile) */
.activity-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
/* Tablet and up: 2 columns */
@media (min-width: 768px) {
.activity-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* ===== COMMAND PALETTE ANIMATIONS ===== */
@keyframes palette-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes palette-modal-in {
from { transform: scale(0.97) translateY(-8px); opacity: 0; }
to { transform: scale(1) translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes palette-overlay-in {
from { opacity: 1; }
to { opacity: 1; }
}
@keyframes palette-modal-in {
from { transform: none; opacity: 1; }
to { transform: none; opacity: 1; }
}
}
/* ===== FOCUS VISIBLE STYLES (WCAG Compliance) ===== */
/* Default focus ring for all focusable elements */
*:focus-visible {
outline: 2px solid rgba(13, 110, 110, 0.4);
outline-offset: 2px;
}
/* Button-like interactive elements */
button:focus-visible,
[role="button"]:focus-visible,
[role="option"]:focus-visible {
outline: 2px solid rgba(13, 110, 110, 0.4);
outline-offset: 2px;
}
/* Links */
a:focus-visible {
outline: 2px solid rgba(13, 110, 110, 0.4);
outline-offset: 2px;
}
/* Inputs and textareas */
input:focus-visible,
textarea:focus-visible {
outline: 2px solid rgba(13, 110, 110, 0.6);
outline-offset: 0px;
}
@media (prefers-reduced-motion: reduce) {
/* Disable pulse animation on status badge dot */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 1; }
}
}
+308 -66
View File
@@ -1,12 +1,298 @@
import Fuse, { type FuseResult } from 'fuse.js' import Fuse, { type FuseResult } from 'fuse.js'
import type { ViewId } from '@/types/pmr' import type { ViewId } from '@/types/pmr'
// Import all data sources
import { consultations } from '@/data/consultations' import { consultations } from '@/data/consultations'
import { medications } from '@/data/medications' import { medications } from '@/data/medications'
import { problems } from '@/data/problems' import { problems } from '@/data/problems'
import { investigations } from '@/data/investigations' import { investigations } from '@/data/investigations'
import { documents } from '@/data/documents' import { documents } from '@/data/documents'
import { skills } from '@/data/skills'
export type PaletteSection = 'Experience' | 'Core Skills' | 'Active Projects' | 'Achievements' | 'Education' | 'Quick Actions'
export type PaletteAction =
| { type: 'scroll'; tileId: string }
| { type: 'expand'; tileId: string; itemId: string }
| { type: 'link'; url: string }
| { type: 'download' }
export type IconColorVariant = 'teal' | 'green' | 'amber' | 'purple'
export interface PaletteItem {
id: string
title: string
subtitle: string
section: PaletteSection
iconVariant: IconColorVariant
iconType: 'role' | 'skill' | 'project' | 'achievement' | 'edu' | 'action'
keywords: string
action: PaletteAction
}
// Build the full palette dataset matching the concept HTML structure
export function buildPaletteData(): PaletteItem[] {
const items: PaletteItem[] = []
// Experience — matching concept HTML entries
const experienceEntries: Array<{ title: string; sub: string; keywords: string; activityId: string }> = [
{
title: 'Interim Head, Population Health & Data Analysis',
sub: 'NHS Norfolk & Waveney ICB \u00b7 2024\u20132025',
keywords: 'head interim population health data analysis nhs norfolk waveney icb 2024 2025 latest current',
activityId: 'interim-head',
},
{
title: 'Senior Data Analyst \u2014 Medicines Optimisation',
sub: 'NHS Norfolk & Waveney ICB \u00b7 2021\u20132024',
keywords: 'senior data analyst medicines optimisation nhs norfolk waveney icb 2021 2024',
activityId: 'senior-analyst',
},
{
title: 'Prescribing Data Pharmacist',
sub: 'NHS Norwich CCG \u00b7 2018\u20132021',
keywords: 'prescribing data pharmacist nhs norwich ccg 2018 2021',
activityId: 'prescribing-pharmacist',
},
{
title: 'Community Pharmacist',
sub: 'Boots UK \u00b7 2016\u20132018',
keywords: 'community pharmacist boots uk 2016 2018',
activityId: 'community-pharmacist',
},
]
experienceEntries.forEach((entry, i) => {
items.push({
id: `exp-${i}`,
title: entry.title,
subtitle: entry.sub,
section: 'Experience',
iconVariant: 'teal',
iconType: 'role',
keywords: entry.keywords,
action: { type: 'expand', tileId: 'career-activity', itemId: entry.activityId },
})
})
// Core Skills — from skills.ts, matching concept format with proficiency %
const skillDescriptions: Record<string, string> = {
'Data Analysis': 'Primary expertise \u00b7 NHS population data',
'Python': 'Data pipelines, automation, analytics',
'SQL': 'Advanced queries, database migration',
'Power BI': 'Dashboard design & deployment',
'JavaScript / TypeScript': 'Web development & tooling',
}
skills.forEach((skill) => {
items.push({
id: `skill-${skill.id}`,
title: `${skill.name} \u2014 ${skill.proficiency}%`,
subtitle: skillDescriptions[skill.name] ?? `${skill.frequency} \u00b7 Since ${skill.startYear}`,
section: 'Core Skills',
iconVariant: 'green',
iconType: 'skill',
keywords: `${skill.name.toLowerCase()} ${skill.proficiency} ${skill.frequency.toLowerCase()}`,
action: { type: 'expand', tileId: 'core-skills', itemId: skill.id },
})
})
// Active Projects — matching concept HTML entries
const projectEntries: Array<{ name: string; sub: string; keywords: string; investigationId: string }> = [
{
name: '\u00a3220M Prescribing Budget',
sub: 'Budget oversight & analytical accountability \u00b7 2024',
keywords: '220m prescribing budget oversight analytical accountability 2024',
investigationId: 'inv-pharmetrics',
},
{
name: 'SQL Analytics Transformation',
sub: 'Legacy migration to modern data stack \u00b7 2025',
keywords: 'sql analytics transformation legacy migration modern data stack 2025',
investigationId: 'inv-switching-algorithm',
},
{
name: 'Team Data Literacy Programme',
sub: 'Upskilling 30+ non-technical staff \u00b7 2024',
keywords: 'team data literacy programme upskilling non-technical staff 2024 training',
investigationId: 'inv-blueteq-gen',
},
]
projectEntries.forEach((entry) => {
items.push({
id: `proj-${entry.investigationId}`,
title: entry.name,
subtitle: entry.sub,
section: 'Active Projects',
iconVariant: 'amber',
iconType: 'project',
keywords: entry.keywords,
action: { type: 'expand', tileId: 'projects', itemId: entry.investigationId },
})
})
// Achievements — matching concept HTML entries
const achievementEntries: Array<{ title: string; sub: string; keywords: string }> = [
{
title: '\u00a314.6M Efficiency Savings Identified',
sub: 'Data-driven prescribing interventions',
keywords: '14.6m efficiency savings identified data-driven prescribing interventions money cost',
},
{
title: '\u00a3220M Budget Oversight',
sub: 'Full analytical accountability to ICB board',
keywords: '220m budget oversight analytical accountability icb board',
},
{
title: 'Power BI Dashboards for 200+ Users',
sub: 'Clinicians & commissioners across ICB',
keywords: 'power bi dashboards 200 users clinicians commissioners',
},
{
title: 'Team of 12 Led',
sub: 'Cross-functional data & population health',
keywords: 'team 12 led cross-functional data population health leadership management',
},
]
achievementEntries.forEach((entry, i) => {
items.push({
id: `ach-${i}`,
title: entry.title,
subtitle: entry.sub,
section: 'Achievements',
iconVariant: 'amber',
iconType: 'achievement',
keywords: entry.keywords,
action: { type: 'scroll', tileId: 'latest-results' },
})
})
// Education — matching concept HTML entries
const educationEntries: Array<{ title: string; sub: string; keywords: string }> = [
{
title: 'MPharm (Hons) \u2014 2:1',
sub: 'University of East Anglia \u00b7 2011\u20132015',
keywords: 'mpharm hons 2:1 university east anglia uea 2011 2015 pharmacy degree',
},
{
title: 'GPhC Registration',
sub: 'General Pharmaceutical Council \u00b7 August 2016',
keywords: 'gphc registration general pharmaceutical council 2016 registered',
},
{
title: 'Power BI Data Analyst Associate',
sub: 'Microsoft Certified \u00b7 2023',
keywords: 'power bi data analyst associate microsoft certified 2023 certification',
},
{
title: 'Clinical Pharmacy Diploma',
sub: 'Professional development \u00b7 2019',
keywords: 'clinical pharmacy diploma professional development 2019',
},
]
educationEntries.forEach((entry, i) => {
items.push({
id: `edu-${i}`,
title: entry.title,
subtitle: entry.sub,
section: 'Education',
iconVariant: 'purple',
iconType: 'edu',
keywords: entry.keywords,
action: { type: 'scroll', tileId: 'education' },
})
})
// Quick Actions
const quickActions: Array<{ title: string; sub: string; keywords: string; action: PaletteAction }> = [
{
title: 'Download CV',
sub: 'Export as PDF',
keywords: 'download cv export pdf resume',
action: { type: 'download' },
},
{
title: 'Send Email',
sub: 'andy@charlwood.xyz',
keywords: 'send email contact andy charlwood',
action: { type: 'link', url: 'mailto:andy@charlwood.xyz' },
},
{
title: 'View LinkedIn',
sub: 'Professional profile',
keywords: 'view linkedin professional profile social',
action: { type: 'link', url: 'https://linkedin.com/in/andycharlwood' },
},
{
title: 'View Projects',
sub: 'GitHub & portfolio',
keywords: 'view projects github portfolio code repositories',
action: { type: 'link', url: 'https://github.com/andycharlwood' },
},
]
quickActions.forEach((entry, i) => {
items.push({
id: `action-${i}`,
title: entry.title,
subtitle: entry.sub,
section: 'Quick Actions',
iconVariant: 'teal',
iconType: 'action',
keywords: entry.keywords,
action: entry.action,
})
})
return items
}
// Build a fuse.js search index from palette items
export function buildSearchIndex(items: PaletteItem[]): Fuse<PaletteItem> {
return new Fuse(items, {
keys: [
{ name: 'title', weight: 2 },
{ name: 'subtitle', weight: 1 },
{ name: 'keywords', weight: 1.5 },
],
threshold: 0.3,
includeScore: true,
minMatchCharLength: 2,
})
}
// Section ordering for grouped display
const SECTION_ORDER: PaletteSection[] = [
'Experience',
'Core Skills',
'Active Projects',
'Achievements',
'Education',
'Quick Actions',
]
// Group palette items by section, maintaining defined order
export function groupBySection(items: PaletteItem[]): Array<{ section: PaletteSection; items: PaletteItem[] }> {
const groups = new Map<PaletteSection, PaletteItem[]>()
for (const item of items) {
const existing = groups.get(item.section)
if (existing) {
existing.push(item)
} else {
groups.set(item.section, [item])
}
}
return SECTION_ORDER
.filter(section => groups.has(section))
.map(section => ({ section, items: groups.get(section)! }))
}
// ===== LEGACY EXPORTS =====
// Used by ClinicalSidebar.tsx (old component, will be removed in Task 21)
export interface SearchResult { export interface SearchResult {
id: string id: string
@@ -17,83 +303,40 @@ export interface SearchResult {
score?: number score?: number
} }
// Build a unified search index from all PMR content /** @deprecated Use buildPaletteData() + buildSearchIndex() instead */
export function buildSearchIndex(): Fuse<SearchResult> { export function buildLegacySearchIndex(): Fuse<SearchResult> {
const searchableItems: SearchResult[] = [] const searchableItems: SearchResult[] = []
// Index consultations (Experience) consultations.forEach(c => {
consultations.forEach(consultation => { searchableItems.push({ id: c.id, title: c.role, section: 'consultations', sectionLabel: 'Experience', highlight: `${c.role} at ${c.organization}${c.history}` })
searchableItems.push({
id: consultation.id,
title: consultation.role,
section: 'consultations',
sectionLabel: 'Experience',
highlight: `${consultation.role} at ${consultation.organization}${consultation.history}`,
}) })
medications.forEach(m => {
searchableItems.push({ id: m.id, title: m.name, section: 'medications', sectionLabel: 'Skills', highlight: `${m.name}${m.frequency} use since ${m.startYear}` })
})
problems.forEach(p => {
searchableItems.push({ id: p.id, title: p.description, section: 'problems', sectionLabel: 'Achievements', highlight: `[${p.code}] ${p.description}${p.narrative}` })
})
investigations.forEach(inv => {
searchableItems.push({ id: inv.id, title: inv.name, section: 'investigations', sectionLabel: 'Projects', highlight: `${inv.name}${inv.methodology}` })
})
documents.forEach(doc => {
searchableItems.push({ id: doc.id, title: doc.title, section: 'documents', sectionLabel: 'Education', highlight: `${doc.title} from ${doc.source} (${doc.date})` })
}) })
// Index medications (Skills) return new Fuse(searchableItems, {
medications.forEach(medication => {
searchableItems.push({
id: medication.id,
title: medication.name,
section: 'medications',
sectionLabel: 'Skills',
highlight: `${medication.name}${medication.frequency} use since ${medication.startYear}`,
})
})
// Index problems (Achievements)
problems.forEach(problem => {
searchableItems.push({
id: problem.id,
title: problem.description,
section: 'problems',
sectionLabel: 'Achievements',
highlight: `[${problem.code}] ${problem.description}${problem.narrative}`,
})
})
// Index investigations (Projects)
investigations.forEach(investigation => {
searchableItems.push({
id: investigation.id,
title: investigation.name,
section: 'investigations',
sectionLabel: 'Projects',
highlight: `${investigation.name}${investigation.methodology}`,
})
})
// Index documents (Education)
documents.forEach(document => {
searchableItems.push({
id: document.id,
title: document.title,
section: 'documents',
sectionLabel: 'Education',
highlight: `${document.title} from ${document.source} (${document.date})`,
})
})
// Fuse.js configuration for fuzzy search
const fuseOptions = {
keys: [ keys: [
{ name: 'title', weight: 2 }, // Primary match on title { name: 'title', weight: 2 },
{ name: 'highlight', weight: 1 }, // Secondary match on full text { name: 'highlight', weight: 1 },
], ],
threshold: 0.3, // 0 = exact match, 1 = match anything threshold: 0.3,
includeScore: true, includeScore: true,
minMatchCharLength: 2, minMatchCharLength: 2,
} })
return new Fuse(searchableItems, fuseOptions)
} }
// Group search results by section /** @deprecated Use groupBySection() instead */
export function groupResultsBySection(results: FuseResult<SearchResult>[]): Map<string, FuseResult<SearchResult>[]> { export function groupResultsBySection(results: FuseResult<SearchResult>[]): Map<string, FuseResult<SearchResult>[]> {
const grouped = new Map<string, FuseResult<SearchResult>[]>() const grouped = new Map<string, FuseResult<SearchResult>[]>()
results.forEach(result => { results.forEach(result => {
const sectionLabel = result.item.sectionLabel const sectionLabel = result.item.sectionLabel
if (!grouped.has(sectionLabel)) { if (!grouped.has(sectionLabel)) {
@@ -101,6 +344,5 @@ export function groupResultsBySection(results: FuseResult<SearchResult>[]): Map<
} }
grouped.get(sectionLabel)!.push(result) grouped.get(sectionLabel)!.push(result)
}) })
return grouped return grouped
} }
+32
View File
@@ -113,3 +113,35 @@ export interface ReferralFormData {
reason: string reason: string
contactMethod: 'Email' | 'Phone' | 'LinkedIn' contactMethod: 'Email' | 'Phone' | 'LinkedIn'
} }
export interface Tag {
label: string
colorVariant: 'teal' | 'amber' | 'green'
}
export interface Alert {
message: string
severity: 'alert' | 'amber'
icon: string
}
export interface KPI {
id: string
value: string
label: string
sub: string
colorVariant: 'green' | 'amber' | 'teal'
explanation: string
}
export interface SkillMedication {
id: string
name: string
frequency: string
startYear: number
yearsOfExperience: number
proficiency: number
category: 'Technical' | 'Domain' | 'Leadership'
status: 'Active' | 'Historical'
icon: string
}
+32 -20
View File
@@ -34,19 +34,30 @@ export default {
grey: '#666666', grey: '#666666',
}, },
pmr: { pmr: {
sidebar: '#1E293B', // GP System Dashboard palette
banner: '#334155', 'bg': '#F0F5F4',
content: '#F5F7FA', 'surface': '#FFFFFF',
card: '#FFFFFF', 'sidebar': '#F7FAFA',
nhsblue: '#005EB8', 'accent': '#0D6E6E',
green: '#22C55E', 'accent-hover': '#0A8080',
amber: '#F59E0B', 'text-primary': '#1A2B2A',
red: '#EF4444', 'text-secondary': '#5B7A78',
'text-primary': '#111827', 'text-tertiary': '#8DA8A5',
'text-secondary': '#6B7280', 'border': '#D4E0DE',
'border-light': '#E4EDEB',
'success': '#059669',
'amber': '#D97706',
'alert': '#DC2626',
'purple': '#7C3AED',
// Legacy tokens kept for transition (Task 21 cleanup)
'nhsblue': '#005EB8',
'content': '#F0F5F4',
'card': '#FFFFFF',
'banner': '#334155',
'green': '#22C55E',
'red': '#EF4444',
'text-on-dark': '#FFFFFF', 'text-on-dark': '#FFFFFF',
'text-on-dark-secondary': '#94A3B8', 'text-on-dark-secondary': '#94A3B8',
'border': '#E5E7EB',
'border-dark': '#D1D5DB', 'border-dark': '#D1D5DB',
'selected-row': '#EFF6FF', 'selected-row': '#EFF6FF',
'alert-bg': '#FEF3C7', 'alert-bg': '#FEF3C7',
@@ -58,20 +69,21 @@ export default {
primary: ['Plus Jakarta Sans', 'system-ui', 'sans-serif'], primary: ['Plus Jakarta Sans', 'system-ui', 'sans-serif'],
secondary: ['Inter Tight', 'system-ui', 'sans-serif'], secondary: ['Inter Tight', 'system-ui', 'sans-serif'],
mono: ['Fira Code', 'monospace'], mono: ['Fira Code', 'monospace'],
ui: ['Blumir', 'system-ui', 'sans-serif'], ui: ['Elvaro Grotesque', 'system-ui', 'sans-serif'],
'ui-alt': ['Elvaro Grotesque', 'system-ui', 'sans-serif'], 'ui-alt': ['Blumir', 'system-ui', 'sans-serif'],
geist: ['Geist Mono', 'Fira Code', 'monospace'], geist: ['Geist Mono', 'Fira Code', 'monospace'],
}, },
boxShadow: { boxShadow: {
'sm': '0 1px 3px rgba(0,0,0,0.06)', // GP System three-tier shadow system
'md': '0 4px 12px rgba(0,0,0,0.08)', 'pmr-sm': '0 1px 2px rgba(26,43,42,0.05)',
'lg': '0 8px 24px rgba(0,0,0,0.1)', 'pmr-md': '0 2px 8px rgba(26,43,42,0.08)',
'pmr': '0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03)', 'pmr-lg': '0 8px 32px rgba(26,43,42,0.12)',
'pmr-hover': '0 2px 4px rgba(0,0,0,0.06), 0 8px 16px rgba(0,0,0,0.04)', // Legacy alias
'pmr-banner': '0 2px 8px rgba(0,0,0,0.12)', 'pmr': '0 1px 2px rgba(26,43,42,0.05)',
}, },
borderRadius: { borderRadius: {
'card': '4px', 'card': '8px',
'card-sm': '6px',
'login': '12px', 'login': '12px',
}, },
}, },