feat: build ProblemsView with traffic light status system

- Create ProblemsView with two tables: Active Problems and Resolved Problems
- Traffic light indicators: 8px circles with text labels (green=Active/Resolved, amber=In Progress)
- Expandable rows showing full narrative and linked consultations
- Linked consultations navigate to Consultations view
- Proper semantic table markup with scope="col"
- Height animation for expand/collapse (200ms, respects reduced motion)
- Task 9 complete
This commit is contained in:
2026-02-11 01:58:32 +00:00
parent 81e8fdf7c7
commit f20791a7ff
4 changed files with 328 additions and 1 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ src/
Create `src/components/views/MedicationsView.tsx`. Three category tabs: Active Medications (technical skills), Clinical Medications (healthcare domain skills), PRN (strategic skills). Each tab shows a table: Drug Name | Dose (%) | Frequency | Start | Status. Sortable columns: clicking header sorts (asc/desc toggle). Default sort: by category grouping. Table styling: gray-200 borders, alternating row colors, 40px row height. Hover: subtle blue tint (#EFF6FF). Click row to expand "Prescribing History" — mini-timeline showing skill progression (year + description). History styled in Geist Mono. 18 total medications mapped from CV skills with accurate proficiency percentages and usage frequencies.
- [ ] **Task 9: Build ProblemsView with traffic light system**
- [x] **Task 9: Build ProblemsView with traffic light system**
Create `src/components/views/ProblemsView.tsx`. Two sections: Active Problems and Resolved Problems. Table columns: Status (traffic light dot), Code (SNOMED-style in Geist Mono), Problem description, Since/Resolved date, Outcome (for resolved). Traffic lights: 8px circles — green (resolved/current), amber (in progress), gray (inactive/historical). Active problems: £220M budget oversight, SQL transformation, data literacy programme. Resolved problems: 8 achievements with specific outcomes ("Python algorithm: 14,000 pts, £2.6M/yr", "70% reduction, 200hrs saved", etc.). Click row to expand full narrative with "linked consultations" navigation.
+30
View File
@@ -276,3 +276,33 @@ This is a complete redesign of the CV presentation, moving from the ECG animatio
- Case block lexical declarations need curly braces wrapping to satisfy ESLint
- Sort state with three values (null/asc/desc) provides intuitive toggle behavior
- Frequency sort uses custom order object mapping
### Iteration 9 — Task 9: Build ProblemsView with traffic light system
- **Completed**: Task 9 - Created ProblemsView with two tables and expandable narrative
- **Files created**:
- `src/components/views/ProblemsView.tsx` - Full Problems view with Active and Resolved sections
- **Files modified**:
- `src/components/PMRInterface.tsx` - Added ProblemsView to renderView switch
- **Design decisions**:
- Two sections: Active Problems (3 items) and Resolved Problems (8 items)
- Traffic light component: 8px circles with text labels (green=Active/Resolved, amber=In Progress)
- Active Problems table: Status, Code, Problem, Since columns
- Resolved Problems table: Status, Code, Problem, Resolved, Outcome columns
- Code column: [XXX000] format in Geist Mono, gray-500
- Expandable rows: click to show narrative and linked consultations
- Linked consultations: clickable buttons navigate to Consultations view with item ID
- Height animation: 200ms ease-out for expand/collapse
- Hover state: blue-50 (#EFF6FF) background tint
- Accordion behavior: only one row expanded at a time (per section? globally? went with global)
- **Accessibility**:
- Proper semantic `<table>` markup with `scope="col"` on headers
- `aria-expanded` on clickable rows
- Traffic lights have `aria-label` with status text
- Expand button has `aria-label` for screen readers
- Screen reader-only column header for expand button
- Respects `prefers-reduced-motion`: height transition disabled
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- `problem.linkedConsultations` needed null coalescing since it's optional in the type
- Height animation uses refs to measure content height for smooth expansion
- Linked consultations use external link icon to indicate navigation
+3
View File
@@ -5,6 +5,7 @@ import { PatientBanner } from './PatientBanner'
import { SummaryView } from './views/SummaryView'
import { ConsultationsView } from './views/ConsultationsView'
import { MedicationsView } from './views/MedicationsView'
import { ProblemsView } from './views/ProblemsView'
interface PMRInterfaceProps {
children?: React.ReactNode
@@ -43,6 +44,8 @@ export function PMRInterface({ children }: PMRInterfaceProps) {
return <ConsultationsView />
case 'medications':
return <MedicationsView />
case 'problems':
return <ProblemsView onNavigate={handleNavigate} />
default:
return (
<div className="bg-white border border-gray-200 rounded p-6">
+294
View File
@@ -0,0 +1,294 @@
import { useState, useEffect, useRef } from 'react'
import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'
import { problems } from '@/data/problems'
import { consultations } from '@/data/consultations'
import type { Problem, Consultation } from '@/types/pmr'
interface ProblemsViewProps {
onNavigate?: (view: 'consultations', itemId?: string) => void
}
type ProblemStatus = 'Active' | 'In Progress' | 'Resolved'
function TrafficLight({ status }: { status: ProblemStatus }) {
const colorMap: Record<ProblemStatus, { bg: string; label: string }> = {
Active: { bg: 'bg-green-500', label: 'Active' },
'In Progress': { bg: 'bg-amber-500', label: 'In Progress' },
Resolved: { bg: 'bg-green-500', label: 'Resolved' },
}
const { bg, label } = colorMap[status]
return (
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${bg}`}
aria-label={`Status: ${label}`}
role="img"
/>
<span className="text-xs text-gray-600">{label}</span>
</div>
)
}
function ProblemRow({
problem,
isExpanded,
onToggle,
onNavigate,
showOutcome,
}: {
problem: Problem
isExpanded: boolean
onToggle: () => void
onNavigate?: (view: 'consultations', itemId?: string) => void
showOutcome: boolean
}) {
const contentRef = useRef<HTMLDivElement>(null)
const [contentHeight, setContentHeight] = useState<number | undefined>(undefined)
const prefersReducedMotion = useRef(
window.matchMedia('(prefers-reduced-motion: reduce)').matches
).current
useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight)
}
}, [isExpanded])
const linkedConsultations = (problem.linkedConsultations ?? [])
.map((id) => consultations.find((c) => c.id === id))
.filter((c): c is Consultation => c !== undefined)
const handleLinkedClick = (consultationId: string) => {
if (onNavigate) {
onNavigate('consultations', consultationId)
}
}
return (
<>
<tr
className={`cursor-pointer hover:bg-blue-50 transition-colors ${
isExpanded ? 'bg-blue-50' : ''
}`}
onClick={onToggle}
aria-expanded={isExpanded}
>
<td className="border border-gray-200 px-3 py-2.5">
<TrafficLight status={problem.status} />
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="font-mono text-xs text-gray-500">[{problem.code}]</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="text-sm text-gray-900">{problem.description}</span>
</td>
<td className="border border-gray-200 px-3 py-2.5">
<span className="font-mono text-xs text-gray-500">
{problem.resolved || problem.since}
</span>
</td>
{showOutcome && (
<td className="border border-gray-200 px-3 py-2.5">
{problem.outcome && (
<span className="text-sm text-gray-700">{problem.outcome}</span>
)}
</td>
)}
<td className="border border-gray-200 px-3 py-2.5 w-10">
<button
className="p-1 hover:bg-gray-100 rounded transition-colors"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</button>
</td>
</tr>
<tr>
<td colSpan={showOutcome ? 6 : 5} className="p-0 border border-gray-200">
<div
style={{
height: isExpanded ? contentHeight : 0,
overflow: 'hidden',
transition: prefersReducedMotion ? 'none' : 'height 200ms ease-out',
}}
>
<div ref={contentRef} className="bg-gray-50 p-4">
<div className="text-sm text-gray-700 leading-relaxed mb-4">
{problem.narrative}
</div>
{linkedConsultations.length > 0 && (
<div>
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Linked Consultations:
</span>
<div className="mt-2 flex flex-wrap gap-2">
{linkedConsultations.map((consultation) => (
<button
key={consultation.id}
onClick={(e) => {
e.stopPropagation()
handleLinkedClick(consultation.id)
}}
className="inline-flex items-center gap-1 text-xs text-nhsblue hover:underline"
>
<ExternalLink className="w-3 h-3" />
{consultation.organization} {consultation.role}
</button>
))}
</div>
</div>
)}
</div>
</div>
</td>
</tr>
</>
)
}
export function ProblemsView({ onNavigate }: ProblemsViewProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const activeProblems = problems.filter(
(p) => p.status === 'Active' || p.status === 'In Progress'
)
const resolvedProblems = problems.filter((p) => p.status === 'Resolved')
const handleToggle = (id: string) => {
setExpandedId(expandedId === id ? null : id)
}
return (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Active Problems
</h2>
</div>
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50">
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Status
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Code
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Problem
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Since
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
>
<span className="sr-only">Expand</span>
</th>
</tr>
</thead>
<tbody>
{activeProblems.map((problem) => (
<ProblemRow
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={false}
/>
))}
</tbody>
</table>
{activeProblems.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No active problems</div>
)}
</div>
<div className="bg-white border border-gray-200 rounded">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
<h2 className="font-inter font-semibold text-sm uppercase tracking-wider text-gray-500">
Resolved Problems
</h2>
</div>
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50">
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Status
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Code
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Problem
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-28"
>
Resolved
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400"
>
Outcome
</th>
<th
scope="col"
className="border border-gray-200 px-3 py-2 text-left font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 w-10"
>
<span className="sr-only">Expand</span>
</th>
</tr>
</thead>
<tbody>
{resolvedProblems.map((problem) => (
<ProblemRow
key={problem.id}
problem={problem}
isExpanded={expandedId === problem.id}
onToggle={() => handleToggle(problem.id)}
onNavigate={onNavigate}
showOutcome={true}
/>
))}
</tbody>
</table>
{resolvedProblems.length === 0 && (
<div className="p-4 text-sm text-gray-500 text-center">No resolved problems</div>
)}
</div>
</div>
)
}