diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index d1d6bc80..dc7d5cdf 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -355,6 +355,12 @@ store). Every patient API call and React Query key includes the selected - OpenStreetMap tiles with attribution. - Patient location is requested with transparent consent, kept strictly in transient memory, and NEVER persisted to storage or URLs. +#### Doctor search & symptom-based search (issues #1/#30; docforum-core#23 and #60 not landed) +- `GET /symptoms?q=&limit=` → `SymptomSuggestion[]` — autocomplete against a curated, backend-owned symptom vocabulary (substring match, case-insensitive). `SymptomSuggestion` = `{slug, term}`. Free text never reaches the doctor-search endpoint — the patient picks a vocabulary entry (docforum-core#60: controlled vocabulary, no ML). The UI debounces typing (250 ms) and cancels superseded in-flight requests. +- `GET /symptoms/:slug` → `SymptomSpecialtyMapping` = `{slug, term, specialties: Specialty[]}` — which specialties treat the symptom. `404` for a slug that has left the vocabulary; the UI explains this and offers to clear rather than showing a generic error. +- `GET /doctors?specialtyIds=&availability=` → `DoctorSearchResult[]` — doctors practising in ANY of the listed specialties (a symptom can map to several), soonest `nextAvailableAt` first, verified doctors only. Omitting `availability` (the UI's `any` value) means no availability filtering; `week` = an open slot within 7 days, `today` = an open slot within the patient's current day (the mock approximates it as the next 24 h). `DoctorSearchResult` deliberately reuses the `DoctorAvailability` shape (`{id, fullName, verificationStatus, specialties, openSlotCount, nextAvailableAt}`) — same entity, one shape (hard rule 2). +- Frontend behaviour: the applied symptom slug and the availability filter live in the URL (`?symptom=&availability=week`); invalid URL values are dropped with a visible notice, never sent to the backend. No symptom applied ⇒ no doctor request is fired (an unfiltered "every doctor" request doesn't exist), and a no-match result always offers a concrete way forward (widen availability, try another symptom). + #### Orders list and facility selection (docforum-core#11/#14, issue #5) - `GET /orders/mine` → `PatientOrder[]` — every version of the patient's prescriptions and lab orders, not only the current one. `PatientOrder` is a discriminated union on `orderType`: - prescription: `{id, patientProfileId, orderType: 'prescription', version, supersedesId, status, issuedAt, issuedByDoctorName?, requiresFacilitySelection, selectedFacility?, medication, dose, quantity?, instructions?}` diff --git a/ROADMAP.md b/ROADMAP.md index 12c9af2d..a90d03c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,7 +62,8 @@ API" for the one documented local-dev cookie caveat. ## Phase W2 — Patient core flow **Fully scoped into issues #1–#5, #18–#23, #26–#29, plus the follow-on patient issues #32–#34 and #37.** -- [ ] Doctor search (specialty + availability filter). Tracked as [issue #1](https://github.com/DocForum/docforum-web/issues/1) (Medium, 150 pts) — built against a documented assumed API shape, since `docforum-core` has no doctor-list endpoint yet. +- [ ] Doctor search (specialty + availability filter). Tracked as [issue #1](https://github.com/DocForum/docforum-web/issues/1) (Medium, 150 pts) — built against a documented assumed API shape, since `docforum-core` has no doctor-list endpoint yet. The doctor-search page itself (`/patient/search`, `src/features/search/`, `src/services/search-service.ts`, mock handlers + fixtures) landed with [issue #30](https://github.com/DocForum/docforum-web/issues/30)'s symptom-based search; what remains for #1 is standalone specialty browsing without a symptom. +- [x] Symptom-based doctor search with accessible autocomplete. Tracked as [issue #30](https://github.com/DocForum/docforum-web/issues/30) (High, 200 pts) — WAI-ARIA combobox against `docforum-core`'s controlled symptom vocabulary (docforum-core#60), debounced with in-flight requests cancelled on new keystrokes, "Showing doctors in X and Y" mapping line, availability filter combined with the symptom and kept in the URL, and a no-match state that always offers a way forward. - [ ] Booking + intake form. Tracked as [issue #2](https://github.com/DocForum/docforum-web/issues/2) (High, 200 pts) — must handle the `409` slot-taken response from `docforum-core`'s real booking-concurrency guarantee explicitly, not as a generic error. - [ ] My appointments list. Tracked as [issue #3](https://github.com/DocForum/docforum-web/issues/3) (Trivial, 100 pts) — calls `docforum-core`'s real, already-implemented `GET /appointments/mine`, no assumption involved. - [ ] Referral accept/decline screen. Tracked as [issue #4](https://github.com/DocForum/docforum-web/issues/4) (Medium, 150 pts) — assumed contract against `docforum-core` issues #5–#8, not yet merged there. @@ -546,6 +547,7 @@ Admin-only screens beyond payments. Everything goes through `docforum-core`'s ad - 2026-10-18 — Built Phase W6 stuck referrals console (#72): `/admin/referrals` queue of referrals pending past an age threshold (URL-synced threshold and reason filter, oldest first, cursor "Load more"), loop-chain context for loop-flagged referrals, reassign dialog with a doctor picker limited to verified doctors in the target specialty (with availability, chain-member warning, ineligible rows hidden and counted), and cancel dialog with patient-notification notice. Both actions need a 10–500 character reason, send `expectedUpdatedAt` for conflict detection, handle 409/404 (someone else acted) by closing and refreshing, and link to the referral's audit trail afterwards. Admin nav and dashboard link to it. API shapes are assumed pending docforum-core#68/#65. 30 test files and 207/207 tests passing; typecheck and build green. - 2026-10-18 — Built Phase W6 system health page (#73): `/admin/system` with readiness, background-job and outbox overview tiles, per-check readiness list (required/optional, duration, detail, cold-start hint), job cards (healthy, last run failed, overdue, running, never run; last success, last failure, next run), recent failures from the last 24 hours, and a confirmed "Run now" that shows the run's result in place (finished, failed, still running, `409` already running, unconfirmed). Polls every 30 s only while the tab is visible. 404s show as "not served yet"/"not exposed" rather than errors. Admin nav and dashboard link to it. API shapes are assumed pending docforum-core#104/#100/#94. 33 test files and 267/267 tests passing; typecheck and build green. - 2026-10-18 — Built Phase W6 feature flags console (#74): `/admin/flags` lists flags with state, type, rollout bar, targeting, a "who it reaches" summary and last change (actor and time from `GET /admin/audit-events?entityType=FeatureFlag`, one request for all flags). Search, state and type filters are synced to the URL. The edit and quick-toggle dialogs share a review-then-confirm step (field diff, audience before and after, last change). Validation covers a whole-number 0–100 percentage and a UUID allowlist (max 500). Optimistic saves roll back per flag on failure, re-read the server's state afterwards, and handle 409/404 conflicts and mid-edit staleness. Admin nav and dashboard link to it. API shape assumed pending docforum-core#132. 37 test files and 335/335 tests passing; typecheck and build green. +- 2026-10-19 — Built Phase W2 symptom-based doctor search (#30): `/patient/search` (new route, patient-gated, linked from the header and patient dashboard) with a WAI-ARIA 1.2 combobox (`src/components/Combobox.tsx`) autocompleting against a controlled symptom vocabulary, mapped-specialty disclosure ("Showing doctors in Cardiology and General practice"), an availability filter combined with the symptom, and a no-match state with concrete guidance. Suggestion requests are debounced (250 ms) and superseded in-flight requests are cancelled via React Query's AbortSignal. All state (symptom slug + availability) lives in the URL with validation and drop-notices for hand-edited links. Because issue #1 (doctor search) was still open, this PR also adds the underlying doctor-search page/service (`src/services/search-service.ts`, assumed shapes for docforum-core#23 and #60), the reusable combobox, and MSW handlers/fixtures for `/symptoms`, `/symptoms/:slug` and `/doctors`. Mock bootstrap (`src/mocks/setup.tsx`/`main.tsx`) now awaits MSW readiness before the first render, so deep-linked states no longer race the worker's startup. New tests: Combobox keyboard/ARIA suite, SymptomCombobox debounce + cancellation suite, DoctorSearchView integration suite, URL-param and specialty-list units, and 3 new handler tests. 45 test files and 407/407 tests passing; typecheck, build and `check:dist` green. Desktop and mobile screenshots of all four states in `screenshots/doctor-search-*`. `ROADMAP.md` and `ARCHITECTURE_ESSENTIALS.md` "Known assumptions" updated. - 2026-10-18 — Built Phase W4 Scan-to-verify orders (#52) and Facility payout ledger with escrow terms (#53): - `#52 Scan-to-verify orders with device camera`: dedicated verification page (`/facility/verify`) enabling facility staff to scan order QR codes via device camera or enter tokens manually. Uses native `BarcodeDetector` API with fallback; guarantees complete media stream track teardown on screen unmount/exit. Dispatches to `POST /facilities/me/verify-order` and cleanly displays all 5 result states (`valid`, `superseded`, `expired`, `fulfilled`, `not_eligible`) alongside minimal clinical order summaries. Valid results feature an immediate CTA to jump into fulfillment (`/facility?orderId=...`). Surfaces HTTP 429 rate-limit protection against enumeration. - `#53 Facility payout ledger with escrow terms`: dedicated payments page (`/facility/payments`) displaying held, released, and refunded totals for both XLM and USDC. Uses BigInt-safe math (`formatStroops`) for Stellar 7-decimal stroop precision. Renders cursor-paginated escrow ledger with plain-language terms (releaser rules and refund deadline countdowns) adhering to threat model T8. Prominently flags approaching refund deadlines (< 48h) on unfulfilled orders. Integrates direct deep links to Stellar Expert testnet explorer for funding, release, and refund transactions. diff --git a/e2e/__screenshots__/visual-desktop/patient-dashboard.png b/e2e/__screenshots__/visual-desktop/patient-dashboard.png index 9d27759a..891365b9 100644 Binary files a/e2e/__screenshots__/visual-desktop/patient-dashboard.png and b/e2e/__screenshots__/visual-desktop/patient-dashboard.png differ diff --git a/e2e/__screenshots__/visual-mobile/patient-dashboard.png b/e2e/__screenshots__/visual-mobile/patient-dashboard.png index 79fde3a2..50b6dced 100644 Binary files a/e2e/__screenshots__/visual-mobile/patient-dashboard.png and b/e2e/__screenshots__/visual-mobile/patient-dashboard.png differ diff --git a/screenshots/doctor-search-1-initial-desktop.png b/screenshots/doctor-search-1-initial-desktop.png new file mode 100644 index 00000000..6b47f7f5 Binary files /dev/null and b/screenshots/doctor-search-1-initial-desktop.png differ diff --git a/screenshots/doctor-search-1-initial-mobile.png b/screenshots/doctor-search-1-initial-mobile.png new file mode 100644 index 00000000..7bf8f706 Binary files /dev/null and b/screenshots/doctor-search-1-initial-mobile.png differ diff --git a/screenshots/doctor-search-2-suggestions-desktop.png b/screenshots/doctor-search-2-suggestions-desktop.png new file mode 100644 index 00000000..04d27c2a Binary files /dev/null and b/screenshots/doctor-search-2-suggestions-desktop.png differ diff --git a/screenshots/doctor-search-2-suggestions-mobile.png b/screenshots/doctor-search-2-suggestions-mobile.png new file mode 100644 index 00000000..4f4799f7 Binary files /dev/null and b/screenshots/doctor-search-2-suggestions-mobile.png differ diff --git a/screenshots/doctor-search-3-results-desktop.png b/screenshots/doctor-search-3-results-desktop.png new file mode 100644 index 00000000..6f1cc302 Binary files /dev/null and b/screenshots/doctor-search-3-results-desktop.png differ diff --git a/screenshots/doctor-search-3-results-mobile.png b/screenshots/doctor-search-3-results-mobile.png new file mode 100644 index 00000000..a96b9b39 Binary files /dev/null and b/screenshots/doctor-search-3-results-mobile.png differ diff --git a/screenshots/doctor-search-4-no-match-desktop.png b/screenshots/doctor-search-4-no-match-desktop.png new file mode 100644 index 00000000..189de2ab Binary files /dev/null and b/screenshots/doctor-search-4-no-match-desktop.png differ diff --git a/screenshots/doctor-search-4-no-match-mobile.png b/screenshots/doctor-search-4-no-match-mobile.png new file mode 100644 index 00000000..d9724a59 Binary files /dev/null and b/screenshots/doctor-search-4-no-match-mobile.png differ diff --git a/src/components/Combobox.module.css b/src/components/Combobox.module.css new file mode 100644 index 00000000..e491ab86 --- /dev/null +++ b/src/components/Combobox.module.css @@ -0,0 +1,129 @@ +.wrapper { + position: relative; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.label { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text); +} + +.inputRow { + display: flex; + gap: var(--space-2); + align-items: stretch; +} + +.input { + flex: 1; + min-width: 0; + padding: 0.7rem 0.875rem; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-strong); + background: var(--color-bg-raised); + color: var(--color-text); + font-family: var(--font-sans); + font-size: var(--text-base); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.input::placeholder { + color: var(--color-text-faint); +} + +.input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-subtle); +} + +.input:disabled { + opacity: 0.6; +} + +.clearButton { + padding: 0 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-muted); + font-family: var(--font-sans); + font-size: var(--text-sm); + cursor: pointer; + transition: all 0.15s ease; +} + +.clearButton:hover { + border-color: var(--color-danger); + color: var(--color-danger); +} + +.listbox { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 20; + margin: var(--space-1) 0 0; + padding: var(--space-1); + list-style: none; + max-height: 280px; + overflow-y: auto; + background: var(--color-bg-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); +} + +.option { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + cursor: pointer; +} + +.optionActive { + background: var(--color-accent-subtle); +} + +.optionLabel { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text); +} + +.optionHint { + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.empty { + padding: var(--space-3); + font-size: var(--text-sm); + color: var(--color-text-muted); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm); +} + +.helpText { + margin: 0; + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} diff --git a/src/components/Combobox.test.tsx b/src/components/Combobox.test.tsx new file mode 100644 index 00000000..16a2d64e --- /dev/null +++ b/src/components/Combobox.test.tsx @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from 'vitest'; +import { useState } from 'react'; +import { render, screen, within } from '../test/render'; +import userEvent from '@testing-library/user-event'; +import { Combobox, type ComboboxOption } from './Combobox'; + +const OPTIONS: ComboboxOption[] = [ + { id: 'rash', label: 'Rash', hint: 'Dermatology' }, + { id: 'chest-pain', label: 'Chest pain', hint: 'Cardiology' }, + { id: 'sore-throat', label: 'Sore throat', hint: 'General practice' }, +]; + +/** Parent harness — the Combobox is controlled, so tests drive it through a + * minimal stateful wrapper the way the symptom combobox does in the app. */ +function Harness({ + options = OPTIONS, + loading = false, + onSelect = vi.fn(), +}: { + options?: ComboboxOption[]; + loading?: boolean; + onSelect?: (optionId: string | null) => void; +}) { + const [text, setText] = useState(''); + const [selected, setSelected] = useState(''); + + return ( + { + setSelected(optionId ?? ''); + setText(optionId ? (options.find((option) => option.id === optionId)?.label ?? '') : ''); + onSelect(optionId); + }} + options={options} + loading={loading} + placeholder="e.g. rash" + emptyMessage="No matching symptoms" + helpText="Describe what feels wrong." + /> + ); +} + +function getInput() { + return screen.getByRole('combobox'); +} + +describe('Combobox', () => { + it('renders collapsed with the right combobox ARIA wiring', () => { + render(); + const input = getInput(); + + expect(screen.getByLabelText('Symptoms')).toBe(input); + expect(input).toHaveAttribute('aria-expanded', 'false'); + expect(input).toHaveAttribute('aria-autocomplete', 'list'); + expect(input).toHaveAttribute('aria-controls'); + expect(input).toHaveAccessibleDescription(/Describe what feels wrong/); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('opens on typing and announces the suggestion count in a live region', async () => { + const user = userEvent.setup(); + render(); + + await user.type(getInput(), 'r'); + + const listbox = screen.getByRole('listbox'); + expect(within(listbox).getAllByRole('option')).toHaveLength(3); + expect(getInput()).toHaveAttribute('aria-expanded', 'true'); + + expect(screen.getByRole('status')).toHaveTextContent( + '3 suggestions available. Use up and down arrows to browse, Enter to select.', + ); + }); + + it('moves the active option with ArrowDown/ArrowUp, wrapping, and tracks aria-activedescendant', async () => { + const user = userEvent.setup(); + render(); + const input = getInput(); + + await user.type(input, 'r'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + // ArrowDown opens highlight on the first option. + await user.keyboard('{ArrowDown}'); + const options = screen.getAllByRole('option'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + + // Wrap forward past the end. + await user.keyboard('{ArrowDown}{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + + // Wrap backward past the start. + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', options[2].id); + + await user.keyboard('{Home}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + await user.keyboard('{End}'); + expect(input).toHaveAttribute('aria-activedescendant', options[2].id); + + // Focus never left the input — the whole point of active-descendant. + expect(input).toHaveFocus(); + }); + + it('commits the active option with Enter and closes the popup', async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(getInput(), 'chest'); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(onSelect).toHaveBeenCalledWith('rash'); + expect(getInput()).toHaveValue('Rash'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent(''); + }); + + it('commits an option on mouse click', async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(getInput(), 'r'); + await user.click(screen.getByRole('option', { name: /Chest pain/ })); + + expect(onSelect).toHaveBeenCalledWith('chest-pain'); + expect(getInput()).toHaveValue('Chest pain'); + }); + + it('Escape closes the popup first, then clears the committed selection', async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(getInput(), 'rash'); + await user.keyboard('{ArrowDown}{Enter}'); + expect(onSelect).toHaveBeenLastCalledWith('rash'); + + // Re-open by typing, then Escape only closes the popup (keeps the selection). + await user.type(getInput(), 's'); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(onSelect).toHaveBeenLastCalledWith('rash'); + + // Escape again clears the selection. + await user.keyboard('{Escape}'); + expect(onSelect).toHaveBeenLastCalledWith(null); + expect(getInput()).toHaveValue(''); + }); + + it('offers a Clear button while a selection is committed', async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole('button', { name: /clear symptoms/i })).not.toBeInTheDocument(); + + await user.type(getInput(), 'rash'); + await user.keyboard('{ArrowDown}{Enter}'); + await user.click(screen.getByRole('button', { name: /clear symptoms/i })); + + expect(onSelect).toHaveBeenLastCalledWith(null); + expect(getInput()).toHaveValue(''); + }); + + it('shows the empty message when nothing matches', async () => { + const user = userEvent.setup(); + render(); + + await user.type(getInput(), 'zz'); + + expect(screen.queryByRole('listbox')).toBeInTheDocument(); + // Shown in the popup AND echoed in the live region for screen readers. + expect(screen.getAllByText('No matching symptoms')).toHaveLength(2); + expect(screen.getByRole('status')).toHaveTextContent('No matching symptoms'); + }); + + it('reflects the loading state', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + await user.type(getInput(), 'ch'); + rerender(); + + expect(screen.getByRole('status')).toHaveTextContent('Loading suggestions'); + }); +}); diff --git a/src/components/Combobox.tsx b/src/components/Combobox.tsx new file mode 100644 index 00000000..d3f9320a --- /dev/null +++ b/src/components/Combobox.tsx @@ -0,0 +1,264 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import type { KeyboardEvent } from 'react'; +import styles from './Combobox.module.css'; + +// Accessible combobox with a listbox popup, following the WAI-ARIA 1.2 +// combobox pattern: the input keeps DOM focus at all times, the highlighted +// option is communicated with aria-activedescendant (never by moving +// focus), and a polite live region announces suggestion counts and the +// active option as the patient arrows through the list. +// +// Deliberately uncontrolled in spirit: it renders whatever text and options +// the parent supplies and reports intent (typing, picking, clearing) back +// up. Debouncing, fetching and state live in the parent, so this component +// stays pure presentation + interaction and is easy to test on its own. + +export interface ComboboxOption { + /** Stable id — becomes the DOM id of its option element. */ + id: string; + label: string; + /** Optional secondary line, e.g. which specialty a symptom maps to. */ + hint?: string; +} + +interface ComboboxProps { + /** Stable base id — the input gets `${id}-input`, the listbox `${id}-listbox`. */ + id: string; + label: string; + /** The committed selection's option id, or '' when nothing is selected. */ + selectedOptionId: string; + /** The text currently shown in the input (controlled). */ + inputValue: string; + onInputChange: (value: string) => void; + /** Fired when the user picks an option, or clears the selection with null. */ + onSelect: (optionId: string | null) => void; + options: ComboboxOption[]; + loading?: boolean; + disabled?: boolean; + placeholder?: string; + /** Shown inside the popup when the user has typed but nothing matched. */ + emptyMessage?: string; + /** Helper text under the input. */ + helpText?: string; +} + +export function Combobox({ + id, + label, + selectedOptionId, + inputValue, + onInputChange, + onSelect, + options, + loading = false, + disabled = false, + placeholder, + emptyMessage = 'No matches found', + helpText, +}: ComboboxProps) { + const reactId = useId(); + const baseId = `${id}${reactId}`; + const inputId = `${baseId}-input`; + const listboxId = `${baseId}-listbox`; + const statusId = `${baseId}-status`; + const helpId = `${baseId}-help`; + + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const listboxRef = useRef(null); + + // Options change under us when a fetch lands or the query changes — + // drop the highlight rather than pointing at a different option. + useEffect(() => { + setActiveIndex(-1); + }, [options]); + + // Keep the highlighted option scrolled into view while arrowing. + useEffect(() => { + if (!open || activeIndex < 0) return; + const active = listboxRef.current?.children[activeIndex]; + if (active && 'scrollIntoView' in active) { + (active as HTMLElement).scrollIntoView({ block: 'nearest' }); + } + }, [open, activeIndex]); + + const close = () => { + setOpen(false); + setActiveIndex(-1); + }; + + const commit = (option: ComboboxOption) => { + onSelect(option.id); + close(); + }; + + const handleInputChange = (text: string) => { + onInputChange(text); + setActiveIndex(-1); + setOpen(true); + }; + + const handleInputBlur = () => { + close(); + }; + + const handleInputKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + if (!open) { + setOpen(true); + setActiveIndex(options.length > 0 ? 0 : -1); + } else if (options.length > 0) { + setActiveIndex((index) => (index + 1) % options.length); + } + break; + case 'ArrowUp': + event.preventDefault(); + if (!open) { + setOpen(true); + setActiveIndex(options.length > 0 ? options.length - 1 : -1); + } else if (options.length > 0) { + setActiveIndex((index) => (index <= 0 ? options.length - 1 : index - 1)); + } + break; + case 'Home': + if (open && options.length > 0) { + event.preventDefault(); + setActiveIndex(0); + } + break; + case 'End': + if (open && options.length > 0) { + event.preventDefault(); + setActiveIndex(options.length - 1); + } + break; + case 'Enter': { + if (open && activeIndex >= 0 && activeIndex < options.length) { + event.preventDefault(); + commit(options[activeIndex]); + } + break; + } + case 'Escape': + event.preventDefault(); + if (open) { + close(); + } else if (selectedOptionId) { + onSelect(null); + } + break; + case 'Tab': + // Move on without committing a half-browsed option. + close(); + break; + default: + break; + } + }; + + const showPopup = open && !disabled; + // An empty `emptyMessage` suppresses the empty state entirely — used when + // the text is still too short for the parent to have searched at all. + const showEmpty = + showPopup && !loading && options.length === 0 && inputValue.trim().length > 0 && emptyMessage !== ''; + + // Single polite status string; screen readers announce it on change. + let statusText = ''; + if (showPopup && loading) { + statusText = 'Loading suggestions'; + } else if (showPopup && !loading && options.length > 0) { + const count = options.length; + const activeDescription = + activeIndex >= 0 ? ` ${options[activeIndex].label},` : ''; + statusText = `${count} suggestion${count === 1 ? '' : 's'} available.${activeDescription} Use up and down arrows to browse, Enter to select.`; + } else if (showEmpty) { + statusText = emptyMessage; + } + + return ( +
+ +
+ = 0 ? `${baseId}-option-${options[activeIndex].id}` : undefined + } + aria-describedby={`${statusId}${helpText ? ` ${helpId}` : ''}`} + disabled={disabled} + placeholder={placeholder} + value={inputValue} + onChange={(event) => handleInputChange(event.target.value)} + onKeyDown={handleInputKeyDown} + onBlur={handleInputBlur} + /> + {selectedOptionId && !disabled && ( + + )} +
+ + {showPopup && ( +
    + {options.map((option, index) => ( +
  • event.preventDefault()} + onMouseEnter={() => setActiveIndex(index)} + onClick={() => commit(option)} + > + {option.label} + {option.hint && {option.hint}} +
  • + ))} +
+ )} + + {showEmpty && ( +
+ {emptyMessage} +
+ )} + +

