Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<text>&limit=<n>` → `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=<id,id>&availability=<week|today>` → `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=<slug>&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?}`
Expand Down
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Binary file modified e2e/__screenshots__/visual-desktop/patient-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/__screenshots__/visual-mobile/patient-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/doctor-search-1-initial-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/doctor-search-1-initial-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/doctor-search-3-results-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/doctor-search-3-results-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/doctor-search-4-no-match-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/doctor-search-4-no-match-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
129 changes: 129 additions & 0 deletions src/components/Combobox.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
198 changes: 198 additions & 0 deletions src/components/Combobox.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Combobox
id="test-combobox"
label="Symptoms"
selectedOptionId={selected}
inputValue={text}
onInputChange={setText}
onSelect={(optionId) => {
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(<Harness />);
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(<Harness />);

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(<Harness />);
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(<Harness onSelect={onSelect} />);

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(<Harness onSelect={onSelect} />);

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(<Harness onSelect={onSelect} />);

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(<Harness onSelect={onSelect} />);

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(<Harness options={[]} />);

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(<Harness loading={false} />);

await user.type(getInput(), 'ch');
rerender(<Harness loading={true} />);

expect(screen.getByRole('status')).toHaveTextContent('Loading suggestions');
});
});
Loading
Loading