From aafdeba93eebe441c626146023d27dca8fe7467f Mon Sep 17 00:00:00 2001 From: A Charlwood Date: Fri, 13 Feb 2026 17:54:31 +0000 Subject: [PATCH] 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 --- src/components/Card.tsx | 4 +- src/components/ClinicalSidebar.tsx | 4 +- src/components/CommandPalette.tsx | 432 ++++++++++++++++++ src/components/DashboardLayout.tsx | 64 ++- src/components/tiles/CareerActivityTile.tsx | 2 +- src/components/tiles/CoreSkillsTile.tsx | 2 +- src/components/tiles/EducationTile.tsx | 2 +- src/components/tiles/LastConsultationTile.tsx | 2 +- src/components/tiles/LatestResultsTile.tsx | 2 +- src/components/tiles/PatientSummaryTile.tsx | 2 +- src/components/tiles/ProjectsTile.tsx | 2 +- src/index.css | 22 + src/lib/search.ts | 376 ++++++++++++--- 13 files changed, 836 insertions(+), 80 deletions(-) create mode 100644 src/components/CommandPalette.tsx diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 7b04a4c..094e88f 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -4,9 +4,10 @@ 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 }: CardProps) { +export function Card({ children, full, className, tileId }: CardProps) { const [isHovered, setIsHovered] = React.useState(false) const baseStyles: React.CSSProperties = { @@ -25,6 +26,7 @@ export function Card({ children, full, className }: CardProps) {
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > diff --git a/src/components/ClinicalSidebar.tsx b/src/components/ClinicalSidebar.tsx index c2c9c3d..736741a 100644 --- a/src/components/ClinicalSidebar.tsx +++ b/src/components/ClinicalSidebar.tsx @@ -12,7 +12,7 @@ import { } from 'lucide-react' import type { ViewId } from '../types/pmr' 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' interface NavItem { @@ -55,7 +55,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }: const { focusAfterLoginRef, setExpandedItem } = useAccessibility() // Build search index once on mount - const searchIndex = useMemo(() => buildSearchIndex(), []) + const searchIndex = useMemo(() => buildLegacySearchIndex(), []) const handleNavClick = useCallback( (view: ViewId) => { diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..d78f3f0 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,432 @@ +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 = { + role: User, + skill: Activity, + project: Monitor, + achievement: Award, + edu: GraduationCap, + action: Zap, +} + +// Color variant → CSS variable mapping for icon containers +const iconColorStyles: Record = { + 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(null) + const resultsRef = useRef(null) + const overlayRef = useRef(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 ( +
+ {/* Palette modal */} +
+ {/* Search input row */} +
+
+ + {/* Results area */} +
+ {flatItems.length === 0 ? ( +
+ No results found for “{query}” +
+ ) : ( + 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 ( +
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 */} +
+ {IconComponent && } +
+ + {/* Text */} +
+
{item.title}
+
+ {item.subtitle} +
+
+
+ ) + }) + + return ( +
+ {/* Section label */} +
+ {group.section} +
+ {sectionItems} +
+ ) + }) + )} +
+ + {/* Footer with keyboard hints */} +
+ + \u2191 \u2193 Navigate + + + Enter Select + + + Esc Close + +
+
+
+ ) +} + +// Small kbd element for the footer +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index 6f9e25a..d165c23 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react' +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' @@ -9,6 +10,7 @@ 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 @@ -45,12 +47,63 @@ const contentVariants = { } export function DashboardLayout() { - const [, setCommandPaletteOpen] = useState(false) + 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 (
- {/* Command palette will be rendered here (Task 18) */} + {/* Command palette overlay */} +
) } diff --git a/src/components/tiles/CareerActivityTile.tsx b/src/components/tiles/CareerActivityTile.tsx index 8503330..edb3f94 100644 --- a/src/components/tiles/CareerActivityTile.tsx +++ b/src/components/tiles/CareerActivityTile.tsx @@ -374,7 +374,7 @@ export const CareerActivityTile: React.FC = () => { ) return ( - +
diff --git a/src/components/tiles/CoreSkillsTile.tsx b/src/components/tiles/CoreSkillsTile.tsx index 35a9812..ff03e42 100644 --- a/src/components/tiles/CoreSkillsTile.tsx +++ b/src/components/tiles/CoreSkillsTile.tsx @@ -233,7 +233,7 @@ export function CoreSkillsTile() { ) return ( - +
{skills.map((skill) => ( diff --git a/src/components/tiles/EducationTile.tsx b/src/components/tiles/EducationTile.tsx index 2cecc56..2690c07 100644 --- a/src/components/tiles/EducationTile.tsx +++ b/src/components/tiles/EducationTile.tsx @@ -22,7 +22,7 @@ export function EducationTile() { ] return ( - +
diff --git a/src/components/tiles/LastConsultationTile.tsx b/src/components/tiles/LastConsultationTile.tsx index 685fc70..b0e5e3f 100644 --- a/src/components/tiles/LastConsultationTile.tsx +++ b/src/components/tiles/LastConsultationTile.tsx @@ -30,7 +30,7 @@ export const LastConsultationTile: React.FC = () => { } return ( - + {/* Header info row */} diff --git a/src/components/tiles/LatestResultsTile.tsx b/src/components/tiles/LatestResultsTile.tsx index 1c60969..458d33b 100644 --- a/src/components/tiles/LatestResultsTile.tsx +++ b/src/components/tiles/LatestResultsTile.tsx @@ -109,7 +109,7 @@ export function LatestResultsTile() { } return ( - +
{kpis.map((kpi) => ( diff --git a/src/components/tiles/PatientSummaryTile.tsx b/src/components/tiles/PatientSummaryTile.tsx index 25dd256..9e77767 100644 --- a/src/components/tiles/PatientSummaryTile.tsx +++ b/src/components/tiles/PatientSummaryTile.tsx @@ -10,7 +10,7 @@ export function PatientSummaryTile() { } return ( - +
{personalStatement}
diff --git a/src/components/tiles/ProjectsTile.tsx b/src/components/tiles/ProjectsTile.tsx index 864b44b..7d4c690 100644 --- a/src/components/tiles/ProjectsTile.tsx +++ b/src/components/tiles/ProjectsTile.tsx @@ -257,7 +257,7 @@ export function ProjectsTile() { ) return ( - +
diff --git a/src/index.css b/src/index.css index c175242..4098b8d 100644 --- a/src/index.css +++ b/src/index.css @@ -337,3 +337,25 @@ html { grid-template-columns: 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; } + } +} diff --git a/src/lib/search.ts b/src/lib/search.ts index 8762042..c47a69b 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -1,12 +1,298 @@ import Fuse, { type FuseResult } from 'fuse.js' import type { ViewId } from '@/types/pmr' -// Import all data sources import { consultations } from '@/data/consultations' import { medications } from '@/data/medications' import { problems } from '@/data/problems' import { investigations } from '@/data/investigations' 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 = { + '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 { + 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() + + 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 { id: string @@ -17,83 +303,40 @@ export interface SearchResult { score?: number } -// Build a unified search index from all PMR content -export function buildSearchIndex(): Fuse { +/** @deprecated Use buildPaletteData() + buildSearchIndex() instead */ +export function buildLegacySearchIndex(): Fuse { const searchableItems: SearchResult[] = [] - // Index consultations (Experience) - consultations.forEach(consultation => { - searchableItems.push({ - id: consultation.id, - title: consultation.role, - section: 'consultations', - sectionLabel: 'Experience', - highlight: `${consultation.role} at ${consultation.organization} — ${consultation.history}`, - }) + consultations.forEach(c => { + searchableItems.push({ id: c.id, title: c.role, section: 'consultations', sectionLabel: 'Experience', highlight: `${c.role} at ${c.organization} — ${c.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) - 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 = { + return new Fuse(searchableItems, { keys: [ - { name: 'title', weight: 2 }, // Primary match on title - { name: 'highlight', weight: 1 }, // Secondary match on full text + { name: 'title', weight: 2 }, + { name: 'highlight', weight: 1 }, ], - threshold: 0.3, // 0 = exact match, 1 = match anything + threshold: 0.3, includeScore: true, minMatchCharLength: 2, - } - - return new Fuse(searchableItems, fuseOptions) + }) } -// Group search results by section +/** @deprecated Use groupBySection() instead */ export function groupResultsBySection(results: FuseResult[]): Map[]> { const grouped = new Map[]>() - results.forEach(result => { const sectionLabel = result.item.sectionLabel if (!grouped.has(sectionLabel)) { @@ -101,6 +344,5 @@ export function groupResultsBySection(results: FuseResult[]): Map< } grouped.get(sectionLabel)!.push(result) }) - return grouped }