+ {statusText} +

+ {helpText && ( +

+ {helpText} +

+ )} +
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 1fd72a5a..1bae5268 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -80,6 +80,7 @@ function roleNavItems(userRole: Role | null): NavItem[] { if (userRole === 'patient') { base.push( + { to: '/patient/search', label: 'Find a doctor', roles: ['patient'], messageId: 'nav.findADoctor' }, { to: '/care', label: 'Care journey', roles: ['patient', 'doctor'], messageId: 'nav.careJourney' }, { to: '/patient/dependents', label: 'Dependents', roles: ['patient'] }, { to: '/patient/health', label: 'Health profile', roles: ['patient'] }, diff --git a/src/features/search/components/AvailabilityFilter.tsx b/src/features/search/components/AvailabilityFilter.tsx new file mode 100644 index 00000000..ac9941b6 --- /dev/null +++ b/src/features/search/components/AvailabilityFilter.tsx @@ -0,0 +1,37 @@ +import type { AvailabilityFilter } from '../../../types/models'; +import { AVAILABILITY_OPTIONS } from '../search-params'; +import styles from './DoctorSearch.module.css'; + +interface AvailabilityFilterProps { + value: AvailabilityFilter; + onChange: (value: AvailabilityFilter) => void; +} + +/** + * Availability filter, combining with the symptom selection. Same + * tab-as-filter treatment as the facility finder's type tabs — a small, + * fully keyboard-operable set of toggles rather than a dropdown. + */ +export function AvailabilityFilter({ value, onChange }: AvailabilityFilterProps) { + return ( +
+ + Availability + +
+ {AVAILABILITY_OPTIONS.map((option) => ( + + ))} +
+
+ ); +} diff --git a/src/features/search/components/DoctorResultCard.tsx b/src/features/search/components/DoctorResultCard.tsx new file mode 100644 index 00000000..f6360919 --- /dev/null +++ b/src/features/search/components/DoctorResultCard.tsx @@ -0,0 +1,40 @@ +import type { DoctorSearchResult } from '../../../types/models'; +import styles from './DoctorSearch.module.css'; + +interface DoctorResultCardProps { + doctor: DoctorSearchResult; +} + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', +}); + +export function slotCountLabel(openSlotCount: number): string { + return `${openSlotCount} open slot${openSlotCount === 1 ? '' : 's'} in the next 7 days`; +} + +export function DoctorResultCard({ doctor }: DoctorResultCardProps) { + return ( +
  • +
    +

    {doctor.fullName}

    + {doctor.verificationStatus === 'verified' && ( + Verified + )} +
    +

    + {doctor.specialties.map((specialty) => specialty.name).join(' · ')} +

    +

    + {doctor.nextAvailableAt + ? `Next available ${dateFormatter.format(new Date(doctor.nextAvailableAt))}` + : 'No open slots in the next 7 days'} + {doctor.nextAvailableAt && doctor.openSlotCount > 0 + ? ` — ${slotCountLabel(doctor.openSlotCount)}` + : ''} +

    +
  • + ); +} diff --git a/src/features/search/components/DoctorSearch.module.css b/src/features/search/components/DoctorSearch.module.css new file mode 100644 index 00000000..41e96906 --- /dev/null +++ b/src/features/search/components/DoctorSearch.module.css @@ -0,0 +1,237 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.intro { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.heading { + margin: 0; + font-family: var(--font-display); + font-size: var(--text-3xl); + color: var(--color-text); +} + +.subheading { + margin: 0; + max-width: 60ch; + font-size: var(--text-base); + color: var(--color-text-muted); + line-height: var(--leading-normal); +} + +.notices { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3) var(--space-4); + background: var(--color-accent-2-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.notice { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text); +} + +.searchCard { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-5); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.appliedRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); +} + +.appliedText { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text); +} + +.appliedText strong { + color: var(--color-accent); +} + +.availabilityFilter { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.filterLabel { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text); +} + +.filterTabs { + display: flex; + gap: var(--space-2); +} + +.tabButton { + padding: 0.4rem 0.8rem; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.tabButton:hover { + border-color: var(--color-accent); +} + +.tabActive { + background: var(--color-accent); + color: #ffffff; + border-color: var(--color-accent); +} + +.results { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-height: 120px; +} + +.loading { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-5) 0; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.resultCount { + margin: 0; + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text); +} + +.doctorList { + display: flex; + flex-direction: column; + gap: var(--space-3); + list-style: none; + margin: 0; + padding: 0; +} + +.doctorCard { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-4); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + transition: border-color 0.15s ease; +} + +.doctorCard:hover { + border-color: var(--color-accent); +} + +.doctorHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.doctorName { + margin: 0; + font-size: var(--text-base); + font-weight: 600; + color: var(--color-text); +} + +.verifiedBadge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + border-radius: var(--radius-full); + background: var(--color-accent-subtle); + color: var(--color-accent); + font-size: var(--text-xs); + font-weight: 600; +} + +.doctorSpecialties { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text); +} + +.doctorAvailability { + margin: 0; + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.emptyState { + display: flex; + flex-direction: column; + gap: var(--space-3); + align-items: flex-start; + padding: var(--space-6); + background: var(--color-surface-alt); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); +} + +.emptyHeading { + margin: 0; + font-size: var(--text-lg); + color: var(--color-text); +} + +.emptyBody { + margin: 0; + max-width: 64ch; + font-size: var(--text-sm); + color: var(--color-text-muted); + line-height: var(--leading-normal); +} + +.guidanceList { + margin: 0; + padding-left: var(--space-5); + font-size: var(--text-sm); + color: var(--color-text-muted); + line-height: var(--leading-relaxed); +} + +@media (max-width: 640px) { + .searchCard { + padding: var(--space-4); + } + + .availabilityFilter { + flex-direction: column; + align-items: flex-start; + gap: var(--space-2); + } +} diff --git a/src/features/search/components/DoctorSearchView.test.tsx b/src/features/search/components/DoctorSearchView.test.tsx new file mode 100644 index 00000000..136d41a2 --- /dev/null +++ b/src/features/search/components/DoctorSearchView.test.tsx @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { render as rtlRender, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DoctorSearchView } from './DoctorSearchView'; +import { ApiError } from '../../../services/api-client'; +import * as searchService from '../../../services/search-service'; +import type { + DoctorSearchResult, + SymptomSpecialtyMapping, + SymptomSuggestion, +} from '../../../types/models'; + +vi.mock('../../../services/search-service'); + +const CHEST_PAIN_MAPPING: SymptomSpecialtyMapping = { + slug: 'chest-pain', + term: 'Chest pain', + specialties: [ + { id: 'spc-cardio', name: 'Cardiology', slug: 'cardiology' }, + { id: 'spc-gp', name: 'General practice', slug: 'general-practice' }, + ], +}; + +const RASH_MAPPING: SymptomSpecialtyMapping = { + slug: 'rash', + term: 'Rash', + specialties: [ + { id: 'spc-derm', name: 'Dermatology', slug: 'dermatology' }, + { id: 'spc-allergy', name: 'Allergy & immunology', slug: 'allergy-immunology' }, + ], +}; + +const CHEST_PAIN_DOCTORS: DoctorSearchResult[] = [ + { + id: 'doc-1', + fullName: 'Dr. Cora Sample (fake)', + verificationStatus: 'verified', + specialties: CHEST_PAIN_MAPPING.specialties, + openSlotCount: 2, + nextAvailableAt: new Date(Date.now() + 8 * 3_600_000).toISOString(), + }, +]; + +function LocationDisplay() { + const location = useLocation(); + return
    {location.search}
    ; +} + +function renderView(initialUrl = '/patient/search') { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const ui: ReactElement = ( + + + + + + + + } + /> + + + + ); + return rtlRender(ui); +} + +describe('DoctorSearchView', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(searchService.searchSymptoms).mockResolvedValue([]); + vi.mocked(searchService.searchDoctors).mockResolvedValue([]); + vi.mocked(searchService.getSymptomMapping).mockImplementation(async (slug) => { + if (slug === 'chest-pain') return CHEST_PAIN_MAPPING; + if (slug === 'rash') return RASH_MAPPING; + throw new ApiError('Mock API: symptom not found', 404); + }); + }); + + it('shows which specialties the symptom maps to and the matching doctors', async () => { + vi.mocked(searchService.searchDoctors).mockResolvedValue(CHEST_PAIN_DOCTORS); + renderView('/patient/search?symptom=chest-pain'); + + // Mapping line names the specialties. + expect(await screen.findByText('Cardiology and General practice')).toBeInTheDocument(); + expect(screen.getByText(/Showing doctors in/)).toBeInTheDocument(); + + // The doctor search used the mapped specialties. + await waitFor(() => + expect(searchService.searchDoctors).toHaveBeenCalledWith( + { specialtyIds: ['spc-cardio', 'spc-gp'], availability: 'any' }, + null, + expect.any(AbortSignal), + ), + ); + + expect(await screen.findByText('Dr. Cora Sample (fake)')).toBeInTheDocument(); + expect(screen.getByText('1 doctor found.')).toBeInTheDocument(); + expect(screen.getByText('Verified')).toBeInTheDocument(); + }); + + it('combines the availability filter with the symptom and syncs both to the URL', async () => { + const user = userEvent.setup(); + renderView('/patient/search?symptom=chest-pain'); + + await screen.findByText('Cardiology and General practice'); + + await user.click(screen.getByRole('tab', { name: 'Today' })); + + expect(screen.getByTestId('location')).toHaveTextContent('symptom=chest-pain'); + expect(screen.getByTestId('location')).toHaveTextContent('availability=today'); + await waitFor(() => + expect(searchService.searchDoctors).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'today' }), + null, + expect.any(AbortSignal), + ), + ); + }); + + it('reads availability from the URL on load', async () => { + renderView('/patient/search?symptom=chest-pain&availability=week'); + + await waitFor(() => + expect(searchService.searchDoctors).toHaveBeenCalledWith( + { specialtyIds: ['spc-cardio', 'spc-gp'], availability: 'week' }, + null, + expect.any(AbortSignal), + ), + ); + }); + + it('guides the patient out of a no-match state', async () => { + vi.mocked(searchService.searchDoctors).mockResolvedValue([]); + const user = userEvent.setup(); + renderView('/patient/search?symptom=chest-pain&availability=today'); + + expect( + await screen.findByRole('heading', { name: 'No doctors match right now' }), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'None of the doctors in Cardiology and General practice have open slots today.', + ), + ).toBeInTheDocument(); + + // One click takes them back to the unfiltered specialty list. + await user.click(screen.getByRole('button', { name: /any availability/i })); + expect(screen.getByTestId('location')).toHaveTextContent('symptom=chest-pain'); + expect(screen.getByTestId('location')).not.toHaveTextContent('availability='); + await waitFor(() => + expect(searchService.searchDoctors).toHaveBeenLastCalledWith( + expect.objectContaining({ availability: 'any' }), + null, + expect.any(AbortSignal), + ), + ); + }); + + it('applies a symptom chosen from the combobox and updates the URL', async () => { + vi.mocked(searchService.searchSymptoms).mockResolvedValue([ + { slug: 'rash', term: 'Rash' }, + ] satisfies SymptomSuggestion[]); + vi.mocked(searchService.searchDoctors).mockResolvedValue([]); + const user = userEvent.setup(); + renderView(); + + await user.type(screen.getByRole('combobox'), 'ra'); + // Wait for the (debounced) suggestion before driving the keyboard. + await screen.findByRole('option', { name: 'Rash' }); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(screen.getByTestId('location')).toHaveTextContent('symptom=rash'); + expect(await screen.findByText('Dermatology and Allergy & immunology')).toBeInTheDocument(); + await waitFor(() => + expect(searchService.searchDoctors).toHaveBeenCalledWith( + { specialtyIds: ['spc-allergy', 'spc-derm'], availability: 'any' }, + null, + expect.any(AbortSignal), + ), + ); + }); + + it('drops invalid URL params with a visible notice and no bogus requests', async () => { + const user = userEvent.setup(); + renderView('/patient/search?symptom=NOT%20A%20SLUG&availability=nope'); + + expect(await screen.findByText(/symptom link was not valid/i)).toBeInTheDocument(); + expect(screen.getByText(/availability filter was not valid/i)).toBeInTheDocument(); + + expect(searchService.getSymptomMapping).not.toHaveBeenCalled(); + expect(searchService.searchDoctors).not.toHaveBeenCalled(); + + // The page still works: clearing resets the URL. + expect(screen.getByRole('heading', { name: 'Start with a symptom' })).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: 'Any time' })); + expect(screen.getByTestId('location')).toHaveTextContent(/^$/); + }); + + it('explains an unknown symptom slug instead of erroring', async () => { + renderView('/patient/search?symptom=long-gone'); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'We don’t recognise “long-gone” any more.', + ); + expect( + await screen.findByRole('heading', { name: 'We can’t look that symptom up' }), + ).toBeInTheDocument(); + + // No specialty ids ⇒ no doctor request is ever fired. + expect(searchService.searchDoctors).not.toHaveBeenCalled(); + }); + + it('shows the starting guidance before any symptom is applied', async () => { + renderView(); + + expect(screen.getByRole('heading', { name: 'Start with a symptom' })).toBeInTheDocument(); + expect(searchService.searchDoctors).not.toHaveBeenCalled(); + expect(searchService.getSymptomMapping).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/search/components/DoctorSearchView.tsx b/src/features/search/components/DoctorSearchView.tsx new file mode 100644 index 00000000..7dd86882 --- /dev/null +++ b/src/features/search/components/DoctorSearchView.tsx @@ -0,0 +1,210 @@ +import { useMemo } from 'react'; +import { Button } from '../../../components/Button'; +import { FormError } from '../../../components/FormError'; +import { Spinner } from '../../../components/Spinner'; +import { useDoctorSearch } from '../hooks/useDoctorSearch'; +import { useSymptomMapping } from '../hooks/useSymptomMapping'; +import { useSearchUrlState } from '../hooks/useSearchUrlState'; +import { formatSpecialtyList } from '../specialty-list'; +import { AvailabilityFilter } from './AvailabilityFilter'; +import { DoctorResultCard } from './DoctorResultCard'; +import { SymptomCombobox } from './SymptomCombobox'; +import type { AvailabilityFilter as AvailabilityFilterValue } from '../../../types/models'; +import styles from './DoctorSearch.module.css'; + +const AVAILABILITY_RESULT_PHRASE: Record = { + any: '', + week: ' with slots in the next 7 days', + today: ' available today', +}; + +const AVAILABILITY_WINDOW_PHRASE: Record = { + any: 'at any time', + week: 'in the next 7 days', + today: 'today', +}; + +/** + * Symptom-based doctor search (issue #30, on top of #1's doctor search). + * A symptom is picked from the backend's controlled vocabulary, mapped to + * the specialties that treat it, and combined with the availability filter + * — all state in the URL. The no-match state always offers a way forward. + */ +export function DoctorSearchView() { + const { filters, issues, selectSymptom, setAvailability } = useSearchUrlState(); + + const mappingQuery = useSymptomMapping(filters.symptomSlug || null); + const mapping = mappingQuery.data; + const specialtyIds = useMemo( + () => (mapping?.specialties ?? []).map((specialty) => specialty.id), + [mapping], + ); + + const doctorsQuery = useDoctorSearch(specialtyIds, filters.availability); + const doctors = doctorsQuery.data; + + const specialtyListText = formatSpecialtyList( + (mapping?.specialties ?? []).map((specialty) => specialty.name), + ); + + const renderResults = () => { + if (!filters.symptomSlug) { + return ( +
    +

    Start with a symptom

    +

    + Type what’s bothering you above — “headache”, “stomach pain”, “sore throat”. We’ll match + it to the specialties that treat it and show doctors from those specialties. +

    +

    + You don’t need to know the medicine: the matching is done from a reviewed list of + symptoms, and the availability filter narrows the results whenever you like. +

    +
    + ); + } + + if (mappingQuery.isError) { + return ( +
    +

    We can’t look that symptom up

    +

    + It may have been removed from our list of symptoms, or the link you followed is out of + date. Clear the symptom and pick a fresh one from the list. +

    + +
    + ); + } + + if (mappingQuery.isFetching || (mapping && doctorsQuery.isFetching)) { + return ( +
    + + Searching doctors for you… +
    + ); + } + + if (doctorsQuery.isError) { + return ( +
    + + +
    + ); + } + + if (!doctors) return null; + + if (doctors.length === 0) { + return ( +
    +

    No doctors match right now

    +

    + {specialtyListText + ? `None of the doctors in ${specialtyListText} have open slots ${AVAILABILITY_WINDOW_PHRASE[filters.availability]}.` + : 'No doctors match this search right now.'} +

    +
      + {filters.availability === 'today' && ( +
    • Doctors in these specialties may have slots later this week.
    • + )} + {filters.availability !== 'any' && ( +
    • Try “Any time” to see every doctor in these specialties.
    • + )} +
    • Try a different symptom — other symptoms map to different specialties.
    • +
    + {filters.availability !== 'any' && ( + + )} +
    + ); + } + + return ( + <> +

    + {doctors.length} doctor{doctors.length === 1 ? '' : 's'} found + {AVAILABILITY_RESULT_PHRASE[filters.availability]}. +

    +
      + {doctors.map((doctor) => ( + + ))} +
    + + ); + }; + + return ( +
    +
    +

    Find the right doctor for what’s bothering you

    +

    + Describe a symptom — like “rash” or “chest pain” — and we’ll show doctors from the + specialties that treat it. No medical training required. +

    +
    + + {issues.length > 0 && ( +
    + {issues.map((issue) => ( +

    + {issue} +

    + ))} +
    + )} + +
    + + + {filters.symptomSlug && ( +
    + {mappingQuery.isFetching && ( +

    + Matching your symptom to the specialties that treat it… +

    + )} + {!mappingQuery.isFetching && mappingQuery.isError && ( +

    + We don’t recognise “{filters.symptomSlug}” any more. +

    + )} + {!mappingQuery.isFetching && mapping && ( +

    + Showing doctors in {specialtyListText}. +

    + )} + +
    + )} + + +
    + +
    + {renderResults()} +
    +
    + ); +} diff --git a/src/features/search/components/SymptomCombobox.test.tsx b/src/features/search/components/SymptomCombobox.test.tsx new file mode 100644 index 00000000..10ef1cc5 --- /dev/null +++ b/src/features/search/components/SymptomCombobox.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useState } from 'react'; +import { render, screen, waitFor } from '../../../test/render'; +import userEvent from '@testing-library/user-event'; +import { SymptomCombobox } from './SymptomCombobox'; +import * as searchService from '../../../services/search-service'; +import type { SymptomSuggestion } from '../../../types/models'; + +vi.mock('../../../services/search-service'); + +const SUGGESTIONS: SymptomSuggestion[] = [ + { slug: 'chest-pain', term: 'Chest pain' }, + { slug: 'palpitations', term: 'Heart palpitations' }, +]; + +const KNOWN_TERMS: Record = Object.fromEntries( + SUGGESTIONS.map((s) => [s.slug, s.term]), +); + +function Harness({ + onSelect = vi.fn(), + debounceMs = 60, +}: { + onSelect?: (slug: string | null) => void; + debounceMs?: number; +}) { + const [slug, setSlug] = useState(''); + return ( + { + setSlug(next ?? ''); + onSelect(next); + }} + /> + ); +} + +describe('SymptomCombobox', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(searchService.searchSymptoms).mockResolvedValue([]); + }); + + it('debounces keystrokes into a single suggestion request', async () => { + vi.mocked(searchService.searchSymptoms).mockResolvedValue(SUGGESTIONS); + const user = userEvent.setup(); + render(); + + // A burst of typing must produce one request, after the debounce. + await user.type(screen.getByRole('combobox'), 'chest'); + expect(searchService.searchSymptoms).not.toHaveBeenCalled(); + + await waitFor(() => expect(searchService.searchSymptoms).toHaveBeenCalledTimes(1)); + expect(searchService.searchSymptoms).toHaveBeenCalledWith('chest', null, expect.any(AbortSignal)); + }); + + it('cancels the in-flight request when a newer keystroke supersedes it', async () => { + const signals: AbortSignal[] = []; + // Never-resolving responses keep both requests genuinely in flight, the + // only situation where cancellation matters. + vi.mocked(searchService.searchSymptoms).mockImplementation((_query, _token, signal) => { + if (signal) signals.push(signal); + return new Promise(() => {}); + }); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('combobox'), 'ch'); + await waitFor(() => expect(signals).toHaveLength(1)); + + await user.type(screen.getByRole('combobox'), 'est'); + await waitFor(() => expect(signals).toHaveLength(2)); + + // The superseded request is aborted — it can never race the new one. + await waitFor(() => expect(signals[0].aborted).toBe(true)); + expect(signals[1].aborted).toBe(false); + }); + + it('commits a suggestion picked with the keyboard and syncs the canonical term', async () => { + vi.mocked(searchService.searchSymptoms).mockResolvedValue(SUGGESTIONS); + const onSelect = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'chest'); + + // Wait for the (debounced) options before driving the keyboard. + await screen.findByRole('option', { name: 'Chest pain' }); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(onSelect).toHaveBeenCalledWith('chest-pain'); + expect(input).toHaveValue('Chest pain'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('shows guidance when nothing in the vocabulary matches', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('combobox'), 'zzz'); + + // Shown in the popup AND echoed in the live region for screen readers. + expect(await screen.findAllByText(/No matching symptoms in our list/)).toHaveLength(2); + }); + + it('resets the text when the selection is cleared from outside', async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render( + , + ); + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('Chest pain'); + + // e.g. the Clear button in the applied-symptom row. + rerender(); + await waitFor(() => expect(input).toHaveValue('')); + + // …and clearing the text itself clears the applied symptom. + await user.type(input, 'ra'); + await user.clear(input); + expect(onSelect).toHaveBeenCalledWith(null); + }); +}); diff --git a/src/features/search/components/SymptomCombobox.tsx b/src/features/search/components/SymptomCombobox.tsx new file mode 100644 index 00000000..5e004b06 --- /dev/null +++ b/src/features/search/components/SymptomCombobox.tsx @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Combobox } from '../../../components/Combobox'; +import { + MIN_SYMPTOM_QUERY_LENGTH, + useSymptomSuggestions, +} from '../hooks/useSymptomSuggestions'; +import type { SymptomSuggestion } from '../../../types/models'; + +const NO_SUGGESTIONS: SymptomSuggestion[] = []; + +interface SymptomComboboxProps { + /** The applied symptom's slug (from the URL), '' when none. */ + selectedSlug: string; + /** The canonical term for `selectedSlug`, once its mapping has loaded. */ + selectedTerm?: string; + onSelect: (slug: string | null) => void; + /** Test seam: override the debounce (ms) to keep real-timer tests fast. */ + debounceMs?: number; +} + +/** + * Symptom search input: patients describe what's wrong ("rash", "chest + * pain") and pick from the backend's controlled vocabulary — free text is + * never sent to the doctor-search endpoint. The committed selection is the + * slug; the text in the box is cosmetic and resets to the canonical term. + */ +export function SymptomCombobox({ + selectedSlug, + selectedTerm, + onSelect, + debounceMs, +}: SymptomComboboxProps) { + const [inputValue, setInputValue] = useState(selectedTerm ?? ''); + + const { data: suggestions = NO_SUGGESTIONS, isFetching } = useSymptomSuggestions( + inputValue, + debounceMs, + ); + + // Keep the text in sync when the applied symptom changes from outside the + // box: a clear, a back/forward navigation, or the mapping load confirming + // the canonical term after a pick. Deliberately does nothing while the + // text differs only because the user is typing. + useEffect(() => { + if (!selectedSlug) { + setInputValue(''); + return; + } + if (selectedTerm) setInputValue(selectedTerm); + }, [selectedSlug, selectedTerm]); + + const handleInputChange = (text: string) => { + setInputValue(text); + // Emptying the box is unambiguous — nothing left to search for. + if (text === '') onSelect(null); + }; + + const handleSelect = (optionId: string | null) => { + if (!optionId) { + setInputValue(''); + onSelect(null); + return; + } + const picked = suggestions.find((suggestion) => suggestion.slug === optionId); + setInputValue(picked?.term ?? optionId); + onSelect(optionId); + }; + + const options = useMemo( + () => suggestions.map((suggestion) => ({ id: suggestion.slug, label: suggestion.term })), + [suggestions], + ); + + const minLengthReached = inputValue.trim().length >= MIN_SYMPTOM_QUERY_LENGTH; + + return ( + + ); +} diff --git a/src/features/search/hooks/useDoctorSearch.ts b/src/features/search/hooks/useDoctorSearch.ts new file mode 100644 index 00000000..c5b26088 --- /dev/null +++ b/src/features/search/hooks/useDoctorSearch.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '../../../store/auth-store'; +import { searchDoctors } from '../../../services/search-service'; +import type { AvailabilityFilter, DoctorSearchResult } from '../../../types/models'; + +/** + * Doctors in the given specialties, filtered by availability. Disabled + * (and silent) until at least one specialty is known — an unfiltered + * "every doctor" request is never sent, so a symptom page can't degrade + * into a full directory listing on a mapping failure. + */ +export function useDoctorSearch(specialtyIds: string[], availability: AvailabilityFilter) { + const accessToken = useAuthStore((state) => state.accessToken); + + // Sorted so the query key doesn't churn if the mapping changes order. + const sortedIds = [...specialtyIds].sort(); + const idsKey = sortedIds.join(','); + + return useQuery({ + queryKey: ['doctor-search', idsKey, availability], + queryFn: ({ signal }) => + searchDoctors({ specialtyIds: sortedIds, availability }, accessToken, signal), + enabled: sortedIds.length > 0, + }); +} diff --git a/src/features/search/hooks/useSearchUrlState.ts b/src/features/search/hooks/useSearchUrlState.ts new file mode 100644 index 00000000..46903e58 --- /dev/null +++ b/src/features/search/hooks/useSearchUrlState.ts @@ -0,0 +1,33 @@ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { EMPTY_FILTERS, parseSearchParams, toSearchParams } from '../search-params'; +import type { SearchFilters } from '../search-params'; +import type { AvailabilityFilter } from '../../../types/models'; + +/** The applied symptom and the availability filter live in the URL, so a + * search is shareable and survives back/forward — same pattern as the + * admin audit console's filters. */ +export function useSearchUrlState() { + const [searchParams, setSearchParams] = useSearchParams(); + + const { filters, issues } = useMemo(() => parseSearchParams(searchParams), [searchParams]); + + const navigate = useCallback( + (next: SearchFilters) => setSearchParams(toSearchParams(next)), + [setSearchParams], + ); + + const selectSymptom = useCallback( + (slug: string | null) => navigate({ ...filters, symptomSlug: slug ?? '' }), + [filters, navigate], + ); + + const setAvailability = useCallback( + (availability: AvailabilityFilter) => navigate({ ...filters, availability }), + [filters, navigate], + ); + + const clearAll = useCallback(() => navigate(EMPTY_FILTERS), [navigate]); + + return { filters, issues, selectSymptom, setAvailability, clearAll }; +} diff --git a/src/features/search/hooks/useSymptomMapping.ts b/src/features/search/hooks/useSymptomMapping.ts new file mode 100644 index 00000000..1d4a634e --- /dev/null +++ b/src/features/search/hooks/useSymptomMapping.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '../../../store/auth-store'; +import { getSymptomMapping } from '../../../services/search-service'; +import type { SymptomSpecialtyMapping } from '../../../types/models'; + +/** Which specialties treat the applied symptom (docforum-core#60). */ +export function useSymptomMapping(slug: string | null) { + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: ['symptom-mapping', slug], + queryFn: ({ signal }) => getSymptomMapping(slug as string, accessToken, signal), + enabled: Boolean(slug), + // A slug that fell out of the vocabulary stays a 404 — retrying it + // only delays the "we don't recognise that symptom" message. + retry: false, + }); +} diff --git a/src/features/search/hooks/useSymptomSuggestions.ts b/src/features/search/hooks/useSymptomSuggestions.ts new file mode 100644 index 00000000..7923c6b7 --- /dev/null +++ b/src/features/search/hooks/useSymptomSuggestions.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useAuthStore } from '../../../store/auth-store'; +import { searchSymptoms } from '../../../services/search-service'; +import type { SymptomSuggestion } from '../../../types/models'; + +export const SYMPTOM_SUGGEST_DEBOUNCE_MS = 250; +export const MIN_SYMPTOM_QUERY_LENGTH = 2; + +/** + * Debounced autocomplete against the controlled symptom vocabulary. + * + * The query key is the *debounced* text, and the queryFn forwards React + * Query's AbortSignal to the fetch — so when a newer keystroke produces a + * new key while an older request is still in flight, that older request is + * aborted rather than racing the new one. The debounce timer itself is + * cleared on every keystroke/unmount, so a burst of typing is one request. + */ +export function useSymptomSuggestions(query: string, delayMs = SYMPTOM_SUGGEST_DEBOUNCE_MS) { + const accessToken = useAuthStore((state) => state.accessToken); + const queryClient = useQueryClient(); + const [debouncedQuery, setDebouncedQuery] = useState(query); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), delayMs); + return () => clearTimeout(timer); + }, [query, delayMs]); + + // A different debounced query (or unmount) makes any in-flight + // suggestions fetch stale — cancel it so its response can never + // overwrite newer results, and the connection is released immediately. + useEffect(() => { + return () => { + void queryClient.cancelQueries({ queryKey: ['symptom-suggestions'] }); + }; + }, [debouncedQuery, queryClient]); + + const trimmed = debouncedQuery.trim(); + + return useQuery({ + queryKey: ['symptom-suggestions', trimmed], + queryFn: ({ signal }) => searchSymptoms(trimmed, accessToken, signal), + enabled: trimmed.length >= MIN_SYMPTOM_QUERY_LENGTH, + // The vocabulary is curated backend-side and effectively static within + // a visit — no point re-hitting it for every backspace/retype. + staleTime: 30_000, + }); +} diff --git a/src/features/search/search-params.test.ts b/src/features/search/search-params.test.ts new file mode 100644 index 00000000..946676f3 --- /dev/null +++ b/src/features/search/search-params.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { EMPTY_FILTERS, parseSearchParams, toSearchParams } from './search-params'; + +function params(query: string) { + return new URLSearchParams(query); +} + +describe('parseSearchParams', () => { + it('defaults to no symptom and any availability', () => { + expect(parseSearchParams(params(''))).toEqual({ filters: EMPTY_FILTERS, issues: [] }); + }); + + it('reads a valid symptom slug and availability', () => { + const { filters, issues } = parseSearchParams(params('symptom=chest-pain&availability=today')); + expect(filters).toEqual({ symptomSlug: 'chest-pain', availability: 'today' }); + expect(issues).toEqual([]); + }); + + it('normalises case and whitespace in the slug', () => { + const { filters } = parseSearchParams(params('symptom=%20Chest-Pain%20')); + expect(filters.symptomSlug).toBe('chest-pain'); + }); + + it('drops an invalid slug with a notice', () => { + const { filters, issues } = parseSearchParams(params('symptom=DROP%20TABLE%20users')); + expect(filters.symptomSlug).toBe(''); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatch(/symptom link was not valid/i); + }); + + it('drops an invalid availability value with a notice', () => { + const { filters, issues } = parseSearchParams(params('availability=whenever')); + expect(filters.availability).toBe('any'); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatch(/availability filter .* not valid/i); + }); + + it('keeps the symptom when only availability is invalid', () => { + const { filters, issues } = parseSearchParams(params('symptom=rash&availability=nope')); + expect(filters).toEqual({ symptomSlug: 'rash', availability: 'any' }); + expect(issues).toHaveLength(1); + }); +}); + +describe('toSearchParams', () => { + it('round-trips through parse', () => { + const filters = { symptomSlug: 'chest-pain', availability: 'today' as const }; + expect(parseSearchParams(toSearchParams(filters)).filters).toEqual(filters); + }); + + it('omits defaults so the URL stays clean', () => { + expect(toSearchParams(EMPTY_FILTERS).toString()).toBe(''); + }); +}); diff --git a/src/features/search/search-params.ts b/src/features/search/search-params.ts new file mode 100644 index 00000000..4ff0e298 --- /dev/null +++ b/src/features/search/search-params.ts @@ -0,0 +1,67 @@ +// URL state for the doctor/symptom search page (issue #30). Everything the +// page shows is derivable from the URL: the applied symptom (its controlled- +// vocabulary slug) and the availability filter. Values parsed out of the URL +// are validated — a hand-edited or stale link drops the invalid part with a +// visible notice instead of firing a request the backend would reject. +import type { AvailabilityFilter } from '../../types/models'; + +export interface SearchFilters { + /** The controlled-vocabulary slug of the applied symptom, '' when none. */ + symptomSlug: string; + availability: AvailabilityFilter; +} + +export const EMPTY_FILTERS: SearchFilters = { symptomSlug: '', availability: 'any' }; + +export const AVAILABILITY_OPTIONS: { value: AvailabilityFilter; label: string }[] = [ + { value: 'any', label: 'Any time' }, + { value: 'week', label: 'This week' }, + { value: 'today', label: 'Today' }, +]; + +/** Slugs come from the backend's controlled vocabulary, so a conservative + * pattern is safe: lowercase words joined by single hyphens. */ +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MAX_SLUG_LENGTH = 64; + +export interface ParsedSearch { + filters: SearchFilters; + /** Human-readable notices for invalid params that were dropped. */ + issues: string[]; +} + +const AVAILABILITY_VALUES: AvailabilityFilter[] = ['any', 'week', 'today']; + +export function parseSearchParams(params: URLSearchParams): ParsedSearch { + const issues: string[] = []; + const filters: SearchFilters = { ...EMPTY_FILTERS }; + + const rawSymptom = params.get('symptom'); + if (rawSymptom) { + const slug = rawSymptom.trim().toLowerCase(); + if (slug.length <= MAX_SLUG_LENGTH && SLUG_PATTERN.test(slug)) { + filters.symptomSlug = slug; + } else { + issues.push('That symptom link was not valid, so the symptom was cleared.'); + } + } + + const rawAvailability = params.get('availability'); + if (rawAvailability !== null) { + if (AVAILABILITY_VALUES.includes(rawAvailability as AvailabilityFilter)) { + filters.availability = rawAvailability as AvailabilityFilter; + } else { + issues.push('That availability filter was not valid, so it was reset to “Any time”.'); + } + } + + return { filters, issues }; +} + +/** Omits defaults, so the URL stays as short as the state it describes. */ +export function toSearchParams(filters: SearchFilters): URLSearchParams { + const params = new URLSearchParams(); + if (filters.symptomSlug) params.set('symptom', filters.symptomSlug); + if (filters.availability !== 'any') params.set('availability', filters.availability); + return params; +} diff --git a/src/features/search/specialty-list.test.ts b/src/features/search/specialty-list.test.ts new file mode 100644 index 00000000..d076a5cd --- /dev/null +++ b/src/features/search/specialty-list.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { formatSpecialtyList } from './specialty-list'; + +describe('formatSpecialtyList', () => { + it('handles 0, 1, 2 and 3+ names', () => { + expect(formatSpecialtyList([])).toBe(''); + expect(formatSpecialtyList(['Dermatology'])).toBe('Dermatology'); + expect(formatSpecialtyList(['Dermatology', 'Allergy & immunology'])).toBe( + 'Dermatology and Allergy & immunology', + ); + expect(formatSpecialtyList(['Cardiology', 'General practice', 'Paediatrics'])).toBe( + 'Cardiology, General practice and Paediatrics', + ); + }); +}); diff --git a/src/features/search/specialty-list.ts b/src/features/search/specialty-list.ts new file mode 100644 index 00000000..129bc3c4 --- /dev/null +++ b/src/features/search/specialty-list.ts @@ -0,0 +1,9 @@ +// Human-readable joining of specialty names for the "Showing doctors in …" +// line, e.g. "Dermatology" / "Dermatology and Cardiology" / +// "Dermatology, Cardiology and General practice". +export function formatSpecialtyList(names: string[]): string { + if (names.length === 0) return ''; + if (names.length === 1) return names[0]; + const head = names.slice(0, -1).join(', '); + return `${head} and ${names[names.length - 1]}`; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 815dd327..ba5e0327 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -265,6 +265,7 @@ "messaging.unreadLabel": "Unread messages: {count}", "nav.careJourney": "Care journey", "nav.dashboard": "Dashboard", + "nav.findADoctor": "Find a doctor", "nav.insights": "Insights", "nav.logIn": "Log in", "nav.logOut": "Log out", @@ -278,6 +279,7 @@ "patient.nav.care": "Care journey timeline", "patient.nav.dependents": "Manage dependents", "patient.nav.facilities": "Find pharmacies & labs", + "patient.nav.findDoctor": "Find a doctor by symptom", "patient.nav.health": "Health profile", "patient.nav.settings": "Account settings & privacy", "patient.phasePill": "Phase W2 · in progress", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 815dd327..ba5e0327 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -265,6 +265,7 @@ "messaging.unreadLabel": "Unread messages: {count}", "nav.careJourney": "Care journey", "nav.dashboard": "Dashboard", + "nav.findADoctor": "Find a doctor", "nav.insights": "Insights", "nav.logIn": "Log in", "nav.logOut": "Log out", @@ -278,6 +279,7 @@ "patient.nav.care": "Care journey timeline", "patient.nav.dependents": "Manage dependents", "patient.nav.facilities": "Find pharmacies & labs", + "patient.nav.findDoctor": "Find a doctor by symptom", "patient.nav.health": "Health profile", "patient.nav.settings": "Account settings & privacy", "patient.phasePill": "Phase W2 · in progress", diff --git a/src/mocks/fixtures.ts b/src/mocks/fixtures.ts index ae07f182..609f246e 100644 --- a/src/mocks/fixtures.ts +++ b/src/mocks/fixtures.ts @@ -21,6 +21,7 @@ import type { DataExport, Dependent, DoctorPracticeInsights, + DoctorSearchResult, FacilityApiKey, FacilityInvite, FacilityMember, @@ -38,6 +39,7 @@ import type { QualifiedOrderFacility, Role, RunningDelay, + Specialty, SpecialtyClaim, User, VerificationStatus, @@ -89,6 +91,45 @@ export const DEPENDENT_PROFILE_ID = 'prf-dependent-demo'; export const DOCTOR_PROFILE_ID = 'prf-doctor-demo'; export const FACILITY_ID = 'fac-main'; +// --- Doctor & symptom search (issues #1/#30) ------------------------------- + +export const SPECIALTIES: Specialty[] = [ + { id: 'spc-derm', name: 'Dermatology', slug: 'dermatology' }, + { id: 'spc-allergy', name: 'Allergy & immunology', slug: 'allergy-immunology' }, + { id: 'spc-cardio', name: 'Cardiology', slug: 'cardiology' }, + { id: 'spc-gp', name: 'General practice', slug: 'general-practice' }, + { id: 'spc-peds', name: 'Paediatrics', slug: 'paediatrics' }, + { id: 'spc-neuro', name: 'Neurology', slug: 'neurology' }, +]; + +/** One entry of the mock's controlled symptom vocabulary (docforum-core#60). */ +export interface MockSymptom { + slug: string; + term: string; + specialtyIds: string[]; +} + +export const SYMPTOM_VOCABULARY: MockSymptom[] = [ + { slug: 'rash', term: 'Rash', specialtyIds: ['spc-derm', 'spc-allergy'] }, + { slug: 'itchy-skin', term: 'Itchy skin', specialtyIds: ['spc-derm', 'spc-allergy'] }, + { slug: 'chest-pain', term: 'Chest pain', specialtyIds: ['spc-cardio', 'spc-gp'] }, + { slug: 'palpitations', term: 'Heart palpitations', specialtyIds: ['spc-cardio'] }, + { slug: 'headache', term: 'Headache', specialtyIds: ['spc-neuro', 'spc-gp'] }, + { slug: 'sore-throat', term: 'Sore throat', specialtyIds: ['spc-gp'] }, + { slug: 'cough', term: 'Cough', specialtyIds: ['spc-gp'] }, + { slug: 'fever', term: 'Fever', specialtyIds: ['spc-gp', 'spc-peds'] }, + { slug: 'stomach-pain', term: 'Stomach pain', specialtyIds: ['spc-gp'] }, + { slug: 'ear-pain', term: 'Ear pain', specialtyIds: ['spc-peds', 'spc-gp'] }, +]; + +const HOUR_MS = 3_600_000; + +/** Relative to seed time so the mock availability filter keeps making + * sense whenever the dev server is actually run. */ +function inHours(hours: number): string { + return new Date(Date.now() + hours * HOUR_MS).toISOString(); +} + // --- Seed database ----------------------------------------------------------- export interface MockDb { @@ -123,6 +164,9 @@ export interface MockDb { paymentIntents: PaymentIntent[]; disputes: FulfillmentDispute[]; consultationAddenda: ConsultationAddendum[]; // flattened, noteId-keyed lookups below + specialties: Specialty[]; + doctors: DoctorSearchResult[]; + symptoms: MockSymptom[]; verifications: MockVerification[]; specialtyClaims: SpecialtyClaim[]; auditEvents: AuditEvent[]; @@ -1397,6 +1441,63 @@ export function createSeedDb(): MockDb { }, ]; + // Patient-facing doctors (issue #1/#30). Availability windows are + // relative to seed time: Dana within a few hours (inside the "today" + // window), Cora later today, Pia tomorrow (inside the week), Nils and + // Bea beyond a week, Ravi fully booked (null). Cora practises cardiology + // only, so a general-practice symptom like "fever" has no doctor inside + // the "today" window — the dev panel can show a real no-match state. + const doctors: DoctorSearchResult[] = [ + { + id: 'doc-demo-derm-1', + fullName: 'Dr. Dana Placeholder (fake)', + verificationStatus: 'verified', + specialties: [SPECIALTIES[0], SPECIALTIES[1]], + openSlotCount: 4, + nextAvailableAt: inHours(3), + }, + { + id: 'doc-demo-derm-2', + fullName: 'Dr. Ravi Example (fake)', + verificationStatus: 'verified', + specialties: [SPECIALTIES[0]], + openSlotCount: 0, + nextAvailableAt: null, + }, + { + id: 'doc-demo-cardio-1', + fullName: 'Dr. Cora Sample (fake)', + verificationStatus: 'verified', + specialties: [SPECIALTIES[2]], + openSlotCount: 2, + nextAvailableAt: inHours(8), + }, + { + id: 'doc-demo-cardio-2', + fullName: 'Dr. Bea Demo (fake)', + verificationStatus: 'verified', + specialties: [SPECIALTIES[2]], + openSlotCount: 1, + nextAvailableAt: inHours(10 * 24), + }, + { + id: 'doc-demo-gp-1', + fullName: 'Dr. Pia Fiction (fake)', + verificationStatus: 'verified', + specialties: [SPECIALTIES[3], SPECIALTIES[4]], + openSlotCount: 3, + nextAvailableAt: inHours(26), + }, + { + id: 'doc-demo-neuro-1', + fullName: 'Dr. Nils Madeup (fake)', + verificationStatus: 'verified', + specialties: [SPECIALTIES[5]], + openSlotCount: 2, + nextAvailableAt: inHours(9 * 24), + }, + ]; + // Fulfilment disputes (issue #76). Every row is fake; the amounts match the // escrowed intents above so the detail's payment panel links somewhere real. const disputes: FulfillmentDispute[] = [ @@ -1845,6 +1946,9 @@ export function createSeedDb(): MockDb { paymentIntents, disputes, consultationAddenda, + specialties: SPECIALTIES.map((specialty) => ({ ...specialty })), + doctors, + symptoms: SYMPTOM_VOCABULARY.map((symptom) => ({ ...symptom })), verifications, specialtyClaims, auditEvents, diff --git a/src/mocks/handlers.test.ts b/src/mocks/handlers.test.ts index 5755da63..ee50b051 100644 --- a/src/mocks/handlers.test.ts +++ b/src/mocks/handlers.test.ts @@ -34,6 +34,7 @@ import * as notificationsService from '../services/notifications-service'; import * as ordersService from '../services/orders-service'; import * as paymentsService from '../services/payments-service'; import * as queueService from '../services/queue-service'; +import * as searchService from '../services/search-service'; import * as verificationService from '../services/admin-verification-service'; import * as auditService from '../services/audit-service'; import * as sessionTimeoutService from '../services/session-timeout-service'; @@ -750,6 +751,85 @@ describe('payments', () => { }); }); +describe('doctor & symptom search', () => { + it('autocomplete-suggests controlled-vocabulary symptoms', async () => { + const matches = await searchService.searchSymptoms('chest', MOCK_ACCESS_TOKEN); + expect(matches).toEqual([{ slug: 'chest-pain', term: 'Chest pain' }]); + + // Case-insensitive substring match, not just a prefix. + const shouted = await searchService.searchSymptoms('RASH', MOCK_ACCESS_TOKEN); + expect(shouted.map((s) => s.slug)).toContain('rash'); + + // Honours the requested limit. + const limited = await searchService.searchSymptoms('a', MOCK_ACCESS_TOKEN); + const capped = await fetch( + `http://localhost:4000/symptoms?q=a&limit=2`, + ).then((r) => r.json() as Promise>); + expect(limited.length).toBeGreaterThan(capped.length); + expect(capped).toHaveLength(2); + + await expect(searchService.searchSymptoms('zzz', MOCK_ACCESS_TOKEN)).resolves.toEqual([]); + }); + + it('maps a symptom to its specialties and 404s on unknown slugs', async () => { + const mapping = await searchService.getSymptomMapping('rash', MOCK_ACCESS_TOKEN); + expect(mapping.term).toBe('Rash'); + expect(mapping.specialties.map((s) => s.slug)).toEqual(['dermatology', 'allergy-immunology']); + + await expect( + searchService.getSymptomMapping('not-a-symptom', MOCK_ACCESS_TOKEN), + ).rejects.toMatchObject({ status: 404 }); + }); + + it('searches doctors by specialty (OR) and availability', async () => { + const derm = await searchService.searchDoctors({ specialtyIds: ['spc-derm'] }, MOCK_ACCESS_TOKEN); + expect(derm.map((d) => d.id)).toEqual(['doc-demo-derm-1', 'doc-demo-derm-2']); + + // "Chest pain" maps to Cardiology + General practice — doctors in + // either specialty come back, soonest available first. + const chestPain = await searchService.searchDoctors( + { specialtyIds: ['spc-cardio', 'spc-gp'] }, + MOCK_ACCESS_TOKEN, + ); + expect(chestPain.map((d) => d.id)).toEqual([ + 'doc-demo-cardio-1', // +8h + 'doc-demo-gp-1', // +26h + 'doc-demo-cardio-2', // +10d + ]); + + const today = await searchService.searchDoctors( + { specialtyIds: ['spc-cardio', 'spc-gp'], availability: 'today' }, + MOCK_ACCESS_TOKEN, + ); + expect(today.map((d) => d.id)).toEqual(['doc-demo-cardio-1']); + + const week = await searchService.searchDoctors( + { specialtyIds: ['spc-cardio', 'spc-gp'], availability: 'week' }, + MOCK_ACCESS_TOKEN, + ); + expect(week.map((d) => d.id)).toEqual(['doc-demo-cardio-1', 'doc-demo-gp-1']); + + // Unbookable doctors drop out of every availability window. + const dermToday = await searchService.searchDoctors( + { specialtyIds: ['spc-derm'], availability: 'today' }, + MOCK_ACCESS_TOKEN, + ); + expect(dermToday.map((d) => d.id)).toEqual(['doc-demo-derm-1']); + + // "Fever" maps to General practice + Paediatrics; nobody in either has + // a slot inside the "today" window, so the list is genuinely empty. + const feverToday = await searchService.searchDoctors( + { specialtyIds: ['spc-gp', 'spc-peds'], availability: 'today' }, + MOCK_ACCESS_TOKEN, + ); + expect(feverToday).toEqual([]); + + await expect( + searchService.searchDoctors({ specialtyIds: ['spc-none'] }, MOCK_ACCESS_TOKEN), + ).resolves.toEqual([]); + }); +}); + describe('admin closures', () => { it('lists, creates, updates and deletes closures', async () => { const seeded = await adminClosuresService.listClosures(MOCK_ACCESS_TOKEN); @@ -994,6 +1074,10 @@ describe('dev panel scenarios', () => { ).resolves.toEqual([]); await expect(accountService.getDataExportStatus(MOCK_ACCESS_TOKEN)).resolves.toBeNull(); await expect(paymentsService.getOwnWalletLink(MOCK_ACCESS_TOKEN)).resolves.toBeNull(); + await expect(searchService.searchSymptoms('rash', MOCK_ACCESS_TOKEN)).resolves.toEqual([]); + await expect( + searchService.searchDoctors({ specialtyIds: ['spc-derm'] }, MOCK_ACCESS_TOKEN), + ).resolves.toEqual([]); }); it('delays responses by the configured latency', async () => { diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index b5ac97d5..1c7daeac 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -20,6 +20,7 @@ import { notificationHandlers } from './handlers/notifications'; import { orderHandlers } from './handlers/orders'; import { paymentsHandlers } from './handlers/payments'; import { queueHandlers } from './handlers/queue'; +import { searchHandlers } from './handlers/search'; import { adminClosuresHandlers } from './handlers/admin-closures'; import { verificationHandlers } from './handlers/verifications'; @@ -44,6 +45,7 @@ export const handlers = [ ...integrationsHandlers, ...orderHandlers, ...paymentsHandlers, + ...searchHandlers, ...adminClosuresHandlers, ...disputesHandlers, ...verificationHandlers, diff --git a/src/mocks/handlers/search.ts b/src/mocks/handlers/search.ts new file mode 100644 index 00000000..e4d8cd18 --- /dev/null +++ b/src/mocks/handlers/search.ts @@ -0,0 +1,94 @@ +// Doctor search + symptom vocabulary/mapping endpoints (issues #1/#30). +// The vocabulary is the mock's controlled list — free text is never +// fuzzy-matched into doctors, only picked symptoms map to specialties, +// mirroring docforum-core#60's "controlled vocabulary, no ML" contract. +import { http } from 'msw'; +import { db } from '../db'; +import { notFound, respond } from '../helpers'; +import type { DoctorSearchResult, SymptomSpecialtyMapping, SymptomSuggestion } from '../../types/models'; + +const WEEK_MS = 7 * 24 * 3_600_000; +// The mock approximates "today" as the next 24 hours — precise local-day +// boundaries are the real backend's concern (ARCHITECTURE_ESSENTIALS.md). +const TODAY_MS = 24 * 3_600_000; + +export const searchHandlers = [ + // Controlled-vocabulary autocomplete: `?q=&limit=`, substring + // match on the term, case-insensitive. + http.get( + '*/symptoms', + respond('list', ({ request }) => { + const url = new URL(request.url); + const q = (url.searchParams.get('q') ?? '').trim().toLowerCase(); + const limit = Number(url.searchParams.get('limit') ?? '8'); + + if (!q) return []; + const matches: SymptomSuggestion[] = db.symptoms + .filter((symptom) => symptom.term.toLowerCase().includes(q)) + .map(({ slug, term }) => ({ slug, term })); + return matches.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 8); + }), + ), + + // The specialties a vocabulary symptom maps to. Registered after the + // collection route so `?q=` above wins for the bare path. + http.get( + '*/symptoms/:slug', + respond('json', ({ params }) => { + const symptom = db.symptoms.find((entry) => entry.slug === String(params.slug)); + if (!symptom) return notFound('symptom'); + + const mapping: SymptomSpecialtyMapping = { + slug: symptom.slug, + term: symptom.term, + specialties: symptom.specialtyIds + .map((id) => db.specialties.find((specialty) => specialty.id === id)) + .filter((specialty): specialty is NonNullable => Boolean(specialty)), + }; + return mapping; + }), + ), + + // Doctors by mapped specialties + availability. `specialtyIds` is a + // comma-separated list matched with OR (a symptom can map to several + // specialties); only verified doctors are ever visible to patients. + http.get( + '*/doctors', + respond('list', ({ request }) => { + const url = new URL(request.url); + const specialtyIds = (url.searchParams.get('specialtyIds') ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + const availability = url.searchParams.get('availability'); + + let doctors: DoctorSearchResult[] = db.doctors + .filter((doctor) => doctor.verificationStatus === 'verified') + .map((doctor) => ({ ...doctor, specialties: doctor.specialties.map((s) => ({ ...s })) })); + + if (specialtyIds.length > 0) { + doctors = doctors.filter((doctor) => + doctor.specialties.some((specialty) => specialtyIds.includes(specialty.id)), + ); + } + + if (availability === 'today' || availability === 'week') { + const windowMs = availability === 'today' ? TODAY_MS : WEEK_MS; + const now = Date.now(); + doctors = doctors.filter((doctor) => { + if (!doctor.nextAvailableAt) return false; + const availableAt = new Date(doctor.nextAvailableAt).getTime(); + return availableAt >= now && availableAt - now <= windowMs; + }); + } + + // Soonest-available first; "no slots" listings last. + doctors.sort((a, b) => { + if (!a.nextAvailableAt) return 1; + if (!b.nextAvailableAt) return -1; + return a.nextAvailableAt.localeCompare(b.nextAvailableAt); + }); + return doctors; + }), + ), +]; diff --git a/src/mocks/setup.tsx b/src/mocks/setup.tsx index 0cdf218c..f0e92adc 100644 --- a/src/mocks/setup.tsx +++ b/src/mocks/setup.tsx @@ -14,16 +14,28 @@ import { useMockStore } from './mock-store'; * first React render, so the persona session exists when route guards first * evaluate — otherwise RequireAuth would bounce a deep-linked protected route * to #/login before the mocks boot. + * + * `worker.start()` is awaited on purpose: until it resolves, the service + * worker passes requests through untouched, so a deep-linked page firing + * its queries during boot would hit the real API URL instead of the mocks + * (with `bypass`, silently). Rendering only after the worker is ready + * closes that race. */ export async function startMockApi(): Promise { - // `BASE_URL` is `/` in dev and can be a subpath when the app is hosted - // under one. Registering the worker at the root path would 404 there and the - // page would fall through to the (deliberately invalid) API URL instead of - // the mocks. - await worker.start({ - onUnhandledRequest: 'bypass', - serviceWorker: { url: `${import.meta.env.BASE_URL}mockServiceWorker.js` }, - }); + try { + // `BASE_URL` is `/` in dev and can be a subpath when the app is hosted + // under one. Registering the worker at the root path would 404 there and + // the page would fall through to the (deliberately invalid) API URL + // instead of the mocks. + await worker.start({ + onUnhandledRequest: 'bypass', + serviceWorker: { url: `${import.meta.env.BASE_URL}mockServiceWorker.js` }, + }); + } catch (error) { + // Still boot the app (and the dev panel) — without the worker, requests + // simply fall through to the configured API base URL. + console.warn('[MSW] Mock worker failed to start; requests are not mocked.', error); + } // Sign the persona in immediately. Navigate to the role's home screen // unless the URL already deep-links a route, which we leave untouched. diff --git a/src/pages/DoctorSearchPage.tsx b/src/pages/DoctorSearchPage.tsx new file mode 100644 index 00000000..bc6b1be4 --- /dev/null +++ b/src/pages/DoctorSearchPage.tsx @@ -0,0 +1,11 @@ +import { ActingForBanner } from '../features/dependents/components/ActingForBanner'; +import { DoctorSearchView } from '../features/search/components/DoctorSearchView'; + +export function DoctorSearchPage() { + return ( +
    + + +
    + ); +} diff --git a/src/pages/PatientDashboardPage.test.tsx b/src/pages/PatientDashboardPage.test.tsx index a26d2df3..158f4ea2 100644 --- a/src/pages/PatientDashboardPage.test.tsx +++ b/src/pages/PatientDashboardPage.test.tsx @@ -214,9 +214,15 @@ describe('PatientDashboardPage care hub', () => { it('files a later visit under Coming up without promising check-in today', async () => { vi.mocked(dependentsService.listDependents).mockResolvedValue([]); - vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([ - appointment(new Date(Date.now() + 26 * 60 * 60 * 1000)), - ]); + // "Tomorrow" in the component's own terms is the calendar day of + // now + 24h (formatSlotDate compares against that), so build the visit + // from that day. A raw now + 26h only lands on it when the run starts + // before 22:00 local — after that it crosses two midnights and the label + // falls back to a plain date. Noon keeps the slot unambiguously ahead of + // the patient, so the card lands under "Coming up". + const tomorrow = new Date(Date.now() + DAY_MS); + const visit = new Date(tomorrow.getFullYear(), tomorrow.getMonth(), tomorrow.getDate(), 12, 0, 0); + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([appointment(visit)]); render(); diff --git a/src/pages/PatientDashboardPage.tsx b/src/pages/PatientDashboardPage.tsx index 46018943..e1651082 100644 --- a/src/pages/PatientDashboardPage.tsx +++ b/src/pages/PatientDashboardPage.tsx @@ -12,6 +12,7 @@ import { HealthProfilePage } from './HealthProfilePage'; import { AccountSettingsPage } from './AccountSettingsPage'; import { FacilityFinderPage } from './FacilityFinderPage'; import { CareThreadPage } from './CareThreadPage'; +import { DoctorSearchPage } from './DoctorSearchPage'; import { OrdersPage } from './OrdersPage'; import { OrderDetailPage } from './OrderDetailPage'; import { PatientOrdersPage } from './PatientOrdersPage'; @@ -80,6 +81,7 @@ function PatientHome() { )}