diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index d4be416..cde87c3 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -264,6 +264,134 @@ assumed and mirrored in `models.ts` under "Phase W6 — Feature flags console". - Approaching deadline warnings: prominent alerts when held escrow refund deadline is < 48 hours away and order is unfulfilled (Threat Model T8 compliance). - Direct explorer links to Stellar testnet transactions (`https://stellar.expert/explorer/testnet/tx/...`). +### Care hub — patient home (#33) +No new backend endpoint and no new assumed shape. The hub is a +client-side aggregation over the two contracts above — +`GET /patients/me/dependents` and `GET /patients/:profileId/health/:section` +— ranked into sections in `src/features/care-hub/ranking.ts`. The derived +`CareCard` is deliberately a presentation-only type (it exists in the +browser and is never sent to or received from `docforum-core`), so it does +not violate hard rule 2. + +Two things the issues ask for are not yet wired because their backend +contract does not exist here: +- **There is no way to learn the signed-in patient's own `patientProfileId`.** + `#20`'s profile store is populated only by tests — no response in this + repo carries an `ActingProfile`, and `User` has no profile id — so + `selectedProfileId` is `null` in a real session and every health-profile + query stays disabled. That is a `docforum-core` gap to file (hard rule 1), + not something to paper over by assuming `profileId === userId`. The care + hub reports such a source as `unavailable` rather than loading or failed, + so the screen renders the cards it *can* derive instead of spinning + forever, and never reads "we could not ask" as "you have nothing saved". + The health editor and dependents screens have the same dependency. +- **Referrals awaiting acceptance, orders needing a facility, results + ready, waitlist offers, follow-ups** wait on `docforum-core`#5–#16 + (see issues #4 and #5 here). Each will contribute its own card source + when that lands. + +"Coming up" is no longer one of those: since #32 it is fed by +`useMyAppointments()`, which reads `docforum-core`'s **real** +`GET /appointments/mine` (see below), so a visit happening today ranks under +"Action needed" and a later one under "Coming up" in a real session — the +appointments source needs only the access token, not a `patientProfileId`. + +## Known assumptions — Phase W2 patient follow-ons (#32, #34, #37) + +Everything under "assumed" below is a shape this repo *wrote down*, not one +`docforum-core` serves yet; each screen treats a 404 as "not available for +this deployment yet", never as "you have nothing". Reconcile against the real +contract when the backend issues land. + +### Appointments — the real part (`docforum-core` Phase 1) +- `GET /appointments/mine` (Bearer access token) → a **bare array**, not an + envelope, newest-first, each item with `availabilitySlot` included + (`{id, doctorProfileId, status, startTime, endTime, createdAt, updatedAt}`). +- `AppointmentStatus` is `scheduled | completed | cancelled | no_show` — + there is **no** `checked_in`/`being_seen` in the real enum, which is why + queue state lives in its own shape below rather than in `status`. +- `AppointmentOrigin` is `direct_booking | referral`; `SlotStatus` is + `open | held | booked`. +- **Still-missing from this real endpoint: no clinician name and no facility + name.** It returns `doctorProfileId` only, so every visit card and the + appointment detail page describe the *time*, never "Dr —". Putting a name + there needs a `docforum-core` change (include the doctor profile, or a + patient-facing summary route); this repo will not resolve a name by + guessing from an id. + +### Check-in and live queue (#32, assumed — `docforum-core` #129) +- `GET /appointments/:appointmentId/queue` → `AppointmentQueue` +- `POST /appointments/:appointmentId/check-in` → `AppointmentQueue` +- Both are keyed by an appointment id the patient already owns: + `AppointmentDetailPage` only asks for a queue after matching that id + against the real `GET /appointments/mine` list, so an unfamiliar id reads + "we cannot find that appointment on your account" and never reaches the + queue route at all. Authorising the id itself is still `docforum-core`'s + job. +- `AppointmentQueue` shape: `{appointmentId, phase: 'not_open'|'waiting'|'being_seen'|'completed', peopleAhead: number|null, estimatedWaitMinutes: number|null, checkInOpensAt: string|null, checkedInAt: string|null}` +- `null` in `peopleAhead`/`estimatedWaitMinutes` means "not published", and + is rendered as such — `0` would be a claim about the waiting room. +- Polling, not SSE: `refetchInterval` 15s while the panel is on screen, and + `gcTime: 0` so unmounting the page stops the requests. `docforum-core` has + no SSE endpoint to hook to yet. + +### 'Who viewed my records' access log (#34, assumed — `docforum-core` #45) +- `GET /patients/me/access-log?from&to&recordType&cursor` → `AccessLogPage` +- `AccessLogPage` shape: `{events: AccessLogEvent[], nextCursor: string|null}` +- `AccessLogEvent` shape: `{id, occurredAt, actorRole: 'doctor'|'facility'|'patient'|'admin', actorName, recordType: 'health_profile'|'appointment'|'referral'|'order'|'dependent', reason: 'consultation'|'order_fulfillment'|'result_delivery'|'handover_claim'|'support'}` +- The `me` scope is used because, again, the frontend cannot learn its own + `patientProfileId` (the gap above) — an id in the path would have to be + guessed. +- The "report a concern" link is gated on `VITE_SUPPORT_EMAIL` — no address + configured means no link, not a `mailto:` to an invented one. + +### Payment history and receipts (#37, partly real) +- **Real today:** the `PaymentIntent` row itself — `{id, orderType, orderId, + status, patientProfileId, facilityId, amountStroops, escrowId, + stellarTxHash, sorobanContractId, createdAt, updatedAt}` — with the BigInt + columns (`amountStroops`, `escrowId`) serialized to **strings**. Those + strings are kept as strings in `models.ts` and converted with `BigInt` + only (`src/features/payments/stroops.ts`); `Number` would round a payment + above 2^53 stroops. +- **Real today, admin-only:** `POST /payments/intents*` and + `GET /payments/intents/:id` require the `admin` role, which is why the + patient screens below are separate. +- Assumed (`docforum-core` #90 has not landed): + - `GET /payments/mine?cursor` → `PaymentHistoryPage` + - `GET /payments/mine/:intentId` → `PaymentHistoryEntry` + - `PaymentHistoryEntry extends PaymentIntent` with optional + `facilityName?: string` and `refundDeadlineAt?: string` — presentation + extras layered *on top of* the mirrored row, not a parallel shape (hard + rule 2). Both are omitted from the UI when absent. + - `patients` routes in `docforum-core` are still a placeholder module, so + every `me`-scoped patient endpoint above is a request, not a report. +- `src/features/payments/explorer.ts` builds testnet explorer links from + `VITE_STELLAR_EXPLORER_BASE_URL` (default + `https://testnet.stellar.expert/explorer/public`). This is display-only: + nothing here queries Stellar (hard rule 1) — it turns a hash + `docforum-core` already returned into a URL. + +### Cursor pagination (assumed across #34 and #37) +`docforum-core` has **no** cursor-pagination helper in `main` — the one +patient list that does exist, `GET /appointments/mine`, returns a bare +array. The `{ : [], nextCursor: string|null }` envelope used by +the access log and payment history is therefore an assumption, and the +frontend treats `nextCursor: null` as "nothing older". If the backend lands +offset pagination instead, only the two services and their +`getNextPageParam` need to change. + +### New routes from these three issues +- `/patient/appointments/:appointmentId` — nested under the patient + dashboard, because it is a detail screen of the hub. +- `/account/access-log`, `/payments`, `/payments/:intentId` — **not** nested + under `/patient`. `#34` names `/account/access-log` in the issue body and + payments are not patient-profile-scoped UI (a doctor or facility arriving + at `/payments` later would not belong under `/patient`), so these sit at + the literal paths the issues ask for, gated on the `patient` role in + `src/App.tsx` until a role actually needs them. +- All three lists are `useInfiniteQuery`-based; the patient asks for older + entries, the page never fetches ahead on its own. + ## Roadmap / status See `ROADMAP.md` in this repo, and Phase 7 in `docforum-core`'s `ROADMAP.md` for the original (pre-split) frontend phase this was carved diff --git a/ROADMAP.md b/ROADMAP.md index 30cb61f..2d65ca3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ real API" (no refresh-on-reload flow). this repo's static frontend only. ## Phase W2 — Patient core flow -**Fully scoped into issues #1–#5, #18–#21, #26–#29.** +**Fully scoped into issues #1–#5, #18–#21, #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. - [ ] 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. @@ -49,6 +49,15 @@ real API" (no refresh-on-reload flow). - [x] Facility finder with a map and distance-sorted results. Tracked as [issue #29](https://github.com/DocForum/docforum-web/issues/29) (High, 200 pts) — assumed contract against `docforum-core` issue #74. +- [x] Patient home as a care hub: action-first dashboard ranking what needs + attention now. Tracked as [issue #33](https://github.com/DocForum/docforum-web/issues/33) (High, 200 pts) — no new backend contract; aggregates the + dependents and health-profile endpoints above. Its "Coming up" section is fed + by the appointment cards below, which read `docforum-core`'s real + `GET /appointments/mine`; a dedicated appointments list screen + ([issue #3](https://github.com/DocForum/docforum-web/issues/3)) is still open. +- [x] Check-in and live queue position on appointment day. Tracked as [issue #32](https://github.com/DocForum/docforum-web/issues/32) (High, 200 pts) — the visit itself comes from `docforum-core`'s real `GET /appointments/mine`; the queue, check-in and its window are an assumed contract against `docforum-core` issue #129, which has not landed. +- [x] 'Who viewed my records' access log. Tracked as [issue #34](https://github.com/DocForum/docforum-web/issues/34) (High, 200 pts) — assumed contract against `docforum-core` issue #45 (PHI read-access log), which has not landed. +- [x] Payment history and receipts for patients. Tracked as [issue #37](https://github.com/DocForum/docforum-web/issues/37) (High, 200 pts) — `PaymentIntent` mirrors `docforum-core`'s real Phase 5.5 shape (`amountStroops`/`escrowId` arrive as strings and are formatted with BigInt, never through `Number`); the patient list/detail routes and the `refundDeadlineAt`/`facilityName` display fields are an assumed contract against `docforum-core` issue #90, which has not landed. ## Phase W3 — Doctor core flow **Fully scoped into issues #6–#7, with #45–#49 added as doctor core-flow features.** - [x] Today's appointments / intake view. Tracked as [issue #6](https://github.com/DocForum/docforum-web/issues/6) (Medium, 150 pts) — foundation laid via the doctor dashboard's live queue (`src/features/queue/`). @@ -84,7 +93,7 @@ real API" (no refresh-on-reload flow). transaction underneath. Admin accounts are seeded/invited, not self-serve (PRD OQ-2, same as facility) — this page is real once such a session exists, same caveat as the facility dashboard already had. -- [ ] Link transaction hashes/escrows to the Stellar testnet explorer. Tracked as [issue #10](https://github.com/DocForum/docforum-web/issues/10) (Trivial, 100 pts). +- [ ] Link transaction hashes/escrows to the Stellar testnet explorer. Tracked as [issue #10](https://github.com/DocForum/docforum-web/issues/10) (Trivial, 100 pts). The helper this needs now exists — `src/features/payments/explorer.ts`, used by the patient receipt built for [issue #37](https://github.com/DocForum/docforum-web/issues/37) under Phase W2 — but the admin console does not link out yet, so this stays open. ## Phase W6 — Admin console Admin-only screens beyond payments. Everything goes through `docforum-core`'s admin API; admin accounts stay seeded/invited (PRD OQ-2). @@ -355,6 +364,78 @@ Admin-only screens beyond payments. Everything goes through `docforum-core`'s ad `/patient/facilities`). Strict privacy enforcement: transient in-memory location only (never persisted to storage or URLs). 15 test files and 62/62 tests passing. +- 2026-10-16 — Rebuilt `PatientDashboardPage` from a placeholder into the + patient care hub ([issue #33](https://github.com/DocForum/docforum-web/issues/33)): `src/features/care-hub/` holds a pure + ranking module (`ranking.ts`) plus `useCareHub`, which fans out to the + existing dependents and health-profile queries in parallel and turns + their results into cards ranked `blocked now → needs you → good to know`, + with a 30-day "Recent" window. One failing source degrades its own + section instead of blanking the page, and a health section that *errored* + is never reported to the patient as an empty profile. Sections for + unbuilt features are hidden, not stubbed — "Coming up" only appears once + a card source for it lands, which [issue #32](https://github.com/DocForum/docforum-web/issues/32) + supplies from `docforum-core`'s real `GET /appointments/mine`. Adds + `src/features/care-hub/ranking.test.ts` and + `src/pages/PatientDashboardPage.test.tsx`; 57 tests pass (was 44 on + `main`), typecheck and build green. +- 2026-10-16 — Checked the three other assigned W2 issues against the + current code and backend: #32 (check-in + live queue position), #34 + ("who viewed my records" access log) and #37 (payment history and + receipts) each need a `docforum-core` endpoint that does not exist yet — + no check-in/queue route, no access-log route, and + `src/services/payments-service.ts` can create/read/fund a single + `PaymentIntent` by id but has no list-the-patient's-payments route. + Concluded they "stay unimplemented rather than built on a fabricated + contract", which was the wrong call and is corrected by the entries + below: hard rule 1 forbids calling Stellar/escrow directly, not building + a patient screen against a *documented* assumed shape — and #18, #19 and + #21 all did exactly that, recorded in "Known assumptions". What the check + did establish and what still gates these screens is the missing backend + work (`docforum-core` #129, #45 and #90). +- 2026-10-16 — Built #32: check-in and live queue position on appointment + day. `src/services/appointments-service.ts` calls `docforum-core`'s real + `GET /appointments/mine` (a bare array, newest-first, `availabilitySlot` + included) and the assumed queue/check-in routes. `src/features/appointments/` + holds the pure parts (`slot.ts` — local-calendar-day maths, so "today" + means the patient's day not UTC's; `queue.ts` — notification threshold and + a signature so a patient is told about a *change*, not every poll), the + `useAppointmentQueue` hook (polls only while the visit is on screen and + `status === 'scheduled'`, `gcTime: 0` so leaving the page stops the + requests) and `QueuePanel`. `AppointmentDetailPage` at + `/patient/appointments/:appointmentId` is reachable from the care hub's + "Coming up"/"Action needed" cards; check-in UI only appears on the day of + the visit. Estimates are always labelled as estimates, `peopleAhead: null` + renders as "not published" rather than "zero people ahead", and a 404 from + the unlanded queue route reads "check-in is not available for this visit + yet" instead of an empty queue. `src/hooks/useBrowserNotification.ts` asks + for permission only on a click and never re-asks after a refusal. +- 2026-10-16 — Built #34: 'who viewed my records' access log at + `/account/access-log`. `src/services/access-log-service.ts` reads the + assumed `GET /patients/me/access-log` (cursor-paginated, `from`/`to`/ + `recordType` filters) through `useInfiniteQuery`, so "show older entries" + asks for the next cursor only when the patient asks for it. + `src/features/account/` renders who (name + role), what and why, and + never an internal id; the "why" copy explains the treating-clinician and + fulfilling-facility cases rather than dumping a reason code. The + report-a-concern link is only rendered when `VITE_SUPPORT_EMAIL` is set — + an invented mailto would send a patient's privacy complaint nowhere. +- 2026-10-16 — Built #37: payment history and printable receipts at + `/payments` and `/payments/:intentId`. `src/features/payments/stroops.ts` + converts `amountStroops` with BigInt only, so an amount above 2^53 prints + exactly (`900,719,925.4740993 XLM`) instead of silently rounding the + patient's own money; `explorer.ts` is the single source of testnet + explorer links (all calls still go through `docforum-core` — hard rule 1 + — this only turns a hash core already returned into a URL); `status.ts` + labels and explains every `PaymentIntentStatus` in the patient's terms. + `PaymentReceipt` keeps the transaction hash and escrow contract id as + readable text so a printed copy is still verifiable, hides the page + chrome at print time, and omits `orderId`, which a patient cannot act on. + The assumed `refundDeadlineAt` renders nothing when absent rather than a + guessed window. +- 2026-10-16 — #32/#34/#37 together: 156 tests pass across 25 files (57 on + this branch before, 44 on `main`), `npm run typecheck` clean, `npm run + build` clean. Types for every assumed shape are annotated as assumed in + `src/types/models.ts` and recorded in `ARCHITECTURE_ESSENTIALS.md`. - 2026-10-17 — Built Phase W3 doctor core flow (#45–#49): live queue with start/complete visits, running delay + manual override and 4s undo window (#49); doctor messages inbox with unread badge, truncated PHI previews, diff --git a/docs/screenshots/2026-10-16-patient-care-hub/patient-home-desktop-no-care-profile.png b/docs/screenshots/2026-10-16-patient-care-hub/patient-home-desktop-no-care-profile.png new file mode 100755 index 0000000..5411f8a Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-care-hub/patient-home-desktop-no-care-profile.png differ diff --git a/docs/screenshots/2026-10-16-patient-care-hub/patient-home-desktop.png b/docs/screenshots/2026-10-16-patient-care-hub/patient-home-desktop.png new file mode 100755 index 0000000..c9068c5 Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-care-hub/patient-home-desktop.png differ diff --git a/docs/screenshots/2026-10-16-patient-care-hub/patient-home-mobile.png b/docs/screenshots/2026-10-16-patient-care-hub/patient-home-mobile.png new file mode 100755 index 0000000..ba0d5d6 Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-care-hub/patient-home-mobile.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/access-log-desktop.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/access-log-desktop.png new file mode 100755 index 0000000..a0f8a19 Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/access-log-desktop.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/access-log-mobile.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/access-log-mobile.png new file mode 100755 index 0000000..bd545a3 Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/access-log-mobile.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/appointment-queue-desktop.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/appointment-queue-desktop.png new file mode 100755 index 0000000..54e3c24 Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/appointment-queue-desktop.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/appointment-queue-mobile.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/appointment-queue-mobile.png new file mode 100755 index 0000000..5dced2c Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/appointment-queue-mobile.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/care-hub-desktop.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/care-hub-desktop.png new file mode 100755 index 0000000..29e1412 Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/care-hub-desktop.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/care-hub-mobile.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/care-hub-mobile.png new file mode 100755 index 0000000..0c1e2bf Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/care-hub-mobile.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payment-receipt-desktop.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payment-receipt-desktop.png new file mode 100755 index 0000000..b6b4b8e Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payment-receipt-desktop.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payment-receipt-mobile.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payment-receipt-mobile.png new file mode 100755 index 0000000..6fe44ad Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payment-receipt-mobile.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payments-history-desktop.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payments-history-desktop.png new file mode 100755 index 0000000..a36eb05 Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payments-history-desktop.png differ diff --git a/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payments-history-mobile.png b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payments-history-mobile.png new file mode 100755 index 0000000..8375cb4 Binary files /dev/null and b/docs/screenshots/2026-10-16-patient-checkin-access-log-payments/payments-history-mobile.png differ diff --git a/src/App.tsx b/src/App.tsx index 6f63b37..a0158f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,9 @@ import { DoctorDashboardPage } from './pages/DoctorDashboardPage'; import { FacilityDashboardPage } from './pages/FacilityDashboardPage'; import { AdminDashboardPage } from './pages/AdminDashboardPage'; import { ClaimAccountPage } from './pages/ClaimAccountPage'; +import { AccessLogPage } from './pages/AccessLogPage'; +import { PaymentDetailPage } from './pages/PaymentDetailPage'; +import { PaymentHistoryPage } from './pages/PaymentHistoryPage'; import { CareThreadPage } from './pages/CareThreadPage'; import { AccountDeletedPage } from './pages/AccountDeletedPage'; import { NotFoundPage } from './pages/NotFoundPage'; @@ -32,6 +35,34 @@ export default function App() { } /> } /> } /> + {/* Issue-path routes (#34 `/account/access-log`, #37 `/payments`) stay + at the literal path the issues name, guarded to patients, rather + than being folded under /patient where the hub's nested routes + live. #32's detail page has no path of its own, so it nests. */} + + + + } + /> + + + + } + /> + + + + } + /> } /> + {message} +

+ ); +} diff --git a/src/features/account/components/AccessLogFilters.module.css b/src/features/account/components/AccessLogFilters.module.css new file mode 100644 index 0000000..3606e5d --- /dev/null +++ b/src/features/account/components/AccessLogFilters.module.css @@ -0,0 +1,48 @@ +.form { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + align-items: flex-end; + margin-bottom: var(--space-6); +} + +/* The two date fields and the select share one row, so they need the same + vertical rhythm TextField gives its own inputs without the extra margin. */ +.form > div { + margin-bottom: 0; +} + +.field { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.label { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text); +} + +.select { + 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); +} + +.select:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-subtle); +} + +.rangeError { + font-size: var(--text-xs); + color: var(--color-danger); + margin: 0; + flex-basis: 100%; +} diff --git a/src/features/account/components/AccessLogFilters.tsx b/src/features/account/components/AccessLogFilters.tsx new file mode 100644 index 0000000..7b30f32 --- /dev/null +++ b/src/features/account/components/AccessLogFilters.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { Button } from '../../../components/Button'; +import { TextField } from '../../../components/TextField'; +import { FILTER_OPTIONS } from '../copy'; +import type { AccessLogFilters } from '../../../services/access-log-service'; +import type { AccessRecordType } from '../../../types/models'; +import styles from './AccessLogFilters.module.css'; + +interface Props { + onApply: (filters: AccessLogFilters) => void; + busy: boolean; +} + +const ANY_TYPE = ''; + +export function AccessLogFiltersForm({ onApply, busy }: Props) { + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [recordType, setRecordType] = useState(ANY_TYPE); + const [rangeError, setRangeError] = useState(null); + + function submit(event: FormEvent) { + event.preventDefault(); + // A reversed range is a request that can only ever return nothing, so it + // is corrected here rather than sent and reported as an empty log. + if (from && to && new Date(from) > new Date(to)) { + setRangeError('The first date must be before the second.'); + return; + } + setRangeError(null); + onApply({ from: from || undefined, to: to || undefined, recordType: recordType || undefined }); + } + + return ( +
+ setFrom(event.target.value)} + max={to || undefined} + /> + setTo(event.target.value)} min={from || undefined} /> + +
+ + +
+ + + + {rangeError && ( +

+ {rangeError} +

+ )} + + ); +} diff --git a/src/features/account/components/AccessLogList.module.css b/src/features/account/components/AccessLogList.module.css new file mode 100644 index 0000000..3dbed88 --- /dev/null +++ b/src/features/account/components/AccessLogList.module.css @@ -0,0 +1,64 @@ +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.event { + border-left: 2px solid var(--color-border-strong); + padding-left: var(--space-4); +} + +.headline { + display: flex; + align-items: baseline; + gap: var(--space-2); + margin: 0; + color: var(--color-text); + max-width: none; +} + +.actor { + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: 600; +} + +.role { + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-accent-2); + background: var(--color-accent-2-subtle); + border-radius: var(--radius-full); + padding: 0.125rem 0.5rem; +} + +.what { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin: var(--space-1) 0 0; + max-width: none; +} + +.when { + color: var(--color-text-faint); +} + +.why { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin: var(--space-1) 0 0; + max-width: none; +} + +.empty { + font-size: var(--text-sm); + color: var(--color-text-muted); + background: var(--color-surface-alt); + border-radius: var(--radius-sm); + padding: var(--space-4); + margin: 0; +} diff --git a/src/features/account/components/AccessLogList.tsx b/src/features/account/components/AccessLogList.tsx new file mode 100644 index 0000000..550f907 --- /dev/null +++ b/src/features/account/components/AccessLogList.tsx @@ -0,0 +1,34 @@ +import { formatWhen, recordLabel, reasonCopy, roleLabel } from '../copy'; +import type { AccessLogEvent } from '../../../types/models'; +import styles from './AccessLogList.module.css'; + +/** Deliberately renders only what docforum-core's log returns: no internal + * ids, no record pointers — a privacy screen that leaks an id is still a + * leak. */ +export function AccessLogList({ events }: { events: AccessLogEvent[] }) { + if (events.length === 0) { + return ( +

+ No one has opened your record yet. When a clinician or facility does, it will appear here with the + reason for the visit or order. +

+ ); + } + + return ( +
    + {events.map((event) => ( +
  • +

    + {event.actorName} + {roleLabel(event.actorRole)} +

    +

    + opened {recordLabel(event.recordType)} · {formatWhen(event.occurredAt)} +

    +

    {reasonCopy(event.reason)}

    +
  • + ))} +
+ ); +} diff --git a/src/features/account/copy.ts b/src/features/account/copy.ts new file mode 100644 index 0000000..60f4ed7 --- /dev/null +++ b/src/features/account/copy.ts @@ -0,0 +1,63 @@ +import type { AccessActorRole, AccessReason, AccessRecordType } from '../../types/models'; + +export const ROLE_LABEL: Record = { + doctor: 'Clinician', + facility: 'Facility', + patient: 'You', + admin: 'DocForum staff', +}; + +export const RECORD_LABEL: Record = { + health_profile: 'your health profile', + appointment: 'an appointment', + referral: 'a referral', + order: 'an order', + dependent: 'a dependent record', +}; + +export const REASON_COPY: Record = { + consultation: 'Opened while treating you at a visit.', + order_fulfillment: 'Opened to carry out an order for you.', + result_delivery: 'Opened to send you a result.', + handover_claim: 'Opened while a record was being handed over.', + support: 'Opened by DocForum staff while handling a request about your account.', +}; + +export const FILTER_OPTIONS: { value: AccessRecordType; label: string }[] = [ + { value: 'health_profile', label: 'Health profile' }, + { value: 'appointment', label: 'Appointments' }, + { value: 'referral', label: 'Referrals' }, + { value: 'order', label: 'Orders' }, + { value: 'dependent', label: 'Dependents' }, +]; + +/** The log is written by docforum-core, which may add a reason before this + * repo adds its sentence — an unknown value must not render "undefined" on a + * privacy screen. */ +export function roleLabel(role: AccessActorRole): string { + return ROLE_LABEL[role] ?? 'Someone with access'; +} + +export function recordLabel(recordType: AccessRecordType): string { + return RECORD_LABEL[recordType] ?? 'part of your record'; +} + +export function reasonCopy(reason: AccessReason): string { + return REASON_COPY[reason] ?? 'Opened as part of your care.'; +} + +export function formatWhen(occurredAt: string): string { + const date = new Date(occurredAt); + return Number.isNaN(date.getTime()) ? occurredAt : date.toLocaleString(); +} + +/** No support mailbox is configured anywhere in this repo yet, so the + * report-a-concern link is built from `VITE_SUPPORT_EMAIL` and simply is not + * a link when that is unset — pointing patients at an address that does not + * exist is worse than telling them the channel is not wired up. */ +export function reportConcernHref(): string | null { + const supportEmail = import.meta.env.VITE_SUPPORT_EMAIL; + if (!supportEmail) return null; + const subject = 'Concern about access to my health record'; + return `mailto:${supportEmail}?subject=${encodeURIComponent(subject)}`; +} diff --git a/src/features/account/hooks/useAccessLog.ts b/src/features/account/hooks/useAccessLog.ts new file mode 100644 index 0000000..60b2ab7 --- /dev/null +++ b/src/features/account/hooks/useAccessLog.ts @@ -0,0 +1,19 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import * as accessLogService from '../../../services/access-log-service'; +import type { AccessLogFilters } from '../../../services/access-log-service'; +import { useAuthStore } from '../../../store/auth-store'; + +export function useAccessLog(filters: AccessLogFilters) { + const accessToken = useAuthStore((state) => state.accessToken); + + return useInfiniteQuery({ + queryKey: ['access-log', filters], + queryFn: ({ pageParam }) => + accessLogService.listAccessLog({ ...filters, cursor: pageParam }, accessToken!), + initialPageParam: undefined as string | undefined, + // null from the API is the end of the log; `undefined` is what React Query + // reads as "there is no next page". + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + enabled: Boolean(accessToken), + }); +} diff --git a/src/features/appointments/components/QueuePanel.module.css b/src/features/appointments/components/QueuePanel.module.css new file mode 100644 index 0000000..a34097c --- /dev/null +++ b/src/features/appointments/components/QueuePanel.module.css @@ -0,0 +1,56 @@ +.panel { + display: flex; + flex-direction: column; + gap: var(--space-3); + align-items: flex-start; + border-top: 1px solid var(--color-border); + margin-top: var(--space-6); + padding-top: var(--space-6); +} + +.heading { + font-family: var(--font-display); + font-size: var(--text-lg); + color: var(--color-text); + margin: 0; +} + +.state { + font-size: var(--text-lg); + font-family: var(--font-display); + color: var(--color-text); + margin: 0; +} + +.checkedIn { + font-size: var(--text-sm); + color: var(--color-text-faint); + margin: 0; +} + +.notice { + font-size: var(--text-sm); + color: var(--color-text-muted); + background: var(--color-surface-alt); + border-radius: var(--radius-sm); + padding: var(--space-3); + margin: 0; + max-width: none; +} + +.stale { + font-size: var(--text-sm); + color: var(--color-text-muted); + background: var(--color-warning-surface); + border-radius: var(--radius-sm); + padding: var(--space-3); + margin: 0; +} + +.alerts { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--color-text-muted); +} diff --git a/src/features/appointments/components/QueuePanel.test.tsx b/src/features/appointments/components/QueuePanel.test.tsx new file mode 100644 index 0000000..d57c0f8 --- /dev/null +++ b/src/features/appointments/components/QueuePanel.test.tsx @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../test/render'; +import { QueuePanel } from './QueuePanel'; +import * as appointmentsService from '../../../services/appointments-service'; +import { ApiError } from '../../../services/api-client'; +import { useAuthStore } from '../../../store/auth-store'; +import type { PatientAppointment, AppointmentQueue } from '../../../types/models'; + +vi.mock('../../../services/appointments-service'); + +const NOW = new Date('2026-10-16T12:00:00Z'); + +class Prompt { + static permission: NotificationPermission = 'default'; + static answer: NotificationPermission = 'granted'; + static count = 0; + + static requestPermission(): Promise { + Prompt.count += 1; + Prompt.permission = Prompt.answer; + return Promise.resolve(Prompt.answer); + } +} + +function stubNotification(permission: NotificationPermission, answer?: NotificationPermission) { + Prompt.permission = permission; + Prompt.answer = answer ?? permission; + Prompt.count = 0; + vi.stubGlobal('Notification', Prompt); +} + +function queue(overrides: Partial = {}): AppointmentQueue { + return { + appointmentId: 'apt-1', + phase: 'waiting', + peopleAhead: 3, + estimatedWaitMinutes: 12, + checkInOpensAt: null, + checkedInAt: '2026-10-16T11:30:00Z', + ...overrides, + }; +} + +const APPOINTMENT: PatientAppointment = { + id: 'apt-1', + patientProfileId: 'pp-1', + doctorProfileId: 'dp-1', + availabilitySlotId: 'slot-1', + status: 'scheduled', + origin: 'direct_booking', + createdAt: '2026-10-13T09:00:00Z', + updatedAt: '2026-10-13T09:00:00Z', + availabilitySlot: { + id: 'slot-1', + doctorProfileId: 'dp-1', + status: 'booked', + startTime: NOW.toISOString(), + endTime: '2026-10-16T13:00:00Z', + createdAt: '2026-10-01T09:00:00Z', + updatedAt: '2026-10-01T09:00:00Z', + }, +}; + +function mockQueue(value: AppointmentQueue | Error) { + vi.mocked(appointmentsService.getAppointmentQueue).mockImplementation(() => + value instanceof Error ? Promise.reject(value) : Promise.resolve(value), + ); +} + +async function renderPanel() { + render(); + return screen.findByText(/ahead of you|queue|turn|complete/i); +} + +describe('QueuePanel', () => { + beforeEach(() => { + useAuthStore.setState({ accessToken: 'test-token', user: null }); + vi.mocked(appointmentsService.getAppointmentQueue).mockReset(); + vi.mocked(appointmentsService.checkIn).mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows the position with the wait labelled as an estimate', async () => { + mockQueue(queue()); + await renderPanel(); + + expect(await screen.findByText(/3 people ahead of you/i)).toBeInTheDocument(); + expect(screen.getByText(/about 12 min \(estimate\)/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^check in$/i })).not.toBeInTheDocument(); + }); + + it('says the position is unpublished instead of reading it as first place', async () => { + mockQueue(queue({ peopleAhead: null, estimatedWaitMinutes: null })); + await renderPanel(); + + expect(await screen.findByText(/has not been published yet/i)).toBeInTheDocument(); + expect(screen.queryByText(/0 people ahead/i)).not.toBeInTheDocument(); + }); + + it('explains a check-in window that has not opened', async () => { + mockQueue( + queue({ + phase: 'not_open', + checkedInAt: null, + peopleAhead: null, + estimatedWaitMinutes: null, + checkInOpensAt: '2026-10-16T17:00:00Z', + }), + ); + render(); + + // Printed in the patient's own clock, so only the shape is asserted here. + expect(await screen.findByText(/check-in opens at \d{2}:\d{2}/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^check in$/i })).not.toBeInTheDocument(); + }); + + it('checks the patient in once the window has opened', async () => { + mockQueue(queue({ phase: 'not_open', checkedInAt: null, checkInOpensAt: '2026-10-16T11:00:00Z' })); + vi.mocked(appointmentsService.checkIn).mockResolvedValue(queue({ checkedInAt: '2026-10-16T12:00:00Z' })); + + render(); + // The window passed but the backend has not recomputed the phase yet, so + // the copy must not keep announcing a time that has already gone. + expect(await screen.findByText(/check-in is open now/i)).toBeInTheDocument(); + await userEvent.click(await screen.findByRole('button', { name: /^check in$/i })); + + await waitFor(() => expect(appointmentsService.checkIn).toHaveBeenCalledWith('apt-1', 'test-token')); + expect(await screen.findByText(/you are in the queue/i)).toBeInTheDocument(); + expect(screen.getByText(/checked in at \d{2}:\d{2}/i)).toBeInTheDocument(); + }); + + it('announces the turn', async () => { + mockQueue(queue({ phase: 'being_seen' })); + render(); + + expect(await screen.findByText(/it's your turn/i)).toBeInTheDocument(); + expect(screen.getByText(/go to the desk now/i)).toBeInTheDocument(); + }); + + it('does not offer check-in for a visit that has finished', async () => { + mockQueue(queue({ phase: 'completed' })); + render(); + + expect(await screen.findByText(/visit is complete/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^check in$/i })).not.toBeInTheDocument(); + }); + + // core#129 has not landed, so today a queue read is a 404 — and "there is no + // queue yet" must not be printed as "you are not in it". + it('says check-in is unavailable when the endpoint does not exist', async () => { + mockQueue(new ApiError('Not Found', 404)); + render(); + + expect(await screen.findByText(/check-in is not available for this visit yet/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^check in$/i })).not.toBeInTheDocument(); + }); + + it('surfaces the API message for any other queue failure', async () => { + mockQueue(new ApiError('The queue service is restarting.', 503)); + render(); + + expect(await screen.findByText(/queue service is restarting/i)).toBeInTheDocument(); + }); + + it('offers the alert opt-in while the patient is waiting, and only asks when it is ticked', async () => { + stubNotification('default', 'granted'); + mockQueue(queue()); + render(); + + const checkbox = await screen.findByRole('checkbox'); + expect(Prompt.count).toBe(0); + + await userEvent.click(checkbox); + await waitFor(() => expect(Prompt.count).toBe(1)); + }); + + it('stops offering alerts once the browser has refused them', async () => { + stubNotification('denied'); + mockQueue(queue()); + render(); + + expect(await screen.findByText(/turned off for this site/i)).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(Prompt.count).toBe(0); + }); + + it('says on-page updates still work when the browser has no alert API', async () => { + vi.stubGlobal('Notification', undefined); + mockQueue(queue()); + render(); + + expect(await screen.findByText(/3 people ahead of you/i)).toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryByText(/turned off for this site/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/appointments/components/QueuePanel.tsx b/src/features/appointments/components/QueuePanel.tsx new file mode 100644 index 0000000..d9f000b --- /dev/null +++ b/src/features/appointments/components/QueuePanel.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { Button } from '../../../components/Button'; +import { FormError } from '../../../components/FormError'; +import { ApiError } from '../../../services/api-client'; +import { useAppointmentQueue, useCheckIn, useQueueNotification } from '../hooks/useAppointmentQueue'; +import { canCheckIn } from '../queue'; +import { formatSlotTime, shouldPollQueue } from '../slot'; +import type { PatientAppointment, AppointmentQueue } from '../../../types/models'; +import styles from './QueuePanel.module.css'; + +interface Props { + appointment: PatientAppointment; + now: Date; +} + +/** A queue read that came back too long ago is not "live" — say so instead of + * leaving a stale position looking current. */ +const STALE_AFTER_MS = 60_000; + +function unavailableMessage(error: unknown): string { + if (error instanceof ApiError && error.status === 404) { + return 'Check-in is not available for this visit yet.'; + } + if (error instanceof ApiError) return error.message; + return 'The live queue could not be loaded.'; +} + +function phaseHeading(queue: AppointmentQueue): string { + switch (queue.phase) { + case 'not_open': + return 'Check-in is not open yet'; + case 'waiting': + return queue.checkedInAt ? 'You are in the queue' : 'Queue'; + case 'being_seen': + return "It's your turn"; + case 'completed': + return 'Your visit is complete'; + } +} + +/** null is reported as "not published", never as 0 — a missing position must + * not read as "you're next". */ +function queueDetail(queue: AppointmentQueue): string { + if (queue.phase === 'being_seen') return 'Go to the desk now — the clinician is ready for you.'; + if (queue.phase === 'completed') return 'Nothing left to wait for.'; + if (queue.peopleAhead === null) return 'Your place in the queue has not been published yet.'; + + const ahead = `${queue.peopleAhead} ${queue.peopleAhead === 1 ? 'person' : 'people'} ahead of you`; + // The issue's wording: an estimate is always labelled as one. + const wait = + queue.estimatedWaitMinutes === null ? ' · no wait estimate yet' : ` · about ${queue.estimatedWaitMinutes} min (estimate)`; + return `${ahead}${wait}`; +} + +function notOpenDetail(queue: AppointmentQueue, now: Date): string { + if (!queue.checkInOpensAt) return 'Check-in opens on the day of your appointment.'; + const opens = new Date(queue.checkInOpensAt); + if (Number.isNaN(opens.getTime())) return 'Check-in opens on the day of your appointment.'; + // A window that has already opened but not yet been recomputed by the + // backend is open — saying "opens at 11:00" next to a live button is a lie. + if (opens.getTime() <= now.getTime()) return 'Check-in is open now.'; + return `Check-in opens at ${formatSlotTime(queue.checkInOpensAt)}.`; +} + +export function QueuePanel({ appointment, now }: Props) { + const queueQuery = useAppointmentQueue(appointment.id, shouldPollQueue(appointment, now)); + const checkInMutation = useCheckIn(appointment.id); + const [alertsWanted, setAlertsWanted] = useState(false); + const notification = useQueueNotification(queueQuery.data, alertsWanted); + + if (queueQuery.isError) { + return ( +

+ {unavailableMessage(queueQuery.error)} +

+ ); + } + + const queue = queueQuery.data; + if (!queue) { + return ( +

+ {queueQuery.isFetching ? 'Checking the live queue…' : 'No live queue is available for this visit yet.'} +

+ ); + } + + const stale = queueQuery.dataUpdatedAt > 0 && now.getTime() - queueQuery.dataUpdatedAt > STALE_AFTER_MS; + const actionError = + checkInMutation.error instanceof ApiError + ? checkInMutation.error.message + : checkInMutation.error + ? 'We could not check you in. Try again.' + : null; + + async function toggleAlerts(event: ChangeEvent) { + const wanted = event.target.checked; + setAlertsWanted(wanted); + // Asked for here, on a deliberate click — never on page load, and never + // again once the browser has answered. + if (wanted && notification.permission === 'default') await notification.requestPermission(); + } + + return ( +
+

+ {phaseHeading(queue)} +

+ + {queue.phase === 'waiting' && queue.checkedInAt ? ( +

Checked in at {formatSlotTime(queue.checkedInAt)}.

+ ) : null} + +

+ {queue.phase === 'not_open' ? notOpenDetail(queue, now) : queueDetail(queue)} +

+ + {stale && ( +

+ This position is more than a minute old — the queue may have moved on. +

+ )} + + + + {canCheckIn(queue, now) && ( + + )} + + {/* A denied permission is stated once and then left alone; the queue + still updates on the page, which is the part that matters. */} + {queue.phase === 'waiting' && notification.permission !== 'unsupported' ? ( + notification.canRequest ? ( + + ) : notification.permission === 'denied' ? ( +

+ Browser alerts are turned off for this site, so we will not ask again. Your queue position still updates here. +

+ ) : ( +

You will get a browser alert when you are close to the front.

+ ) + ) : null} +
+ ); +} diff --git a/src/features/appointments/hooks/useAppointmentQueue.test.tsx b/src/features/appointments/hooks/useAppointmentQueue.test.tsx new file mode 100644 index 0000000..037f5bf --- /dev/null +++ b/src/features/appointments/hooks/useAppointmentQueue.test.tsx @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useAppointmentQueue } from './useAppointmentQueue'; +import * as appointmentsService from '../../../services/appointments-service'; +import { useAuthStore } from '../../../store/auth-store'; +import type { AppointmentQueue } from '../../../types/models'; + +vi.mock('../../../services/appointments-service'); + +const POLL_MS = 20; + +const QUEUE: AppointmentQueue = { + appointmentId: 'apt-1', + phase: 'waiting', + peopleAhead: 4, + estimatedWaitMinutes: 18, + checkInOpensAt: null, + checkedInAt: '2026-10-16T09:30:00Z', +}; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +} + +describe('useAppointmentQueue', () => { + beforeEach(() => { + useAuthStore.setState({ accessToken: 'test-token', user: null }); + vi.mocked(appointmentsService.getAppointmentQueue).mockReset(); + }); + + it('keeps re-reading the queue while it is on screen', async () => { + vi.mocked(appointmentsService.getAppointmentQueue).mockResolvedValue(QUEUE); + + renderHook(() => useAppointmentQueue('apt-1', true, POLL_MS), { wrapper }); + + await waitFor(() => expect(vi.mocked(appointmentsService.getAppointmentQueue).mock.calls.length).toBeGreaterThan(2)); + }); + + // The review criterion for #32: leaving the page must stop live updates. + it('stops polling as soon as the queue is no longer mounted', async () => { + const read = vi.mocked(appointmentsService.getAppointmentQueue).mockResolvedValue(QUEUE); + + const { unmount } = renderHook(() => useAppointmentQueue('apt-1', true, POLL_MS), { wrapper }); + + await waitFor(() => expect(read.mock.calls.length).toBeGreaterThan(1)); + unmount(); + + const callsAfterUnmount = read.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 6)); + expect(read.mock.calls.length).toBe(callsAfterUnmount); + }); + + it('never polls for a visit that is not happening today', async () => { + const read = vi.mocked(appointmentsService.getAppointmentQueue).mockResolvedValue(QUEUE); + + renderHook(() => useAppointmentQueue('apt-1', false, POLL_MS), { wrapper }); + + await waitFor(() => expect(read).toHaveBeenCalledTimes(1)); + // act() around the wait: the fetch's final state update lands while the + // hook is still mounted, and React must see it inside an act scope. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 6)); + }); + expect(read.mock.calls.length).toBe(1); + }); + + it('does not ask for a queue without a session', async () => { + useAuthStore.setState({ accessToken: null }); + const read = vi.mocked(appointmentsService.getAppointmentQueue).mockResolvedValue(QUEUE); + + const { result } = renderHook(() => useAppointmentQueue('apt-1', true, POLL_MS), { wrapper }); + + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 3)); + expect(read).not.toHaveBeenCalled(); + // A disabled query is idle, not "loading" — the page must be able to tell + // the two apart instead of spinning forever. + expect(result.current.isFetching).toBe(false); + }); +}); diff --git a/src/features/appointments/hooks/useAppointmentQueue.ts b/src/features/appointments/hooks/useAppointmentQueue.ts new file mode 100644 index 0000000..3c25630 --- /dev/null +++ b/src/features/appointments/hooks/useAppointmentQueue.ts @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import * as appointmentsService from '../../../services/appointments-service'; +import { useAuthStore } from '../../../store/auth-store'; +import { useBrowserNotification } from '../../../hooks/useBrowserNotification'; +import { notificationBody, queueSignature, shouldNotify } from '../queue'; +import type { AppointmentQueue } from '../../../types/models'; + +export const QUEUE_POLL_INTERVAL_MS = 15_000; + +function queueKey(appointmentId: string) { + return ['appointments', appointmentId, 'queue'] as const; +} + +export function useAppointmentQueue(appointmentId: string | undefined, poll: boolean, intervalMs = QUEUE_POLL_INTERVAL_MS) { + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: queueKey(appointmentId ?? ''), + queryFn: () => appointmentsService.getAppointmentQueue(appointmentId!, accessToken!), + enabled: Boolean(accessToken && appointmentId), + // The interval belongs to this observer: React Query clears it as soon as + // the last component using the queue unmounts, and `gcTime: 0` means an + // abandoned queue is dropped rather than kept warm for a later visit to + // this page. Leaving the appointment page therefore stops live updates. + refetchInterval: poll ? intervalMs : false, + refetchIntervalInBackground: false, + gcTime: 0, + }); +} + +export function useCheckIn(appointmentId: string) { + const accessToken = useAuthStore((state) => state.accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => appointmentsService.checkIn(appointmentId, accessToken!), + onSuccess: (queue) => queryClient.setQueryData(queueKey(appointmentId), queue), + }); +} + +/** Fires at most one notification per distinct queue state, and only while + * the patient has both opted in and granted the browser permission. */ +export function useQueueNotification(queue: AppointmentQueue | undefined, optedIn: boolean) { + const notification = useBrowserNotification(); + const firedFor = useRef(null); + + useEffect(() => { + if (!queue || !optedIn || !notification.canNotify) return; + const signature = queueSignature(queue); + if (firedFor.current === signature || !shouldNotify(queue)) return; + firedFor.current = signature; + notification.notify(notificationBody(queue)); + }, [queue, optedIn, notification]); + + return notification; +} diff --git a/src/features/appointments/hooks/useAppointments.ts b/src/features/appointments/hooks/useAppointments.ts new file mode 100644 index 0000000..0e32f32 --- /dev/null +++ b/src/features/appointments/hooks/useAppointments.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import * as appointmentsService from '../../../services/appointments-service'; +import { useAuthStore } from '../../../store/auth-store'; + +export const APPOINTMENTS_KEY = ['appointments', 'mine'] as const; + +export function useMyAppointments() { + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: APPOINTMENTS_KEY, + queryFn: () => appointmentsService.listMyAppointments(accessToken!), + enabled: Boolean(accessToken), + }); +} diff --git a/src/features/appointments/queue.test.ts b/src/features/appointments/queue.test.ts new file mode 100644 index 0000000..429ec2e --- /dev/null +++ b/src/features/appointments/queue.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { canCheckIn, notificationBody, queueSignature, shouldNotify } from './queue'; +import type { AppointmentQueue } from '../../types/models'; + +const NOW = new Date('2026-10-16T10:00:00Z'); + +function queue(overrides: Partial = {}): AppointmentQueue { + return { + appointmentId: 'apt-1', + phase: 'not_open', + peopleAhead: null, + estimatedWaitMinutes: null, + checkInOpensAt: '2026-10-16T09:00:00Z', + checkedInAt: null, + ...overrides, + }; +} + +describe('canCheckIn', () => { + it('offers the button once the window has opened', () => { + expect(canCheckIn(queue({ checkInOpensAt: '2026-10-16T09:59:00Z' }), NOW)).toBe(true); + }); + + it('withholds it before the window opens', () => { + expect(canCheckIn(queue({ checkInOpensAt: '2026-10-16T10:01:00Z' }), NOW)).toBe(false); + }); + + it('offers it when the backend publishes no window at all', () => { + expect(canCheckIn(queue({ checkInOpensAt: null }), NOW)).toBe(true); + }); + + it('never offers it twice, nor for a finished visit', () => { + expect(canCheckIn(queue({ phase: 'waiting', checkedInAt: '2026-10-16T09:30:00Z' }), NOW)).toBe(false); + expect(canCheckIn(queue({ phase: 'completed', checkedInAt: '2026-10-16T09:30:00Z' }), NOW)).toBe(false); + expect(canCheckIn(queue({ phase: 'being_seen', checkedInAt: '2026-10-16T09:30:00Z' }), NOW)).toBe(false); + }); +}); + +describe('shouldNotify', () => { + it('alerts at two ahead and below, not at three', () => { + const waiting = { phase: 'waiting' as const, checkedInAt: '2026-10-16T09:30:00Z' }; + expect(shouldNotify(queue({ ...waiting, peopleAhead: 2 }))).toBe(true); + expect(shouldNotify(queue({ ...waiting, peopleAhead: 1 }))).toBe(true); + expect(shouldNotify(queue({ ...waiting, peopleAhead: 3 }))).toBe(false); + }); + + it('treats an unpublished position as nothing to alert about', () => { + expect(shouldNotify(queue({ phase: 'waiting', peopleAhead: null, checkedInAt: 'x' }))).toBe(false); + }); + + it("alerts the moment it is the patient's turn", () => { + expect(shouldNotify(queue({ phase: 'being_seen', peopleAhead: 7, checkedInAt: 'x' }))).toBe(true); + }); + + it('does not alert before check-in is even possible', () => { + expect(shouldNotify(queue({ phase: 'not_open', peopleAhead: 0 }))).toBe(false); + expect(shouldNotify(queue({ phase: 'completed', peopleAhead: 0 }))).toBe(false); + }); +}); + +describe('queueSignature', () => { + it('is stable for an unchanged queue and changes with the position', () => { + const waiting = queue({ phase: 'waiting', peopleAhead: 3, checkedInAt: 'x' }); + expect(queueSignature(waiting)).toBe(queueSignature({ ...waiting })); + expect(queueSignature(waiting)).not.toBe(queueSignature({ ...waiting, peopleAhead: 2 })); + expect(queueSignature(waiting)).not.toBe(queueSignature({ ...waiting, phase: 'being_seen' })); + }); + + it('keeps an unpublished position distinct from zero ahead', () => { + expect(queueSignature(queue({ peopleAhead: null }))).not.toBe(queueSignature(queue({ peopleAhead: 0 }))); + }); +}); + +describe('notificationBody', () => { + it('labels the wait as an estimate', () => { + expect(notificationBody(queue({ phase: 'waiting', peopleAhead: 2, estimatedWaitMinutes: 12 }))).toBe( + 'Only 2 ahead of you — about 12 min (estimate).', + ); + }); + + it('omits an estimate that does not exist', () => { + expect(notificationBody(queue({ phase: 'waiting', peopleAhead: 1 }))).toBe('Only 1 ahead of you.'); + }); + + it('says it is your turn', () => { + expect(notificationBody(queue({ phase: 'being_seen' }))).toBe('It is your turn now.'); + }); +}); diff --git a/src/features/appointments/queue.ts b/src/features/appointments/queue.ts new file mode 100644 index 0000000..620f104 --- /dev/null +++ b/src/features/appointments/queue.ts @@ -0,0 +1,39 @@ +import type { AppointmentQueue } from '../../types/models'; + +/** The issue asks for an alert at "1–2 patients ahead". */ +export const NOTIFY_AT_OR_BELOW = 2; + +/** "Being seen" is worth an alert however long the queue looked a moment + * ago — it is the state the whole feature exists to save waiting for. */ +export function shouldNotify(queue: AppointmentQueue): boolean { + if (queue.phase === 'being_seen') return true; + return ( + queue.phase === 'waiting' && + queue.peopleAhead !== null && + queue.peopleAhead <= NOTIFY_AT_OR_BELOW + ); +} + +/** Two identical reads must produce one alert, not one per poll — but a + * position that moves 3 → 2 → 1 is three different states. */ +export function queueSignature(queue: AppointmentQueue): string { + return `${queue.phase}:${queue.peopleAhead ?? 'unknown'}`; +} + +export function notificationBody(queue: AppointmentQueue): string { + if (queue.phase === 'being_seen') return 'It is your turn now.'; + const ahead = queue.peopleAhead ?? 0; + const wait = queue.estimatedWaitMinutes === null ? '' : ` — about ${queue.estimatedWaitMinutes} min (estimate)`; + return `Only ${ahead} ahead of you${wait}.`; +} + +/** The window is the backend's call. `not_open` with an opening time already + * in the past just means the projection has not recomputed yet, so the + * button is offered; a missing opening time means this backend has no window + * at all and the panel is only ever rendered on the day of the visit. */ +export function canCheckIn(queue: AppointmentQueue, now: Date): boolean { + if (queue.checkedInAt) return false; + if (queue.phase === 'completed') return false; + if (queue.phase === 'waiting' || queue.phase === 'being_seen') return true; + return queue.checkInOpensAt === null || new Date(queue.checkInOpensAt) <= now; +} diff --git a/src/features/appointments/slot.test.ts b/src/features/appointments/slot.test.ts new file mode 100644 index 0000000..b671977 --- /dev/null +++ b/src/features/appointments/slot.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { formatSlotDate, formatSlotTime, isOnSlotDay, isUpcoming, shouldPollQueue } from './slot'; +import type { PatientAppointment } from '../../types/models'; + +/** Local-time builders, so nothing in these assertions depends on the TZ the + * suite happens to run in. */ +function local(dayOffset: number, hour: number, minute = 0): string { + const base = new Date(2026, 9, 16, 9, 0, 0); + base.setDate(base.getDate() + dayOffset); + base.setHours(hour, minute, 0, 0); + return base.toISOString(); +} + +const TODAY = new Date(2026, 9, 16, 12, 0, 0); + +function appointment(overrides: Partial = {}): PatientAppointment { + return { + id: 'apt-1', + patientProfileId: 'pp-1', + doctorProfileId: 'dp-1', + availabilitySlotId: 'slot-1', + status: 'scheduled', + origin: 'direct_booking', + createdAt: local(-3, 9), + updatedAt: local(-3, 9), + availabilitySlot: { + id: 'slot-1', + doctorProfileId: 'dp-1', + status: 'booked', + startTime: local(0, 14, 30), + endTime: local(0, 15), + createdAt: local(-10, 9), + updatedAt: local(-10, 9), + }, + ...overrides, + }; +} + +function withSlot(overrides: Partial): PatientAppointment { + const base = appointment(); + return { ...base, availabilitySlot: { ...base.availabilitySlot, ...overrides } }; +} + +describe('slot day maths', () => { + it('recognises the day of the visit', () => { + expect(isOnSlotDay(appointment(), TODAY)).toBe(true); + expect(isOnSlotDay(withSlot({ startTime: local(1, 9) }), TODAY)).toBe(false); + }); + + it('treats a slot near midnight as still being that local day', () => { + expect(isOnSlotDay(withSlot({ startTime: local(0, 0, 5) }), TODAY)).toBe(true); + expect(isOnSlotDay(withSlot({ startTime: local(0, 23, 45) }), TODAY)).toBe(true); + }); + + it('polls only a scheduled visit happening today', () => { + expect(shouldPollQueue(appointment(), TODAY)).toBe(true); + expect(shouldPollQueue(withSlot({ startTime: local(1, 9) }), TODAY)).toBe(false); + expect(shouldPollQueue(appointment({ status: 'completed' }), TODAY)).toBe(false); + expect(shouldPollQueue(appointment({ status: 'cancelled' }), TODAY)).toBe(false); + }); + + it('keeps a future visit upcoming and drops a past one', () => { + expect(isUpcoming(withSlot({ startTime: local(5, 9) }), TODAY)).toBe(true); + expect(isUpcoming(appointment(), TODAY)).toBe(true); + expect(isUpcoming(withSlot({ startTime: local(-1, 9) }), TODAY)).toBe(false); + expect(isUpcoming(appointment({ status: 'cancelled' }), TODAY)).toBe(false); + }); + + it('labels the day in the words a patient would use', () => { + expect(formatSlotDate(local(0, 9), TODAY)).toBe('today'); + expect(formatSlotDate(local(1, 9), TODAY)).toBe('tomorrow'); + expect(formatSlotDate(local(4, 9), TODAY)).toMatch(/2026/); + }); + + it('prints a 24-hour start time and never a made-up one', () => { + expect(formatSlotTime(local(0, 9, 5))).toBe('09:05'); + expect(formatSlotTime('not-a-date')).toBe('not-a-date'); + }); +}); diff --git a/src/features/appointments/slot.ts b/src/features/appointments/slot.ts new file mode 100644 index 0000000..c039f44 --- /dev/null +++ b/src/features/appointments/slot.ts @@ -0,0 +1,58 @@ +import type { PatientAppointment } from '../../types/models'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function slotStart(appointment: PatientAppointment): number { + return new Date(appointment.availabilitySlot.startTime).getTime(); +} + +/** Slot times come from Postgres as instants; a visit "on the day" is about + * the local calendar day the patient is living, not a UTC midnight. */ +function isSameLocalDay(a: number, b: number): boolean { + const da = new Date(a); + const db = new Date(b); + return ( + da.getFullYear() === db.getFullYear() && + da.getMonth() === db.getMonth() && + da.getDate() === db.getDate() + ); +} + +export function isOnSlotDay(appointment: PatientAppointment, now: Date): boolean { + const start = slotStart(appointment); + return !Number.isNaN(start) && isSameLocalDay(start, now.getTime()); +} + +/** Live updates only make sense for a visit that is happening today and has + * not already been settled — a past or cancelled appointment must not keep + * a poll running. */ +export function shouldPollQueue(appointment: PatientAppointment, now: Date): boolean { + return appointment.status === 'scheduled' && isOnSlotDay(appointment, now); +} + +/** The hub shows a visit while it is still ahead of you (or happening today); + * anything older drops out on its own without a server-side filter. */ +export function isUpcoming(appointment: PatientAppointment, now: Date): boolean { + const start = slotStart(appointment); + if (Number.isNaN(start)) return false; + return appointment.status === 'scheduled' && (start >= now.getTime() || isSameLocalDay(start, now.getTime())); +} + +function pad(value: number): string { + return value.toString().padStart(2, '0'); +} + +export function formatSlotTime(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +export function formatSlotDate(iso: string, now: Date = new Date()): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + if (isSameLocalDay(date.getTime(), now.getTime())) return 'today'; + const tomorrow = new Date(now.getTime() + DAY_MS); + if (isSameLocalDay(date.getTime(), tomorrow.getTime())) return 'tomorrow'; + return date.toLocaleDateString(); +} diff --git a/src/features/care-hub/components/CareCardList.module.css b/src/features/care-hub/components/CareCardList.module.css new file mode 100644 index 0000000..14f13a1 --- /dev/null +++ b/src/features/care-hub/components/CareCardList.module.css @@ -0,0 +1,57 @@ +.list { + display: flex; + flex-direction: column; + gap: var(--space-3); + list-style: none; + margin: 0; + padding: 0; +} + +.card { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-raised); + color: inherit; + text-decoration: none; +} + +.card:hover { + background: var(--color-surface-hover); +} + +.card:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +.head { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.title { + font-family: var(--font-display); + font-size: var(--text-base); + color: var(--color-text); +} + +.badge { + font-size: var(--text-xs); + font-weight: 600; + padding: 2px var(--space-2); + border-radius: var(--radius-full); + background: var(--color-warning-surface); + color: var(--color-warning); +} + +.detail { + font-size: var(--text-sm); + color: var(--color-text-muted); + line-height: var(--leading-normal); +} diff --git a/src/features/care-hub/components/CareCardList.tsx b/src/features/care-hub/components/CareCardList.tsx new file mode 100644 index 0000000..b287367 --- /dev/null +++ b/src/features/care-hub/components/CareCardList.tsx @@ -0,0 +1,25 @@ +import { Link } from 'react-router-dom'; +import type { CareCard } from '../ranking'; +import styles from './CareCardList.module.css'; + +interface Props { + cards: CareCard[]; +} + +export function CareCardList({ cards }: Props) { + return ( +
    + {cards.map((card) => ( +
  • + + + {card.title} + {card.badge && {card.badge}} + + {card.detail} + +
  • + ))} +
+ ); +} diff --git a/src/features/care-hub/hooks/useCareHub.ts b/src/features/care-hub/hooks/useCareHub.ts new file mode 100644 index 0000000..a295ebb --- /dev/null +++ b/src/features/care-hub/hooks/useCareHub.ts @@ -0,0 +1,146 @@ +import { useDependents } from '../../dependents/hooks/useDependents'; +import { useHealthEntries } from '../../health-profile/hooks/useHealthEntries'; +import { useMyAppointments } from '../../appointments/hooks/useAppointments'; +import { formatSlotDate, formatSlotTime, isOnSlotDay, isUpcoming } from '../../appointments/slot'; +import { useAuthStore } from '../../../store/auth-store'; +import { useProfileStore } from '../../../store/profile-store'; +import { PRIORITY, type CareCard } from '../ranking'; + +const RECENT_WINDOW_DAYS = 30; + +export interface SourceStatus { + isLoading: boolean; + failed: boolean; + /** The request cannot even be made yet (no session, no profile selected), + * which is not the same as loading and not the same as failed. */ + unavailable: boolean; +} + +export interface CareHub { + cards: CareCard[]; + sources: { dependents: SourceStatus; health: SourceStatus; appointments: SourceStatus }; +} + +/** A handover invite with no expiry sent by the backend is not the same thing + * as an expired one, so absence must not read as "blocked". */ +function inviteExpired(expiresAt?: string): boolean { + return Boolean(expiresAt && new Date(expiresAt) < new Date()); +} + +export function useCareHub(now: Date = new Date()): CareHub { + const accessToken = useAuthStore((state) => state.accessToken); + const profileId = useProfileStore((state) => state.selectedProfileId); + const dependents = useDependents(); + const appointments = useMyAppointments(); + const allergies = useHealthEntries('allergies'); + const conditions = useHealthEntries('conditions'); + const medications = useHealthEntries('medications'); + const healthQueries = [allergies, conditions, medications]; + + const dependentsUnavailable = !accessToken; + const appointmentsUnavailable = !accessToken; + const healthUnavailable = !accessToken || !profileId; + + const healthEntries = healthQueries.flatMap((query) => query.data ?? []); + const healthSettled = healthQueries.every((query) => !query.isPending); + const healthFailed = healthQueries.some((query) => query.isError); + + const cards: CareCard[] = []; + + for (const dependent of dependents.data ?? []) { + if (dependent.handoverStatus === 'invited' && inviteExpired(dependent.claimInviteExpiresAt)) { + cards.push({ + id: `handover-expired-${dependent.id}`, + section: 'action', + priority: PRIORITY.blockedNow, + title: `Resend ${dependent.fullName}'s invite`, + detail: 'Their handover invite expired before they claimed it, so they still cannot manage their own record.', + to: '/patient/dependents', + badge: 'Invite expired', + }); + } else if (dependent.handoverStatus === 'eligible') { + cards.push({ + id: `handover-eligible-${dependent.id}`, + section: 'action', + priority: PRIORITY.needsYou, + title: `Start ${dependent.fullName}'s handover`, + detail: 'They are approaching the age where they can manage their own health record.', + to: '/patient/dependents', + }); + } + } + + // A visit happening today is the most concrete thing a patient can act on + // right now, so it leads "Action needed"; later visits wait in "Coming up". + for (const appointment of appointments.data ?? []) { + if (!isUpcoming(appointment, now)) continue; + const startsAt = appointment.availabilitySlot.startTime; + const today = isOnSlotDay(appointment, now); + cards.push({ + id: `appointment-${appointment.id}`, + section: today ? 'action' : 'upcoming', + priority: today ? PRIORITY.needsYou : PRIORITY.goodToKnow, + title: today ? 'Check in for your visit today' : `Visit ${formatSlotDate(startsAt, now)} at ${formatSlotTime(startsAt)}`, + detail: today + ? `Your appointment is at ${formatSlotTime(startsAt)}. Open it to check in and see your place in the queue.` + : 'Check-in opens on the day of your visit.', + to: `/patient/appointments/${appointment.id}`, + badge: today ? 'Today' : undefined, + }); + } + + // Only claim the profile is empty once every section answered successfully — + // a failed request would otherwise be reported to the patient as missing care + // data they may well have already entered. + if (dependents.isSuccess && healthSettled && !healthFailed && healthEntries.length === 0) { + cards.push({ + id: 'health-profile-empty', + section: 'action', + priority: PRIORITY.goodToKnow, + title: 'Add your allergies, conditions and medications', + detail: 'Doctors see these at every consult. Yours are all still empty.', + to: '/patient/health', + }); + } + + for (const entry of healthEntries) { + const updated = new Date(entry.updatedAt); + if (Number.isNaN(updated.getTime())) continue; + if (updated.getTime() > now.getTime() - RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1000) { + cards.push({ + id: `health-updated-${entry.id}`, + section: 'recent', + priority: PRIORITY.recent, + title: `${entry.name} updated`, + detail: `Your ${entry.section} list changed on ${formatDate(entry.updatedAt)}.`, + to: '/patient/health', + }); + } + } + + return { + cards, + sources: { + dependents: { + isLoading: !dependentsUnavailable && dependents.isPending, + failed: !dependentsUnavailable && dependents.isError, + unavailable: dependentsUnavailable, + }, + appointments: { + isLoading: !appointmentsUnavailable && appointments.isPending, + failed: !appointmentsUnavailable && appointments.isError, + unavailable: appointmentsUnavailable, + }, + health: { + isLoading: !healthUnavailable && healthQueries.some((query) => query.isPending), + failed: !healthUnavailable && healthFailed, + unavailable: healthUnavailable, + }, + }, + }; +} + +function formatDate(iso: string): string { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? iso : date.toLocaleDateString(); +} diff --git a/src/features/care-hub/ranking.test.ts b/src/features/care-hub/ranking.test.ts new file mode 100644 index 0000000..b8037c0 --- /dev/null +++ b/src/features/care-hub/ranking.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { groupBySection, PRIORITY, rankCards, type CareCard } from './ranking'; + +function card(overrides: Partial): CareCard { + return { + id: overrides.id ?? 'card', + section: overrides.section ?? 'action', + priority: overrides.priority ?? PRIORITY.needsYou, + title: overrides.title ?? 'Card', + detail: 'detail', + to: '/patient', + ...overrides, + }; +} + +describe('rankCards', () => { + it('sorts by ascending priority so the most urgent action is first', () => { + const ranked = rankCards([ + card({ id: 'later', priority: PRIORITY.goodToKnow, title: 'Add allergies' }), + card({ id: 'urgent', priority: PRIORITY.blockedNow, title: 'Resend invite' }), + card({ id: 'middle', priority: PRIORITY.needsYou, title: 'Start handover' }), + ]); + + expect(ranked.map((c) => c.id)).toEqual(['urgent', 'middle', 'later']); + }); + + it('breaks priority ties alphabetically for a stable order', () => { + const ranked = rankCards([ + card({ id: 'zoe', title: 'Zoe', priority: PRIORITY.needsYou }), + card({ id: 'amy', title: 'Amy', priority: PRIORITY.needsYou }), + ]); + + expect(ranked.map((c) => c.title)).toEqual(['Amy', 'Zoe']); + }); + + it('does not mutate the input', () => { + const input = [card({ id: 'b', priority: 2 }), card({ id: 'a', priority: 1 })]; + + rankCards(input); + + expect(input.map((c) => c.id)).toEqual(['b', 'a']); + }); +}); + +describe('groupBySection', () => { + it('splits cards into the three hub sections, each ranked', () => { + const grouped = groupBySection([ + card({ id: 'r2', section: 'recent', priority: PRIORITY.recent, title: 'B' }), + card({ id: 'a1', section: 'action', priority: PRIORITY.needsYou, title: 'A' }), + card({ id: 'r1', section: 'recent', priority: PRIORITY.recent, title: 'A' }), + card({ id: 'u1', section: 'upcoming', priority: PRIORITY.needsYou, title: 'U' }), + ]); + + expect(grouped.action.map((c) => c.id)).toEqual(['a1']); + expect(grouped.upcoming.map((c) => c.id)).toEqual(['u1']); + expect(grouped.recent.map((c) => c.id)).toEqual(['r1', 'r2']); + }); + + it('returns empty arrays rather than omitting sections with no cards', () => { + const grouped = groupBySection([]); + + expect(grouped).toEqual({ action: [], upcoming: [], recent: [] }); + }); +}); diff --git a/src/features/care-hub/ranking.ts b/src/features/care-hub/ranking.ts new file mode 100644 index 0000000..aec350e --- /dev/null +++ b/src/features/care-hub/ranking.ts @@ -0,0 +1,33 @@ +export type CareSection = 'action' | 'upcoming' | 'recent'; + +export interface CareCard { + id: string; + section: CareSection; + /** Lower sorts first: the hub answers "what do I do next", so the most + * time-critical thing the patient can still act on ranks highest. */ + priority: number; + title: string; + detail: string; + to: string; + badge?: string; +} + +export const PRIORITY = { + blockedNow: 10, + needsYou: 20, + goodToKnow: 30, + recent: 40, +} as const; + +export function rankCards(cards: CareCard[]): CareCard[] { + return [...cards].sort((a, b) => (a.priority - b.priority) || a.title.localeCompare(b.title)); +} + +export function groupBySection(cards: CareCard[]): Record { + const ranked = rankCards(cards); + return { + action: ranked.filter((card) => card.section === 'action'), + upcoming: ranked.filter((card) => card.section === 'upcoming'), + recent: ranked.filter((card) => card.section === 'recent'), + }; +} diff --git a/src/features/payments/components/PaymentHistoryList.module.css b/src/features/payments/components/PaymentHistoryList.module.css new file mode 100644 index 0000000..ef143b4 --- /dev/null +++ b/src/features/payments/components/PaymentHistoryList.module.css @@ -0,0 +1,87 @@ +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* Two columns, two rows: amount left / status right, then the order label and + the date underneath, so nothing has to be squeezed onto one line. */ +.row { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + 'amount status' + 'meta date'; + gap: var(--space-1) var(--space-4); + align-items: baseline; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + background: var(--color-bg-raised); + color: var(--color-text); +} + +.row:hover { + text-decoration: none; + border-color: var(--color-border-strong); +} + +.amount { + grid-area: amount; + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 600; +} + +.meta { + grid-area: meta; + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.status { + grid-area: status; + justify-self: end; + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-accent); + background: var(--color-accent-subtle); + border-radius: var(--radius-full); + padding: 0.125rem 0.5rem; + white-space: nowrap; +} + +.date { + grid-area: date; + justify-self: end; + font-size: var(--text-xs); + color: var(--color-text-faint); +} + +.empty { + font-size: var(--text-sm); + color: var(--color-text-muted); + background: var(--color-surface-alt); + border-radius: var(--radius-sm); + padding: var(--space-4); + margin: 0; +} + +@media (max-width: 760px) { + .row { + grid-template-columns: 1fr; + grid-template-areas: + 'amount' + 'meta' + 'status' + 'date'; + } + + .status, + .date { + justify-self: start; + } +} diff --git a/src/features/payments/components/PaymentHistoryList.tsx b/src/features/payments/components/PaymentHistoryList.tsx new file mode 100644 index 0000000..fcd43c2 --- /dev/null +++ b/src/features/payments/components/PaymentHistoryList.tsx @@ -0,0 +1,39 @@ +import { Link } from 'react-router-dom'; +import { amountLabel } from '../stroops'; +import { formatDay, orderTypeLabel, statusLabel } from '../status'; +import type { PaymentHistoryEntry } from '../../../types/models'; +import styles from './PaymentHistoryList.module.css'; + +export function PaymentHistoryList({ entries }: { entries: PaymentHistoryEntry[] }) { + if (entries.length === 0) { + return ( +

+ You have not paid for anything through DocForum yet. Once a facility takes an order and you fund it, the + payment and its receipt appear here. +

+ ); + } + + return ( +
    + {entries.map((entry) => ( +
  • + + {amountLabel(entry.amountStroops)} + + {orderTypeLabel(entry.orderType)} + {/* The real API response carries only `facilityId`; the name is an + assumed extra, so absence shows the order type alone rather + than an internal id. */} + {entry.facilityName ? ` · ${entry.facilityName}` : ''} + + + {statusLabel(entry.status)} + + {formatDay(entry.createdAt)} + +
  • + ))} +
+ ); +} diff --git a/src/features/payments/components/PaymentReceipt.module.css b/src/features/payments/components/PaymentReceipt.module.css new file mode 100644 index 0000000..91688d7 --- /dev/null +++ b/src/features/payments/components/PaymentReceipt.module.css @@ -0,0 +1,99 @@ +.receipt { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-raised); + padding: var(--space-6); + margin-top: var(--space-6); +} + +.brand { + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-accent-2); + margin: 0; +} + +.heading { + font-family: var(--font-display); + font-size: var(--text-xl); + margin: var(--space-1) 0 var(--space-4); + color: var(--color-text); +} + +.fields { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-2) var(--space-6); + margin: 0; +} + +.fields dt { + font-size: var(--text-sm); + color: var(--color-text-faint); +} + +.fields dd { + font-size: var(--text-sm); + color: var(--color-text); + margin: 0; + overflow-wrap: anywhere; +} + +.amount { + font-weight: 600; +} + +.mono { + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +.verify { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin: var(--space-4) 0 0; +} + +.note { + font-size: var(--text-sm); + color: var(--color-text-muted); + border-top: 1px solid var(--color-border); + padding-top: var(--space-4); + margin: var(--space-4) 0 0; +} + +/* Below the shared breakpoint the value column starves, and the amount and + the transaction hash are the two lines a patient actually needs to read. */ +@media (max-width: 760px) { + .fields { + grid-template-columns: 1fr; + gap: var(--space-1); + } + + .fields dd { + margin-bottom: var(--space-3); + } +} + +/* A receipt is the one thing that should survive printing — everything else + on the page is marked no-print by the page itself. */ +@media print { + .receipt { + border: none; + border-radius: 0; + background: #fff; + padding: 0; + margin: 0; + color: #000; + } + + .fields dd, + .fields dt, + .verify, + .note, + .heading { + color: #000; + } +} diff --git a/src/features/payments/components/PaymentReceipt.tsx b/src/features/payments/components/PaymentReceipt.tsx new file mode 100644 index 0000000..0aef264 --- /dev/null +++ b/src/features/payments/components/PaymentReceipt.tsx @@ -0,0 +1,90 @@ +import { contractUrl, transactionUrl } from '../explorer'; +import { amountLabel } from '../stroops'; +import { formatDay, orderTypeLabel, refundDeadlineCopy, statusCopy, statusLabel } from '../status'; +import type { PaymentHistoryEntry } from '../../../types/models'; +import styles from './PaymentReceipt.module.css'; + +interface Props { + entry: PaymentHistoryEntry; + now: Date; +} + +/** Proof-of-payment view, deliberately printable: it carries the hashes and + * the explorer addresses in plain text, so a printed copy is still verifiable + * without a browser. `orderId` is not shown — it is an internal pointer, not + * something a patient can act on. */ +export function PaymentReceipt({ entry, now }: Props) { + const txLink = transactionUrl(entry.stellarTxHash); + const contractLink = contractUrl(entry.sorobanContractId); + const refund = refundDeadlineCopy(entry.refundDeadlineAt, now); + + return ( +
+

DocForum

+

Payment receipt

+ +
+
Paid on
+
{formatDay(entry.createdAt)}
+ +
For
+
{orderTypeLabel(entry.orderType)}
+ + {entry.facilityName ? ( + <> +
Facility
+
{entry.facilityName}
+ + ) : null} + +
Amount
+
{amountLabel(entry.amountStroops)}
+ +
Status
+
{statusLabel(entry.status)}
+ + {refund ? ( + <> +
Refund window
+
{refund}
+ + ) : null} + + {entry.stellarTxHash ? ( + <> +
Transaction hash
+
{entry.stellarTxHash}
+ + ) : null} + + {entry.sorobanContractId ? ( + <> +
Escrow contract
+
{entry.sorobanContractId}
+ + ) : null} +
+ + {txLink || contractLink ? ( +

+ Verify on the explorer:{' '} + {txLink ? ( + + transaction + + ) : null} + {txLink && contractLink ? ' · ' : null} + {contractLink ? ( + + escrow contract + + ) : null} +

+ ) : ( +

No on-chain transaction yet — nothing to verify until this payment is funded.

+ )} + +

{statusCopy(entry.status)}

+
+ ); +} diff --git a/src/features/payments/explorer.test.ts b/src/features/payments/explorer.test.ts new file mode 100644 index 0000000..08f241a --- /dev/null +++ b/src/features/payments/explorer.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { contractUrl, transactionUrl } from './explorer'; + +describe('explorer links', () => { + it('links a hash core returned', () => { + expect(transactionUrl('5c2f4bHash')).toBe( + 'https://testnet.stellar.expert/explorer/public/transaction/5c2f4bHash', + ); + expect(contractUrl('CONTRACTID')).toBe('https://testnet.stellar.expert/explorer/public/contract/CONTRACTID'); + }); + + // A payment that has not been funded has no hash yet — linking to + // `/transaction/null` would look like a real, broken transaction. + it('produces no link for a null hash', () => { + expect(transactionUrl(null)).toBeNull(); + expect(transactionUrl(undefined)).toBeNull(); + expect(contractUrl(null)).toBeNull(); + }); +}); diff --git a/src/features/payments/explorer.ts b/src/features/payments/explorer.ts new file mode 100644 index 0000000..0b08106 --- /dev/null +++ b/src/features/payments/explorer.ts @@ -0,0 +1,15 @@ +// Stellar testnet explorer links — the single place the explorer base URL +// lives, so mainnet later is a one-line change (or a build-time env). This +// is a display-only helper: nothing here queries Stellar (hard rule 1); it +// only turns a hash/contract id core already returned into a link. + +const EXPLORER_BASE_URL = import.meta.env.VITE_STELLAR_EXPLORER_BASE_URL ?? 'https://testnet.stellar.expert/explorer/public'; + +/** A hash core hasn't produced yet (null before funding) gets no link. */ +export function transactionUrl(txHash: string | null | undefined): string | null { + return txHash ? `${EXPLORER_BASE_URL}/transaction/${encodeURIComponent(txHash)}` : null; +} + +export function contractUrl(contractId: string | null | undefined): string | null { + return contractId ? `${EXPLORER_BASE_URL}/contract/${encodeURIComponent(contractId)}` : null; +} diff --git a/src/features/payments/hooks/usePaymentHistory.ts b/src/features/payments/hooks/usePaymentHistory.ts new file mode 100644 index 0000000..76b32a7 --- /dev/null +++ b/src/features/payments/hooks/usePaymentHistory.ts @@ -0,0 +1,25 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import * as paymentHistoryService from '../../../services/payment-history-service'; +import { useAuthStore } from '../../../store/auth-store'; + +export function usePaymentHistory() { + const accessToken = useAuthStore((state) => state.accessToken); + + return useInfiniteQuery({ + queryKey: ['payments', 'mine'], + queryFn: ({ pageParam }) => paymentHistoryService.listPaymentHistory(pageParam, accessToken!), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + enabled: Boolean(accessToken), + }); +} + +export function usePaymentHistoryEntry(id: string | undefined) { + const accessToken = useAuthStore((state) => state.accessToken); + + return useQuery({ + queryKey: ['payments', 'mine', id ?? ''], + queryFn: () => paymentHistoryService.getPaymentHistoryEntry(id!, accessToken!), + enabled: Boolean(accessToken && id), + }); +} diff --git a/src/features/payments/status.test.ts b/src/features/payments/status.test.ts new file mode 100644 index 0000000..face355 --- /dev/null +++ b/src/features/payments/status.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError } from '../../services/api-client'; +import { + historyErrorCopy, + orderTypeLabel, + refundDeadlineCopy, + statusCopy, + statusLabel, + STATUS_COPY, +} from './status'; +import type { PaymentIntentStatus } from '../../types/models'; + +const NOW = new Date('2026-10-16T12:00:00Z'); + +describe('payment status copy', () => { + // The review criterion for #37 is that each real status is explained, not + // printed as the contract's token. + it('explains every status the contract has', () => { + const statuses: PaymentIntentStatus[] = ['created', 'escrowed', 'released', 'refunded', 'failed']; + expect(statuses).toEqual(Object.keys(STATUS_COPY)); + for (const status of statuses) { + expect(statusCopy(status)).not.toBe(status); + expect(statusCopy(status).length).toBeGreaterThan(20); + expect(statusLabel(status)).not.toBe(status); + } + }); + + it('says where the money actually is for each state', () => { + expect(statusCopy('created')).toMatch(/nothing has left you/i); + expect(statusCopy('escrowed')).toMatch(/holding this money in escrow/i); + expect(statusCopy('released')).toMatch(/released to the facility/i); + expect(statusCopy('refunded')).toMatch(/came back out of escrow/i); + expect(statusCopy('failed')).toMatch(/nothing was moved/i); + }); + + it('does not crash on a status added server-side first', () => { + expect(statusCopy('pending_signature' as PaymentIntentStatus)).toMatch(/cannot explain/i); + expect(statusLabel('pending_signature' as PaymentIntentStatus)).toBe('Unknown status'); + }); + + it('names the two order types in words', () => { + expect(orderTypeLabel('prescription')).toBe('Prescription'); + expect(orderTypeLabel('lab_order')).toBe('Lab order'); + }); +}); + +describe('refundDeadlineCopy', () => { + it('says nothing when the backend set no deadline', () => { + expect(refundDeadlineCopy(null, NOW)).toBeNull(); + expect(refundDeadlineCopy(undefined, NOW)).toBeNull(); + }); + + it('opens the window in the present tense before it closes', () => { + expect(refundDeadlineCopy('2026-10-20T12:00:00Z', NOW)).toMatch(/can still be refunded until/i); + }); + + it('switches to closed once it has passed', () => { + expect(refundDeadlineCopy('2026-10-10T12:00:00Z', NOW)).toMatch(/refund window closed on/i); + }); + + it('ignores an unparseable deadline instead of printing NaN', () => { + expect(refundDeadlineCopy('soon', NOW)).toBeNull(); + }); +}); + +describe('historyErrorCopy', () => { + // core#90 has not landed, so a 404 is the ordinary case today and must read + // as "not available yet", never as "you have paid nothing". + it('describes a missing endpoint as unavailable, not empty', () => { + expect(historyErrorCopy(new ApiError('Not Found', 404))).toMatch(/not available for this deployment yet/i); + }); + + it('passes a real API message through', () => { + expect(historyErrorCopy(new ApiError('Payment history is temporarily unavailable.', 503))).toBe( + 'Payment history is temporarily unavailable.', + ); + }); + + it('has a fallback for a network failure', () => { + expect(historyErrorCopy(new Error('offline'))).toMatch(/could not be loaded/i); + }); +}); diff --git a/src/features/payments/status.ts b/src/features/payments/status.ts new file mode 100644 index 0000000..9f72faa --- /dev/null +++ b/src/features/payments/status.ts @@ -0,0 +1,61 @@ +import { ApiError } from '../../services/api-client'; +import type { OrderTypeForPayment, PaymentIntentStatus } from '../../types/models'; + +export const STATUS_LABEL: Record = { + created: 'Not funded', + escrowed: 'Held in escrow', + released: 'Released', + refunded: 'Refunded', + failed: 'Failed', +}; + +/** Sentences for the person who paid, not the enum token the contract uses. + * Unknown values fall back rather than rendering a bare word — and never a + * `undefined`. */ +export const STATUS_COPY: Record = { + created: 'This payment was set up but never funded. Nothing has left you and nothing is on-chain.', + escrowed: 'DocForum is holding this money in escrow. The facility has not been paid yet — that is the protection escrow exists for.', + released: 'The money was released to the facility. This payment is complete.', + refunded: 'The money came back out of escrow and is no longer held for the facility.', + failed: 'This payment failed. Nothing was moved on-chain.', +}; + +export function statusLabel(status: PaymentIntentStatus): string { + return STATUS_LABEL[status] ?? 'Unknown status'; +} + +export function statusCopy(status: PaymentIntentStatus): string { + return STATUS_COPY[status] ?? 'We cannot explain this status yet.'; +} + +export function orderTypeLabel(orderType: OrderTypeForPayment): string { + return orderType === 'prescription' ? 'Prescription' : 'Lab order'; +} + +export function formatDay(iso: string): string { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? iso : date.toLocaleDateString(); +} + +/** An empty payment list and a list that could not be fetched are different + * statements — the first is about the patient's money, the second about our + * request. core#90 has not landed, so a 404 here is the normal case. */ +export function historyErrorCopy(error: unknown): string { + if (error instanceof ApiError && error.status === 404) { + return 'Your payment history is not available for this deployment yet.'; + } + if (error instanceof ApiError) return error.message; + return 'Your payment history could not be loaded.'; +} + +/** `refundDeadlineAt` is assumed (docforum-core#90) — the real row has no such + * column, so absence is normal and renders nothing rather than a guess. */ +export function refundDeadlineCopy(deadline: string | null | undefined, now: Date): string | null { + if (!deadline) return null; + const date = new Date(deadline); + if (Number.isNaN(date.getTime())) return null; + const day = date.toLocaleDateString(); + return date.getTime() < now.getTime() + ? `The refund window closed on ${day}.` + : `This payment can still be refunded until ${day}.`; +} diff --git a/src/features/payments/stroops.test.ts b/src/features/payments/stroops.test.ts new file mode 100644 index 0000000..b55d525 --- /dev/null +++ b/src/features/payments/stroops.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { amountLabel, formatStroopsAsLumens } from './stroops'; + +describe('formatStroopsAsLumens', () => { + it('splits stroops into XLM without a decimal point when they divide evenly', () => { + expect(formatStroopsAsLumens('10000000')).toBe('1'); + expect(formatStroopsAsLumens('0')).toBe('0'); + }); + + it('keeps every decimal place and drops only trailing zeros', () => { + expect(formatStroopsAsLumens('1234567890123')).toBe('123,456.7890123'); + expect(formatStroopsAsLumens('15000000')).toBe('1.5'); + expect(formatStroopsAsLumens('10000001')).toBe('1.0000001'); + }); + + // The review criterion for #37: a JS number would round this, and the + // patient's receipt would be quietly wrong. + it('stays exact above 2^53 stroops', () => { + expect(formatStroopsAsLumens('9007199254740993')).toBe('900,719,925.4740993'); + expect(formatStroopsAsLumens('1000000000000000000000')).toBe('100,000,000,000,000'); + }); + + it('refuses to invent a number for anything that is not a plain integer', () => { + expect(formatStroopsAsLumens('12.5')).toBeNull(); + expect(formatStroopsAsLumens('-1')).toBeNull(); + expect(formatStroopsAsLumens('1e9')).toBeNull(); + expect(formatStroopsAsLumens('')).toBeNull(); + }); +}); + +describe('amountLabel', () => { + it('names the unit it printed', () => { + expect(amountLabel('200000000')).toBe('20 XLM'); + }); + + it('falls back to the raw stroops rather than a wrong XLM figure', () => { + expect(amountLabel('not-a-number')).toBe('not-a-number stroops'); + }); +}); diff --git a/src/features/payments/stroops.ts b/src/features/payments/stroops.ts new file mode 100644 index 0000000..38c4b7e --- /dev/null +++ b/src/features/payments/stroops.ts @@ -0,0 +1,34 @@ +// Stroops -> XLM display, in BigInt only. +// +// docforum-core sends `amountStroops` as a string because the column is a +// BigInt (see its payments/controllers serializeIntent). Going through a JS +// `number` would silently round anything over 2^53 stroops, so every step +// here stays in BigInt and string manipulation. +const STROOPS_PER_LUMEN = 10_000_000n; +const DECIMAL_PLACES = 7; + +/** `1234.56789` — no trailing zeros, thousands separators, never a float. + * Returns null for anything that isn't a plain non-negative integer string, + * so callers show the raw value rather than a made-up number. */ +export function formatStroopsAsLumens(stroops: string): string | null { + if (!/^\d+$/.test(stroops)) return null; + + const whole = BigInt(stroops) / STROOPS_PER_LUMEN; + const fraction = BigInt(stroops) % STROOPS_PER_LUMEN; + const wholePart = groupThousands(whole.toString()); + + if (fraction === 0n) return wholePart; + const decimals = fraction.toString().padStart(DECIMAL_PLACES, '0').replace(/0+$/, ''); + return `${wholePart}.${decimals}`; +} + +function groupThousands(digits: string): string { + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +/** Every amount shown to a patient carries its unit, so a value this helper + * cannot parse is still readable instead of silently wrong. */ +export function amountLabel(stroops: string): string { + const lumens = formatStroopsAsLumens(stroops); + return lumens === null ? `${stroops} stroops` : `${lumens} XLM`; +} diff --git a/src/hooks/useBrowserNotification.test.ts b/src/hooks/useBrowserNotification.test.ts new file mode 100644 index 0000000..b3ba2e3 --- /dev/null +++ b/src/hooks/useBrowserNotification.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useBrowserNotification } from './useBrowserNotification'; + +/** Stands in for the browser's Notification API, counting how many times a + * prompt would actually have reached the user. */ +class TestNotification { + static permission: NotificationPermission = 'default'; + /** What the browser answers the next time it is asked. */ + static answer: NotificationPermission = 'granted'; + static promptCount = 0; + static sent: TestNotification[] = []; + + constructor( + public readonly title: string, + public readonly options?: NotificationOptions, + ) { + TestNotification.sent.push(this); + } + + static requestPermission(): Promise { + TestNotification.promptCount += 1; + TestNotification.permission = TestNotification.answer; + return Promise.resolve(TestNotification.answer); + } +} + +describe('useBrowserNotification', () => { + beforeEach(() => { + TestNotification.permission = 'default'; + TestNotification.answer = 'granted'; + TestNotification.promptCount = 0; + TestNotification.sent = []; + vi.stubGlobal('Notification', TestNotification); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('never asks just because the queue rendered', () => { + const { result } = renderHook(() => useBrowserNotification()); + + expect(result.current.permission).toBe('default'); + expect(TestNotification.promptCount).toBe(0); + }); + + it('asks once when the patient opts in, and remembers the answer', async () => { + const { result } = renderHook(() => useBrowserNotification()); + + // The permission answer arrives as a promise, and the hook stores it in + // state — act() is what makes that update land before the assertions. + await act(async () => { + await result.current.requestPermission(); + }); + + expect(TestNotification.promptCount).toBe(1); + expect(result.current.canNotify).toBe(true); + expect(result.current.canRequest).toBe(false); + }); + + it('does not ask again when the browser already answered', async () => { + TestNotification.permission = 'granted'; + const { result } = renderHook(() => useBrowserNotification()); + + await act(async () => { + await result.current.requestPermission(); + }); + + expect(TestNotification.promptCount).toBe(0); + expect(result.current.canNotify).toBe(true); + }); + + // The constraint in #32: a refusal must not be followed by more prompts. + it('does not ask a second time after the patient said no', async () => { + TestNotification.answer = 'denied'; + const { result } = renderHook(() => useBrowserNotification()); + + await act(async () => { + await result.current.requestPermission(); + }); + await act(async () => { + await result.current.requestPermission(); + }); + await act(async () => { + await result.current.requestPermission(); + }); + + expect(TestNotification.promptCount).toBe(1); + expect(result.current.canRequest).toBe(false); + expect(result.current.canNotify).toBe(false); + }); + + it('sends nothing while permission is not granted', () => { + const { result } = renderHook(() => useBrowserNotification()); + + result.current.notify('Only 2 ahead of you.'); + + expect(TestNotification.sent).toHaveLength(0); + }); + + it('sends the body it was given once granted', () => { + TestNotification.permission = 'granted'; + const { result } = renderHook(() => useBrowserNotification()); + + result.current.notify('It is your turn now.'); + + expect(TestNotification.sent[0].title).toBe('DocForum'); + expect(TestNotification.sent[0].options?.body).toBe('It is your turn now.'); + }); + + it('reports an unsupported browser without offering an opt-in that cannot work', () => { + vi.stubGlobal('Notification', undefined); + const { result } = renderHook(() => useBrowserNotification()); + + expect(result.current.permission).toBe('unsupported'); + expect(result.current.canRequest).toBe(false); + expect(result.current.canNotify).toBe(false); + }); +}); diff --git a/src/hooks/useBrowserNotification.ts b/src/hooks/useBrowserNotification.ts new file mode 100644 index 0000000..d1768b7 --- /dev/null +++ b/src/hooks/useBrowserNotification.ts @@ -0,0 +1,52 @@ +import { useCallback, useState } from 'react'; + +type Permission = NotificationPermission | 'unsupported'; + +/** Some browsers (and this repo's test environment) have no Notification API + * at all; a few expose the global as undefined, which is just as unusable. */ +function supported(): boolean { + return typeof window !== 'undefined' && typeof Notification !== 'undefined' && Notification !== null; +} + +function currentPermission(): Permission { + return supported() ? Notification.permission : 'unsupported'; +} + +/** + * Browser notifications, asked for only when the patient clicks something. + * + * `Notification.requestPermission()` resolves immediately from a cached + * decision, so re-asking after a denial would fire a prompt the patient + * already refused — that is the nagging this hook exists to prevent. A denied + * or unsupported permission is therefore never requested again. + */ +export function useBrowserNotification() { + const [permission, setPermission] = useState(currentPermission); + + const requestPermission = useCallback(async () => { + if (!supported()) return; + if (Notification.permission === 'denied') return; + if (Notification.permission !== 'default') { + setPermission(Notification.permission); + return; + } + // jsdom and some older browsers return a value instead of a promise. + const result = await Promise.resolve(Notification.requestPermission()); + setPermission(result); + }, []); + + const notify = useCallback((body: string) => { + if (!supported() || Notification.permission !== 'granted') return; + new Notification('DocForum', { body }); + }, []); + + return { + permission, + canNotify: permission === 'granted', + /** False once the browser has said no (or has no Notification API at all), + * so the UI stops offering an opt-in that cannot be honoured. */ + canRequest: permission === 'default', + requestPermission, + notify, + }; +} diff --git a/src/pages/AccessLogPage.module.css b/src/pages/AccessLogPage.module.css new file mode 100644 index 0000000..cd11048 --- /dev/null +++ b/src/pages/AccessLogPage.module.css @@ -0,0 +1,25 @@ +.title { + text-wrap: balance; +} + +.intro { + margin-top: 0; +} + +.loading { + font-size: var(--text-sm); + color: var(--color-text-faint); + margin: var(--space-3) 0; +} + +.older { + margin-top: var(--space-4); +} + +.concern { + font-size: var(--text-sm); + color: var(--color-text-muted); + border-top: 1px solid var(--color-border); + padding-top: var(--space-4); + margin: var(--space-6) 0 0; +} diff --git a/src/pages/AccessLogPage.test.tsx b/src/pages/AccessLogPage.test.tsx new file mode 100644 index 0000000..e4732b2 --- /dev/null +++ b/src/pages/AccessLogPage.test.tsx @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, waitFor } from '../test/render'; +import { AccessLogPage } from './AccessLogPage'; +import * as accessLogService from '../services/access-log-service'; +import { ApiError } from '../services/api-client'; +import { useAuthStore } from '../store/auth-store'; +import type { AccessLogEvent, AccessLogPage as AccessLogPageData } from '../types/models'; + +vi.mock('../services/access-log-service'); + +function event(overrides: Partial = {}): AccessLogEvent { + return { + id: 'ev-1', + occurredAt: '2026-10-14T09:12:00Z', + actorRole: 'doctor', + actorName: 'Dr Amara Bello', + recordType: 'health_profile', + reason: 'consultation', + ...overrides, + }; +} + +function page(events: AccessLogEvent[], nextCursor: string | null): AccessLogPageData { + return { events, nextCursor }; +} + +describe('AccessLogPage', () => { + beforeEach(() => { + useAuthStore.setState({ accessToken: 'test-token', user: null }); + vi.mocked(accessLogService.listAccessLog).mockReset(); + }); + + it('lists who opened the record, in words a patient can read', async () => { + vi.mocked(accessLogService.listAccessLog).mockResolvedValue( + page([event(), event({ id: 'ev-2', actorRole: 'facility', actorName: 'Bello Lab', recordType: 'order', reason: 'order_fulfillment' })], null), + ); + + const { container } = render(); + + expect(await screen.findByText('Dr Amara Bello')).toBeInTheDocument(); + expect(screen.getByText(/opened your health profile/i)).toBeInTheDocument(); + expect(screen.getByText(/treating you at a visit/i)).toBeInTheDocument(); + expect(screen.getByText('Bello Lab')).toBeInTheDocument(); + expect(screen.getByText(/carry out an order for you/i)).toBeInTheDocument(); + + // Edge case from the issue: a privacy screen that prints internal ids is + // still a leak, so none of them may reach the DOM. + expect(container.textContent).not.toMatch(/ev-1|pp-1|patientProfileId|health_profile_/); + }); + + it('loads the next cursor only when asked, and keeps what it already showed', async () => { + vi.mocked(accessLogService.listAccessLog) + .mockResolvedValueOnce(page([event()], 'cursor-2')) + .mockResolvedValueOnce(page([event({ id: 'ev-9', actorName: 'Nurse Sam' })], null)); + + render(); + + await screen.findByText('Dr Amara Bello'); + expect(screen.queryByText('Nurse Sam')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /show older entries/i })); + + await waitFor(() => expect(screen.getByText('Nurse Sam')).toBeInTheDocument()); + expect(screen.getByText('Dr Amara Bello')).toBeInTheDocument(); + expect(accessLogService.listAccessLog).toHaveBeenLastCalledWith( + expect.objectContaining({ cursor: 'cursor-2' }), + 'test-token', + ); + expect(screen.queryByRole('button', { name: /show older entries/i })).not.toBeInTheDocument(); + }); + + it('sends the date range and type it was given', async () => { + vi.mocked(accessLogService.listAccessLog).mockResolvedValue(page([], null)); + + render(); + await waitFor(() => expect(accessLogService.listAccessLog).toHaveBeenCalled()); + + // Date inputs are set directly — userEvent types them in the browser's + // locale format, which is not what this repo's tests should depend on. + fireEvent.change(screen.getByLabelText('From'), { target: { value: '2026-10-01' } }); + fireEvent.change(screen.getByLabelText('To'), { target: { value: '2026-10-15' } }); + await userEvent.selectOptions(screen.getByLabelText('Type'), 'referral'); + await userEvent.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => + expect(accessLogService.listAccessLog).toHaveBeenLastCalledWith( + expect.objectContaining({ from: '2026-10-01', to: '2026-10-15', recordType: 'referral' }), + 'test-token', + ), + ); + }); + + it('corrects a reversed range instead of fetching nothing and calling it empty', async () => { + vi.mocked(accessLogService.listAccessLog).mockResolvedValue(page([], null)); + + render(); + await waitFor(() => expect(accessLogService.listAccessLog).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByLabelText('From'), { target: { value: '2026-10-15' } }); + fireEvent.change(screen.getByLabelText('To'), { target: { value: '2026-10-01' } }); + await userEvent.click(screen.getByRole('button', { name: /apply/i })); + + expect(await screen.findByText(/first date must be before the second/i)).toBeInTheDocument(); + expect(accessLogService.listAccessLog).toHaveBeenCalledTimes(1); + }); + + // core#45 has not landed, so the ordinary answer today is 404. "No one has + // opened your record" would be a false privacy claim. + it('says the log is unavailable rather than empty when the endpoint is missing', async () => { + vi.mocked(accessLogService.listAccessLog).mockRejectedValue(new ApiError('Not Found', 404)); + + render(); + + expect(await screen.findByText(/access log is not available yet/i)).toBeInTheDocument(); + expect(screen.queryByText(/no one has opened your record/i)).not.toBeInTheDocument(); + }); + + it('keeps the entries it has and flags a later page that failed', async () => { + vi.mocked(accessLogService.listAccessLog) + .mockResolvedValueOnce(page([event()], 'cursor-2')) + .mockRejectedValueOnce(new ApiError('Gateway Timeout', 504)); + + render(); + + await screen.findByText('Dr Amara Bello'); + await userEvent.click(screen.getByRole('button', { name: /show older entries/i })); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + expect(screen.getByText('Dr Amara Bello')).toBeInTheDocument(); + }); + + it('describes an empty log for a new account', async () => { + vi.mocked(accessLogService.listAccessLog).mockResolvedValue(page([], null)); + + render(); + + expect(await screen.findByText(/no one has opened your record yet/i)).toBeInTheDocument(); + }); + + it('will not invent a support link that no one reads', async () => { + vi.stubEnv('VITE_SUPPORT_EMAIL', ''); + vi.mocked(accessLogService.listAccessLog).mockResolvedValue(page([], null)); + + render(); + + expect(await screen.findByText(/will not invent a link/i)).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /report a concern/i })).not.toBeInTheDocument(); + vi.unstubAllEnvs(); + }); + + it('links the concern report to the configured support address', async () => { + vi.stubEnv('VITE_SUPPORT_EMAIL', 'privacy@docforum.test'); + vi.mocked(accessLogService.listAccessLog).mockResolvedValue(page([], null)); + + render(); + + const link = await screen.findByRole('link', { name: /report a concern/i }); + expect(link).toHaveAttribute('href', expect.stringMatching(/^mailto:privacy@docforum\.test\?subject=/)); + vi.unstubAllEnvs(); + }); +}); diff --git a/src/pages/AccessLogPage.tsx b/src/pages/AccessLogPage.tsx new file mode 100644 index 0000000..b7cdaf9 --- /dev/null +++ b/src/pages/AccessLogPage.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import type { ReactNode } from 'react'; +import { Button } from '../components/Button'; +import { AccessLogError } from '../features/account/components/AccessLogError'; +import { AccessLogFiltersForm } from '../features/account/components/AccessLogFilters'; +import { AccessLogList } from '../features/account/components/AccessLogList'; +import { reportConcernHref } from '../features/account/copy'; +import { useAccessLog } from '../features/account/hooks/useAccessLog'; +import type { AccessLogFilters } from '../services/access-log-service'; +import styles from './AccessLogPage.module.css'; + +export function AccessLogPage() { + const [filters, setFilters] = useState({}); + const logQuery = useAccessLog(filters); + + const events = logQuery.data?.pages.flatMap((page) => page.events) ?? []; + const concernHref = reportConcernHref(); + + // An empty log is a claim about the patient's privacy, so it is only ever + // rendered from a response that actually arrived. A failed request says so + // instead — including when docforum-core#45 has not landed and the route + // answers 404. + let body: ReactNode; + if (events.length > 0) body = ; + else if (logQuery.isError) body = null; + else if (logQuery.isFetching) body =

Loading your access log…

; + else body = ; + + return ( +
+
+ Privacy +

Who viewed my records

+

+ DocForum writes down every time a clinician or facility opens your record. This page is that log — only + what it actually contains, with the reason for each access. +

+ + + + {logQuery.isError ? : null} + {body} + + {logQuery.hasNextPage ? ( + + ) : null} + +

+ {concernHref ? ( + <> + Seen something that does not look right?{' '} + + Report a concern + {' '} + and DocForum will investigate. + + ) : ( + <> + Seen something that does not look right? Contact DocForum support to report a concern — no support + channel is configured for this deployment yet, so this page will not invent a link. + + )} +

+
+
+ ); +} diff --git a/src/pages/AppointmentDetailPage.module.css b/src/pages/AppointmentDetailPage.module.css new file mode 100644 index 0000000..60f0032 --- /dev/null +++ b/src/pages/AppointmentDetailPage.module.css @@ -0,0 +1,41 @@ +.title { + text-wrap: balance; +} + +.when { + font-size: var(--text-base); + color: var(--color-text); + margin: 0 0 var(--space-2); +} + +.until { + font-size: var(--text-sm); + color: var(--color-text-faint); +} + +.detail { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin: 0; +} + +.loading { + font-size: var(--text-sm); + color: var(--color-text-faint); + margin: var(--space-3) 0 0; +} + +.notice { + font-size: var(--text-sm); + color: var(--color-text-muted); + background: var(--color-surface-alt); + border-radius: var(--radius-sm); + padding: var(--space-3); + margin: var(--space-4) 0 0; + max-width: none; +} + +.back { + margin: var(--space-6) 0 0; + font-size: var(--text-sm); +} diff --git a/src/pages/AppointmentDetailPage.test.tsx b/src/pages/AppointmentDetailPage.test.tsx new file mode 100644 index 0000000..01a711c --- /dev/null +++ b/src/pages/AppointmentDetailPage.test.tsx @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render as rtlRender, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { AppointmentDetailPage } from './AppointmentDetailPage'; +import * as appointmentsService from '../services/appointments-service'; +import { ApiError } from '../services/api-client'; +import { useAuthStore } from '../store/auth-store'; +import type { PatientAppointment, AppointmentQueue } from '../types/models'; + +vi.mock('../services/appointments-service'); + +// The queue hook owns a poll timer, so the page test drives it directly: what +// is being proved here is which *page states* mount a live queue at all. +const queueHook = vi.hoisted(() => vi.fn()); +vi.mock('../features/appointments/hooks/useAppointmentQueue', () => ({ + QUEUE_POLL_INTERVAL_MS: 15_000, + useAppointmentQueue: queueHook, + useCheckIn: () => ({ mutate: vi.fn(), isPending: false, error: null }), + useQueueNotification: () => ({ + permission: 'unsupported', + canNotify: false, + canRequest: false, + requestPermission: vi.fn(), + notify: vi.fn(), + }), +})); + +const NOW = new Date('2026-10-16T12:00:00Z'); + +function appointment(overrides: Partial = {}): PatientAppointment { + const slotStart = new Date(NOW.getTime() + 2 * 60 * 60 * 1000); + const slotEnd = new Date(slotStart.getTime() + 30 * 60 * 1000); + return { + id: 'apt-1', + patientProfileId: 'pp-1', + doctorProfileId: 'dp-1', + availabilitySlotId: 'slot-1', + status: 'scheduled', + origin: 'direct_booking', + createdAt: '2026-10-13T09:00:00Z', + updatedAt: '2026-10-13T09:00:00Z', + availabilitySlot: { + id: 'slot-1', + doctorProfileId: 'dp-1', + status: 'booked', + startTime: slotStart.toISOString(), + endTime: slotEnd.toISOString(), + createdAt: '2026-10-01T09:00:00Z', + updatedAt: '2026-10-01T09:00:00Z', + }, + ...overrides, + }; +} + +const QUEUE: AppointmentQueue = { + appointmentId: 'apt-1', + phase: 'waiting', + peopleAhead: 2, + estimatedWaitMinutes: 9, + checkInOpensAt: null, + checkedInAt: '2026-10-16T11:40:00Z', +}; + +function renderAt(path: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return rtlRender( + + + + } /> + + + , + ); +} + +describe('AppointmentDetailPage', () => { + beforeEach(() => { + useAuthStore.setState({ accessToken: 'test-token', user: null }); + vi.mocked(appointmentsService.listMyAppointments).mockReset(); + // The page reads the wall clock for "is this visit today", so the clock is + // frozen to NOW. Only Date is faked — waitFor still runs on real timers. + vi.useFakeTimers({ now: NOW.getTime(), toFake: ['Date'] }); + queueHook.mockReset(); + queueHook.mockReturnValue({ + data: QUEUE, + error: null, + isError: false, + isFetching: false, + dataUpdatedAt: NOW.getTime(), + } as never); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('names the day and the time of the visit', async () => { + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([appointment()]); + + renderAt('/patient/appointments/apt-1'); + + expect(await screen.findByText(/today at \d{2}:\d{2}/i)).toBeInTheDocument(); + expect(screen.getByText(/scheduled until/i)).toBeInTheDocument(); + }); + + it('shows the live queue on the day of the visit', async () => { + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([appointment()]); + + renderAt('/patient/appointments/apt-1'); + + expect(await screen.findByText(/2 people ahead of you/i)).toBeInTheDocument(); + expect(screen.getByText(/about 9 min \(estimate\)/i)).toBeInTheDocument(); + }); + + it.each(['checked_in', 'in_progress'] as const)( + 'keeps the live queue visible for a visit that is %s', + async (status) => { + // The doctor-side flow added these two statuses to AppointmentStatus. A + // patient already in the queue must not be told there is "nothing to + // check in to". + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([appointment({ status })]); + + renderAt('/patient/appointments/apt-1'); + + expect(await screen.findByText(/2 people ahead of you/i)).toBeInTheDocument(); + expect(screen.queryByText(/nothing to check in to/i)).not.toBeInTheDocument(); + }, + ); + + it('does not start reading the queue before the day of the visit', async () => { + const later = appointment(); + later.availabilitySlot.startTime = new Date(NOW.getTime() + 26 * 60 * 60 * 1000).toISOString(); + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([later]); + + renderAt('/patient/appointments/apt-1'); + + expect(await screen.findByText(/check-in opens on the day of your visit/i)).toBeInTheDocument(); + expect(queueHook).not.toHaveBeenCalled(); + }); + + it('says a cancelled visit has nothing to check in to', async () => { + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([appointment({ status: 'cancelled' })]); + + renderAt('/patient/appointments/apt-1'); + + expect(await screen.findByText(/was cancelled, so there is nothing to check in to/i)).toBeInTheDocument(); + expect(queueHook).not.toHaveBeenCalled(); + }); + + it('explains an appointment id that is not on this account', async () => { + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([appointment()]); + + renderAt('/patient/appointments/apt-999'); + + expect(await screen.findByText(/cannot find that appointment/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /back to your care summary/i })).toHaveAttribute( + 'href', + '/patient', + ); + }); + + it('surfaces a failed appointments request', async () => { + vi.mocked(appointmentsService.listMyAppointments).mockRejectedValue(new ApiError('PatientAppointment service is down.', 503)); + + renderAt('/patient/appointments/apt-1'); + + expect(await screen.findByText(/appointment service is down/i)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/AppointmentDetailPage.tsx b/src/pages/AppointmentDetailPage.tsx new file mode 100644 index 0000000..d3f54d9 --- /dev/null +++ b/src/pages/AppointmentDetailPage.tsx @@ -0,0 +1,122 @@ +import { Link, useParams } from 'react-router-dom'; +import { FormError } from '../components/FormError'; +import { QueuePanel } from '../features/appointments/components/QueuePanel'; +import { useMyAppointments } from '../features/appointments/hooks/useAppointments'; +import { formatSlotDate, formatSlotTime, isOnSlotDay } from '../features/appointments/slot'; +import { ApiError } from '../services/api-client'; +import type { AppointmentStatus } from '../types/models'; +import styles from './AppointmentDetailPage.module.css'; + +const STATUS_COPY: Record = { + scheduled: 'Booked', + checked_in: 'Checked in', + in_progress: 'Being seen', + completed: 'Completed', + cancelled: 'Cancelled', + no_show: 'Marked as a no-show', +}; + +/** Settled visits have no queue to join, and saying why is kinder than + * silently omitting the panel. */ +const CLOSED_COPY: Record<'completed' | 'cancelled' | 'no_show', string> = { + completed: 'This visit is already closed, so there is nothing to check in to.', + cancelled: 'This appointment was cancelled, so there is nothing to check in to.', + no_show: 'This appointment was recorded as a no-show, so there is nothing to check in to.', +}; + +/** `checked_in` and `in_progress` came from the doctor-side statuses the W3 + * flow added: a patient already in the queue must still see the live panel, + * not a "nothing to check in to" message. */ +function isClosedVisit(status: AppointmentStatus): status is 'completed' | 'cancelled' | 'no_show' { + return status === 'completed' || status === 'cancelled' || status === 'no_show'; +} + +export function AppointmentDetailPage() { + const { appointmentId } = useParams(); + const appointmentsQuery = useMyAppointments(); + const now = new Date(); + + if (appointmentsQuery.isError) { + const message = + appointmentsQuery.error instanceof ApiError + ? appointmentsQuery.error.message + : 'Your appointment could not be loaded.'; + return ( +
+
+

Your appointment

+ +

+ Back to your care summary +

+
+
+ ); + } + + const appointments = appointmentsQuery.data; + if (!appointments) { + return ( +
+
+

Your appointment

+

{appointmentsQuery.isFetching ? 'Loading your appointment…' : 'Sign in to see your appointment.'}

+
+
+ ); + } + + const appointment = appointments.find((row) => row.id === appointmentId); + if (!appointment) { + return ( +
+
+

Your appointment

+

We cannot find that appointment on your account.

+

+ Back to your care summary +

+
+
+ ); + } + + const slot = appointment.availabilitySlot; + + return ( +
+
+ {STATUS_COPY[appointment.status]} +

Your appointment

+ +

+ + {formatSlotDate(slot.startTime, now)} at {formatSlotTime(slot.startTime)} + +
+ Scheduled until {formatSlotTime(slot.endTime)}. +

+ +

+ {appointment.origin === 'referral' + ? 'This visit came from a referral your clinician sent.' + : 'You booked this visit yourself.'} +

+ + {isClosedVisit(appointment.status) ? ( +

{CLOSED_COPY[appointment.status]}

+ ) : appointment.status === 'scheduled' && !isOnSlotDay(appointment, now) ? ( +

+ Check-in opens on the day of your visit ({formatSlotDate(slot.startTime, now)}). +

+ ) : ( + + )} + +

+ Back to your care summary +

+
+
+ ); +} diff --git a/src/pages/PatientDashboardPage.module.css b/src/pages/PatientDashboardPage.module.css new file mode 100644 index 0000000..289bb38 --- /dev/null +++ b/src/pages/PatientDashboardPage.module.css @@ -0,0 +1,46 @@ +.title { + text-wrap: balance; +} + +.heading { + font-family: var(--font-display); + font-size: var(--text-lg); + color: var(--color-text); + margin: var(--space-6) 0 var(--space-3); +} + +.loading { + font-size: var(--text-sm); + color: var(--color-text-faint); + margin: var(--space-3) 0 0; +} + +.sourceError { + font-size: var(--text-sm); + color: var(--color-text-muted); + background: var(--color-warning-surface); + border-radius: var(--radius-sm); + padding: var(--space-3); + margin: var(--space-3) 0; +} + +.empty { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin: 0; +} + +.links { + display: flex; + gap: var(--space-4); + margin-top: var(--space-6); +} + +/* Same breakpoint HomePage uses — at phone width the two links each wrapped + onto two lines side by side, which reads as a broken row. */ +@media (max-width: 760px) { + .links { + flex-direction: column; + gap: var(--space-3); + } +} diff --git a/src/pages/PatientDashboardPage.test.tsx b/src/pages/PatientDashboardPage.test.tsx new file mode 100644 index 0000000..a26d2df --- /dev/null +++ b/src/pages/PatientDashboardPage.test.tsx @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '../test/render'; +import { PatientDashboardPage } from './PatientDashboardPage'; +import { useAuthStore } from '../store/auth-store'; +import { useProfileStore } from '../store/profile-store'; +import type { PatientAppointment, Dependent, HealthEntry, HealthSectionKey } from '../types/models'; +import * as dependentsService from '../services/dependents-service'; +import * as healthService from '../services/health-profile-service'; +import * as appointmentsService from '../services/appointments-service'; + +vi.mock('../services/dependents-service'); +vi.mock('../services/health-profile-service'); +vi.mock('../services/appointments-service'); + +const DAY_MS = 24 * 60 * 60 * 1000; + +function dependent(overrides: Partial): Dependent { + return { + id: 'dep-1', + guardianProfileId: 'gp-1', + fullName: 'Child One', + dateOfBirth: '2012-05-15', + relationship: 'parent', + handoverStatus: 'not_eligible', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function entry(overrides: Partial): HealthEntry { + return { + id: 'he-1', + patientProfileId: 'pp-1', + section: 'allergies', + name: 'Penicillin', + version: 1, + updatedAt: new Date(Date.now() - 2 * DAY_MS).toISOString(), + createdAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +/** Every section resolves to the same list unless a section map is given, so + * tests describe only the data they care about. */ +function mockHealth(bySection: Partial> = {}) { + vi.mocked(healthService.listHealthEntries).mockImplementation((_profileId, section) => { + const value = bySection[section] ?? []; + return value instanceof Error ? Promise.reject(value) : Promise.resolve(value); + }); +} + +/** "Late this evening" is the one slot time that is guaranteed to still be + * today *and* still ahead of the patient, whatever local timezone CI runs in — + * a fixed `09:00` would be in the past for an afternoon run. */ +function laterToday(): Date { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 50, 0); +} + +function appointment(start: Date, status: PatientAppointment['status'] = 'scheduled'): PatientAppointment { + return { + id: 'apt-1', + patientProfileId: 'pp-1', + doctorProfileId: 'dp-1', + availabilitySlotId: 'slot-1', + status, + origin: 'direct_booking', + createdAt: '2026-10-13T09:00:00Z', + updatedAt: '2026-10-13T09:00:00Z', + availabilitySlot: { + id: 'slot-1', + doctorProfileId: 'dp-1', + status: 'booked', + startTime: start.toISOString(), + endTime: new Date(start.getTime() + 10 * 60 * 1000).toISOString(), + createdAt: '2026-10-01T09:00:00Z', + updatedAt: '2026-10-01T09:00:00Z', + }, + }; +} + +describe('PatientDashboardPage care hub', () => { + beforeEach(() => { + useAuthStore.setState({ accessToken: 'test-token', user: null }); + useProfileStore.setState({ profiles: [{ id: 'pp-1', fullName: 'Self', isSelf: true }], selectedProfileId: 'pp-1' }); + vi.mocked(dependentsService.listDependents).mockReset(); + vi.mocked(healthService.listHealthEntries).mockReset(); + vi.mocked(appointmentsService.listMyAppointments).mockReset(); + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([]); + mockHealth(); + }); + + it('ranks an expired handover invite above a not-yet-blocked one', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([ + dependent({ id: 'dep-2', fullName: 'Child Two', handoverStatus: 'eligible' }), + dependent({ + id: 'dep-1', + fullName: 'Amara', + handoverStatus: 'invited', + claimEmail: 'amara@example.test', + claimInviteExpiresAt: new Date(Date.now() - DAY_MS).toISOString(), + }), + ]); + mockHealth({ allergies: [entry({})] }); + + render(); + + const actionHeading = await screen.findByRole('heading', { name: 'Action needed' }); + const actionSection = actionHeading.parentElement as HTMLElement; + await within(actionSection).findByText(/Resend Amara's invite/i); + const cards = within(actionSection).getAllByRole('link'); + expect(cards[0]).toHaveTextContent(/Resend Amara's invite/i); + expect(cards[1]).toHaveTextContent(/Start Child Two's handover/i); + expect(within(actionSection).getByText('Invite expired')).toBeInTheDocument(); + }); + + it('prompts for a health profile that is genuinely empty', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([]); + mockHealth(); + + render(); + + expect(await screen.findByText(/Add your allergies, conditions and medications/i)).toBeInTheDocument(); + }); + + it('does not call a profile empty when a health section failed to load', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([]); + mockHealth({ conditions: new Error('offline') }); + + render(); + + await screen.findByText(/could not be loaded/i); + expect(screen.queryByText(/Add your allergies, conditions and medications/i)).not.toBeInTheDocument(); + }); + + it('keeps rendering the rest of the hub when one source fails', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([ + dependent({ handoverStatus: 'eligible' }), + ]); + mockHealth({ medications: new Error('offline') }); + + render(); + + expect(await screen.findByText(/Start Child One's handover/i)).toBeInTheDocument(); + expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument(); + }); + + it('renders the dependent cards instead of an endless spinner while no care profile is selected', async () => { + useProfileStore.setState({ profiles: [], selectedProfileId: null }); + vi.mocked(dependentsService.listDependents).mockResolvedValue([ + dependent({ handoverStatus: 'eligible' }), + ]); + mockHealth(); + + render(); + + expect(await screen.findByText(/Start Child One's handover/i)).toBeInTheDocument(); + expect(screen.queryByText(/Loading your care summary/i)).not.toBeInTheDocument(); + // Nothing was fetched, so nothing was fetched-and-found-empty either: the + // hub must not read a profile with no health data as an empty one. + expect(healthService.listHealthEntries).not.toHaveBeenCalled(); + expect(screen.queryByText(/Add your allergies, conditions and medications/i)).not.toBeInTheDocument(); + }); + + it('lists a recently updated entry under Recent', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([]); + mockHealth({ allergies: [entry({ id: 'he-9', name: 'Latex' })] }); + + render(); + + expect(await screen.findByText('Latex updated')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Recent' })).toBeInTheDocument(); + }); + + it('hides the Recent section when nothing changed recently', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([]); + mockHealth({ + conditions: [entry({ id: 'he-old', name: 'Asthma', updatedAt: new Date(Date.now() - 90 * DAY_MS).toISOString() })], + }); + + render(); + + await screen.findByText(/Nothing needs your attention right now/i); + expect(screen.queryByRole('heading', { name: 'Recent' })).not.toBeInTheDocument(); + expect(screen.queryByText('Asthma updated')).not.toBeInTheDocument(); + }); + + it('keeps the detail links reachable from the hub', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([]); + mockHealth({ + conditions: [entry({ id: 'he-settled', name: 'Asthma', updatedAt: new Date(Date.now() - 90 * DAY_MS).toISOString() })], + }); + + render(); + + await screen.findByText(/Nothing needs your attention right now/i); + expect(screen.getByRole('link', { name: /manage dependents/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /health profile/i })).toBeInTheDocument(); + }); + + it('brings a visit happening today into Action needed and points at check-in', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([]); + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([appointment(laterToday())]); + + render(); + + const action = within(screen.getByRole('heading', { name: 'Action needed' }).parentElement as HTMLElement); + const card = await action.findByRole('link', { name: /check in for your visit today/i }); + expect(card).toHaveAttribute('href', '/patient/appointments/apt-1'); + expect(within(card).getByText('Today')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Coming up' })).not.toBeInTheDocument(); + }); + + 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)), + ]); + + render(); + + const comingUp = await screen.findByRole('heading', { name: 'Coming up' }); + const section = within(comingUp.parentElement as HTMLElement); + const card = await section.findByRole('link', { name: /visit tomorrow at \d{2}:\d{2}/i }); + expect(section.getByText('Check-in opens on the day of your visit.')).toBeInTheDocument(); + expect(card).toHaveAttribute('href', '/patient/appointments/apt-1'); + expect(screen.getByRole('heading', { name: 'Action needed' }).parentElement).not.toHaveTextContent(/visit tomorrow/i); + }); + + it('does not offer a cancelled visit for check-in', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([]); + vi.mocked(appointmentsService.listMyAppointments).mockResolvedValue([ + appointment(laterToday(), 'cancelled'), + ]); + + render(); + + // Anchor on *something* the hub settled into: the empty-profile prompt, + // which only appears once every other source has answered. + await screen.findByText(/Add your allergies/i); + expect(screen.queryByText(/check in for your visit today/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Coming up' })).not.toBeInTheDocument(); + }); + + it('says the visit list could not be loaded instead of showing no visits', async () => { + vi.mocked(dependentsService.listDependents).mockResolvedValue([dependent({ handoverStatus: 'eligible' })]); + vi.mocked(appointmentsService.listMyAppointments).mockRejectedValue(new Error('offline')); + + render(); + + expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument(); + // The rest of the hub is still real, so it must still be on screen. + expect(screen.getByText(/Start Child One's handover/i)).toBeInTheDocument(); + expect(screen.queryByText(/Nothing needs your attention right now/i)).not.toBeInTheDocument(); + }); + + it('makes no visit request without a session, and does not spin forever', async () => { + useAuthStore.setState({ accessToken: null }); + + render(); + + expect(await screen.findByText(/Nothing needs your attention right now/i)).toBeInTheDocument(); + expect(screen.queryByText(/Loading your care summary/i)).not.toBeInTheDocument(); + expect(appointmentsService.listMyAppointments).not.toHaveBeenCalled(); + expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/PatientDashboardPage.tsx b/src/pages/PatientDashboardPage.tsx index 03f19c5..6675f3b 100644 --- a/src/pages/PatientDashboardPage.tsx +++ b/src/pages/PatientDashboardPage.tsx @@ -1,28 +1,77 @@ import { Link, Route, Routes } from 'react-router-dom'; -import { useAuthStore } from '../store/auth-store'; import { ActingForBanner } from '../features/dependents/components/ActingForBanner'; +import { CareCardList } from '../features/care-hub/components/CareCardList'; +import { useCareHub } from '../features/care-hub/hooks/useCareHub'; +import { groupBySection } from '../features/care-hub/ranking'; +import { AppointmentDetailPage } from './AppointmentDetailPage'; import { DependentsPage } from './DependentsPage'; import { HealthProfilePage } from './HealthProfilePage'; import { AccountSettingsPage } from './AccountSettingsPage'; import { FacilityFinderPage } from './FacilityFinderPage'; import { CareThreadPage } from './CareThreadPage'; +import styles from './PatientDashboardPage.module.css'; function PatientHome() { - const user = useAuthStore((state) => state.user); + const { cards, sources } = useCareHub(); + const sections = groupBySection(cards); + + // A source that failed must not blank the hub: its section says so, the + // others still render from the data that did arrive. + const anyLoading = sources.dependents.isLoading || sources.appointments.isLoading || sources.health.isLoading; return (
+
Phase W2 · in progress -

Welcome{user ? `, ${user.email}` : ''}

-

Doctor search, booking, and your appointments will live here — see ROADMAP.md Phase W2.

-
@@ -38,6 +87,7 @@ export function PatientDashboardPage() { } /> } /> } /> + } /> ); } diff --git a/src/pages/PaymentDetailPage.module.css b/src/pages/PaymentDetailPage.module.css new file mode 100644 index 0000000..64535f0 --- /dev/null +++ b/src/pages/PaymentDetailPage.module.css @@ -0,0 +1,27 @@ +.title { + text-wrap: balance; +} + +.loading { + font-size: var(--text-sm); + color: var(--color-text-faint); + margin: var(--space-3) 0 0; +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-4); +} + +.back { + margin: var(--space-6) 0 0; + font-size: var(--text-sm); +} + +@media print { + .noPrint { + display: none; + } +} diff --git a/src/pages/PaymentDetailPage.test.tsx b/src/pages/PaymentDetailPage.test.tsx new file mode 100644 index 0000000..4cbd8f2 --- /dev/null +++ b/src/pages/PaymentDetailPage.test.tsx @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render as rtlRender, screen, within } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { PaymentDetailPage } from './PaymentDetailPage'; +import * as paymentHistoryService from '../services/payment-history-service'; +import { ApiError } from '../services/api-client'; +import { useAuthStore } from '../store/auth-store'; +import type { PaymentHistoryEntry } from '../types/models'; + +vi.mock('../services/payment-history-service'); + +const TX = 'a'.repeat(64); +const CONTRACT = 'CA'.padEnd(56, 'B'); + +function entry(overrides: Partial = {}): PaymentHistoryEntry { + return { + id: 'pi-1', + orderType: 'prescription', + orderId: 'ord-1', + status: 'escrowed', + patientProfileId: 'pp-1', + facilityId: 'fac-1', + amountStroops: '25000000', + escrowId: '77', + stellarTxHash: TX, + sorobanContractId: CONTRACT, + createdAt: '2026-10-01T10:00:00Z', + updatedAt: '2026-10-01T10:00:00Z', + ...overrides, + }; +} + +function renderAt(path: string) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return rtlRender( + + + + } /> + + + , + ); +} + +describe('PaymentDetailPage', () => { + beforeEach(() => { + useAuthStore.setState({ accessToken: 'test-token', user: null }); + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockReset(); + window.print = vi.fn(); + }); + + it('prints a receipt that is still verifiable on paper', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockResolvedValue(entry()); + + const { container } = renderAt('/payments/pi-1'); + + const receipt = await screen.findByTestId('payment-receipt'); + // The status word appears twice on this page (the pill above the receipt + // and the line inside it), so the receipt itself is the scope here. + expect(within(receipt).getByText('2.5 XLM')).toBeInTheDocument(); + expect(within(receipt).getByText('Held in escrow')).toBeInTheDocument(); + // The hashes have to be readable text, not only hrefs, or a printed + // receipt proves nothing to whoever receives it. + expect(within(receipt).getByText(TX)).toBeInTheDocument(); + expect(within(receipt).getByText(CONTRACT)).toBeInTheDocument(); + // …and the internal pointers a patient cannot act on stay off it. + expect(container.textContent).not.toMatch(/fac-1|ord-1|pp-1/); + }); + + it('links the explorer from the page chrome, never the printed copy', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockResolvedValue(entry()); + + renderAt('/payments/pi-1'); + + const txLink = await screen.findByRole('link', { name: 'transaction' }); + expect(txLink).toHaveAttribute('href', `https://testnet.stellar.expert/explorer/public/transaction/${TX}`); + expect(txLink).toHaveAttribute('target', '_blank'); + expect(txLink).toHaveAttribute('rel', 'noopener noreferrer'); + + const contractLink = screen.getByRole('link', { name: 'escrow contract' }); + expect(contractLink).toHaveAttribute('href', `https://testnet.stellar.expert/explorer/public/contract/${CONTRACT}`); + expect(contractLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('does not offer to print a receipt that was never fetched', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockResolvedValue(entry()); + + renderAt('/payments/pi-1'); + + await screen.findByTestId('payment-receipt'); + await userEvent.click(screen.getByRole('button', { name: /print receipt/i })); + expect(window.print).toHaveBeenCalledTimes(1); + }); + + it('says there is nothing to verify before the payment is on-chain', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockResolvedValue( + entry({ stellarTxHash: null, sorobanContractId: null }), + ); + + renderAt('/payments/pi-1'); + + expect(await screen.findByText(/no on-chain transaction yet/i)).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'transaction' })).not.toBeInTheDocument(); + }); + + it('explains what each status means for the money', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockResolvedValue( + entry({ status: 'released' }), + ); + + renderAt('/payments/pi-1'); + + expect(await screen.findByText(/the money was released to the facility/i)).toBeInTheDocument(); + }); + + it('shows a refund window that is still open', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockResolvedValue( + entry({ + status: 'released', + refundDeadlineAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), + }), + ); + + renderAt('/payments/pi-1'); + + expect(await screen.findByText(/can still be refunded until/i)).toBeInTheDocument(); + }); + + it('shows a refund window that has already closed', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockResolvedValue( + entry({ + refundDeadlineAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + }), + ); + + renderAt('/payments/pi-1'); + + expect(await screen.findByText(/the refund window closed on/i)).toBeInTheDocument(); + }); + + it('omits the refund window entirely when the response has none', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockResolvedValue(entry()); + + renderAt('/payments/pi-1'); + + await screen.findByTestId('payment-receipt'); + expect(screen.queryByText(/refund window/i)).not.toBeInTheDocument(); + }); + + it('labels a lab order as a lab order', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockResolvedValue( + entry({ orderType: 'lab_order' }), + ); + + renderAt('/payments/pi-1'); + + expect(await screen.findByText('Lab order')).toBeInTheDocument(); + }); + + it('says the receipt is unavailable rather than showing an empty one', async () => { + vi.mocked(paymentHistoryService.getPaymentHistoryEntry).mockRejectedValue(new ApiError('Not Found', 404)); + + renderAt('/payments/pi-1'); + + expect(await screen.findByText(/not available for this deployment yet/i)).toBeInTheDocument(); + expect(screen.queryByTestId('payment-receipt')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /back to your payments/i })).toHaveAttribute('href', '/payments'); + }); + + it('asks the visitor to sign in instead of guessing', async () => { + useAuthStore.setState({ accessToken: null }); + + renderAt('/payments/pi-1'); + + expect(await screen.findByText(/sign in to see this payment/i)).toBeInTheDocument(); + expect(paymentHistoryService.getPaymentHistoryEntry).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/PaymentDetailPage.tsx b/src/pages/PaymentDetailPage.tsx new file mode 100644 index 0000000..0c1b02a --- /dev/null +++ b/src/pages/PaymentDetailPage.tsx @@ -0,0 +1,78 @@ +import { Link, useParams } from 'react-router-dom'; +import { Button } from '../components/Button'; +import { FormError } from '../components/FormError'; +import { PaymentReceipt } from '../features/payments/components/PaymentReceipt'; +import { contractUrl, transactionUrl } from '../features/payments/explorer'; +import { usePaymentHistoryEntry } from '../features/payments/hooks/usePaymentHistory'; +import { historyErrorCopy, statusLabel } from '../features/payments/status'; +import styles from './PaymentDetailPage.module.css'; + +export function PaymentDetailPage() { + const { intentId } = useParams(); + const entryQuery = usePaymentHistoryEntry(intentId); + const now = new Date(); + + if (entryQuery.isError) { + return ( +
+
+

Payment

+ +

+ Back to your payments +

+
+
+ ); + } + + const entry = entryQuery.data; + if (!entry) { + return ( +
+
+

Payment

+

+ {entryQuery.isFetching ? 'Loading this payment…' : 'Sign in to see this payment.'} +

+
+
+ ); + } + + const txLink = transactionUrl(entry.stellarTxHash); + const contractLink = contractUrl(entry.sorobanContractId); + + return ( +
+
+ {statusLabel(entry.status)} +

Payment

+ + {/* The print button is the only action here: a receipt has to be + produceable without a developer knowing which element to hide. */} +
+ + {txLink ? ( + + View transaction on the explorer + + ) : null} + {contractLink ? ( + + View escrow contract + + ) : null} +
+ + + +

+ Back to your payments +

+
+
+ ); +} diff --git a/src/pages/PaymentHistoryPage.module.css b/src/pages/PaymentHistoryPage.module.css new file mode 100644 index 0000000..ee94a0c --- /dev/null +++ b/src/pages/PaymentHistoryPage.module.css @@ -0,0 +1,17 @@ +.title { + text-wrap: balance; +} + +.intro { + margin-top: 0; +} + +.loading { + font-size: var(--text-sm); + color: var(--color-text-faint); + margin: var(--space-3) 0; +} + +.older { + margin-top: var(--space-4); +} diff --git a/src/pages/PaymentHistoryPage.test.tsx b/src/pages/PaymentHistoryPage.test.tsx new file mode 100644 index 0000000..4a215a9 --- /dev/null +++ b/src/pages/PaymentHistoryPage.test.tsx @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../test/render'; +import { PaymentHistoryPage } from './PaymentHistoryPage'; +import * as paymentHistoryService from '../services/payment-history-service'; +import { ApiError } from '../services/api-client'; +import { useAuthStore } from '../store/auth-store'; +import type { PaymentHistoryEntry, PaymentHistoryPage as Page } from '../types/models'; + +vi.mock('../services/payment-history-service'); + +function entry(overrides: Partial = {}): PaymentHistoryEntry { + return { + id: 'pi-1', + orderType: 'prescription', + orderId: 'ord-1', + status: 'escrowed', + patientProfileId: 'pp-1', + facilityId: 'fac-1', + amountStroops: '25000000', + escrowId: '77', + stellarTxHash: null, + sorobanContractId: null, + createdAt: '2026-10-01T10:00:00Z', + updatedAt: '2026-10-01T10:00:00Z', + ...overrides, + }; +} + +function page(intents: PaymentHistoryEntry[], nextCursor: string | null): Page { + return { intents, nextCursor }; +} + +describe('PaymentHistoryPage', () => { + beforeEach(() => { + useAuthStore.setState({ accessToken: 'test-token', user: null }); + vi.mocked(paymentHistoryService.listPaymentHistory).mockReset(); + }); + + it('shows the amount with its unit and the status in plain words', async () => { + vi.mocked(paymentHistoryService.listPaymentHistory).mockResolvedValue(page([entry()], null)); + + const { container } = render(); + + expect(await screen.findByText('2.5 XLM')).toBeInTheDocument(); + expect(screen.getByText('Held in escrow')).toBeInTheDocument(); + expect(screen.getByText('Prescription')).toBeInTheDocument(); + + // facilityId/orderId are pointers the patient cannot act on, and the + // facility name is only an assumed extra — never fall back to the id. + expect(container.textContent).not.toMatch(/fac-1|ord-1|pp-1/); + expect(screen.getByText('Prescription')).toBeInTheDocument(); + }); + + it('names the facility when the response carries it', async () => { + vi.mocked(paymentHistoryService.listPaymentHistory).mockResolvedValue( + page([entry({ facilityName: 'Bello Lab' })], null), + ); + + render(); + + expect(await screen.findByText(/Bello Lab/)).toBeInTheDocument(); + }); + + // The review criterion for #37: above 2^53 a JS number would round the + // patient's own payment. + it('prints an amount larger than 2^53 stroops exactly', async () => { + vi.mocked(paymentHistoryService.listPaymentHistory).mockResolvedValue( + page([entry({ amountStroops: '9007199254740993' })], null), + ); + + render(); + + expect(await screen.findByText('900,719,925.4740993 XLM')).toBeInTheDocument(); + }); + + it('links each payment to its own page', async () => { + vi.mocked(paymentHistoryService.listPaymentHistory).mockResolvedValue(page([entry()], null)); + + render(); + + expect(await screen.findByRole('link', { name: /2\.5 XLM/i })).toHaveAttribute('href', '/payments/pi-1'); + }); + + it('asks for the next cursor only when the patient asks for older payments', async () => { + vi.mocked(paymentHistoryService.listPaymentHistory) + .mockResolvedValueOnce(page([entry()], 'cursor-2')) + .mockResolvedValueOnce(page([entry({ id: 'pi-0', amountStroops: '10000000' })], null)); + + render(); + + await screen.findByText('2.5 XLM'); + expect(screen.queryByText('1 XLM')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /show older payments/i })); + + await waitFor(() => expect(screen.getByText('1 XLM')).toBeInTheDocument()); + expect(paymentHistoryService.listPaymentHistory).toHaveBeenLastCalledWith('cursor-2', 'test-token'); + }); + + it('says history is unavailable rather than empty when the endpoint is missing', async () => { + vi.mocked(paymentHistoryService.listPaymentHistory).mockRejectedValue(new ApiError('Not Found', 404)); + + render(); + + expect(await screen.findByText(/not available for this deployment yet/i)).toBeInTheDocument(); + expect(screen.queryByText(/have not paid for anything/i)).not.toBeInTheDocument(); + }); + + it('describes an account that genuinely has no payments', async () => { + vi.mocked(paymentHistoryService.listPaymentHistory).mockResolvedValue(page([], null)); + + render(); + + expect(await screen.findByText(/have not paid for anything/i)).toBeInTheDocument(); + }); + + it('explains every status the contract has, without printing the token', async () => { + vi.mocked(paymentHistoryService.listPaymentHistory).mockResolvedValue( + page( + [ + entry({ id: 'a', status: 'created' }), + entry({ id: 'b', status: 'escrowed' }), + entry({ id: 'c', status: 'released' }), + entry({ id: 'd', status: 'refunded' }), + entry({ id: 'e', status: 'failed' }), + ], + null, + ), + ); + + render(); + + await screen.findByText(/not funded/i); + for (const label of ['Not funded', 'Held in escrow', 'Released', 'Refunded', 'Failed']) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + expect(document.body.textContent).not.toMatch(/\bescrowed\b/); + }); +}); diff --git a/src/pages/PaymentHistoryPage.tsx b/src/pages/PaymentHistoryPage.tsx new file mode 100644 index 0000000..b14e7db --- /dev/null +++ b/src/pages/PaymentHistoryPage.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; +import { Button } from '../components/Button'; +import { FormError } from '../components/FormError'; +import { PaymentHistoryList } from '../features/payments/components/PaymentHistoryList'; +import { usePaymentHistory } from '../features/payments/hooks/usePaymentHistory'; +import { historyErrorCopy } from '../features/payments/status'; +import styles from './PaymentHistoryPage.module.css'; + +export function PaymentHistoryPage() { + const historyQuery = usePaymentHistory(); + const entries = historyQuery.data?.pages.flatMap((page) => page.intents) ?? []; + + // "You have paid for nothing" is a statement about the patient's money, so + // it is only rendered from a response that arrived. A failed request says + // what failed instead — and a later page failing still shows what loaded. + let body: ReactNode; + if (entries.length > 0) body = ; + else if (historyQuery.isError) body = null; + else if (historyQuery.isFetching) body =

Loading your payments…

; + else body = ; + + return ( +
+
+ Payments +

Your payments

+

+ Every payment you have made through DocForum, with where the money is now: still yours, held in escrow, + released to the facility, or returned to you. +

+ + {historyQuery.isError ? ( + 0 ? 'Older payments could not be loaded.' : historyErrorCopy(historyQuery.error)} /> + ) : null} + + {body} + + {historyQuery.hasNextPage ? ( + + ) : null} +
+
+ ); +} diff --git a/src/services/access-log-service.ts b/src/services/access-log-service.ts new file mode 100644 index 0000000..3865cec --- /dev/null +++ b/src/services/access-log-service.ts @@ -0,0 +1,27 @@ +// 'Who viewed my records' access log. Assumed contract against +// docforum-core#45 (PHI read-access logging), which has not landed — see +// ARCHITECTURE_ESSENTIALS.md "Known assumptions". This repo never reads a +// log from anywhere else (hard rule 1). +import { apiFetch } from './api-client'; +import type { AccessLogPage, AccessRecordType } from '../types/models'; + +export interface AccessLogFilters { + from?: string; + to?: string; + recordType?: AccessRecordType; + cursor?: string; +} + +function toQuery(filters: AccessLogFilters): string { + const params = new URLSearchParams(); + for (const value of ['from', 'to', 'recordType', 'cursor'] as const) { + const param = filters[value]; + if (param) params.set(value, param); + } + const query = params.toString(); + return query ? `?${query}` : ''; +} + +export function listAccessLog(filters: AccessLogFilters, accessToken: string): Promise { + return apiFetch(`/patients/me/access-log${toQuery(filters)}`, { accessToken }); +} diff --git a/src/services/appointments-service.ts b/src/services/appointments-service.ts new file mode 100644 index 0000000..d4c5cd8 --- /dev/null +++ b/src/services/appointments-service.ts @@ -0,0 +1,27 @@ +// Appointments calls to docforum-core. +// +// `GET /appointments/mine` is REAL (its appointments/routes + +// appointments/repositories, which include the availability slot). The two +// check-in/queue calls are an assumed contract against docforum-core#129, +// which has not landed — see ARCHITECTURE_ESSENTIALS.md "Known assumptions". +// +// Nothing here needs the patient's own `patientProfileId`: core scopes +// `/appointments/mine` from the session, which is why the patient-facing +// endpoints in #32/#34/#37 are all built as `me`-scoped calls (the frontend +// has no way to learn that id — documented gap). +import { apiFetch } from './api-client'; +import type { PatientAppointment, AppointmentQueue } from '../types/models'; + +export function listMyAppointments(accessToken: string): Promise { + return apiFetch('/appointments/mine', { accessToken }); +} + +/** assumed (core#129) */ +export function getAppointmentQueue(appointmentId: string, accessToken: string): Promise { + return apiFetch(`/appointments/${appointmentId}/queue`, { accessToken }); +} + +/** assumed (core#129) */ +export function checkIn(appointmentId: string, accessToken: string): Promise { + return apiFetch(`/appointments/${appointmentId}/check-in`, { method: 'POST', accessToken }); +} diff --git a/src/services/payment-history-service.ts b/src/services/payment-history-service.ts new file mode 100644 index 0000000..c1ed3af --- /dev/null +++ b/src/services/payment-history-service.ts @@ -0,0 +1,17 @@ +// Patient-facing payment history. The PaymentIntent shape these return is +// real (docforum-core Phase 5.5, its docs/adr/0004-custodial-payments-v1.md); +// the `mine` list/detail endpoints themselves are assumed against +// docforum-core#90, which has not landed — today core only exposes +// admin-only `/payments/intents...` routes. See ARCHITECTURE_ESSENTIALS.md +// "Known assumptions". No Stellar call happens here (hard rule 1). +import { apiFetch } from './api-client'; +import type { PaymentHistoryEntry, PaymentHistoryPage } from '../types/models'; + +export function listPaymentHistory(cursor: string | undefined, accessToken: string): Promise { + const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''; + return apiFetch(`/payments/mine${query}`, { accessToken }); +} + +export function getPaymentHistoryEntry(id: string, accessToken: string): Promise { + return apiFetch(`/payments/mine/${encodeURIComponent(id)}`, { accessToken }); +} diff --git a/src/styles/global.css b/src/styles/global.css index d8b10bb..8ed8091 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -117,3 +117,13 @@ --color-danger-surface: #2e1c15; --color-focus-ring: #4a + +/* Printing should hand over the document, not the app around it. The patient + receipt (#37) is the first thing here worth printing. */ +@media print { + header, + nav, + footer { + display: none; + } +} diff --git a/src/types/models.ts b/src/types/models.ts index b9f6135..0481520 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -777,3 +777,93 @@ export interface LabResult { isCritical: boolean; issuedAt: string; } + +// --- Patient-side appointment view (issues #32/#33/#34/#37) ------------------ +// `Appointment` above is the doctor-facing row the W3 queue screens use. The +// patient screens in this feature set read docforum-core's real +// `GET /appointments/mine`, which `include`s the availability slot and returns +// the origin and audit timestamps, so it is a different shape and gets its own +// name rather than a second declaration of `Appointment`. Dates are ISO strings +// because that is what JSON carries. + +export type SlotStatus = 'open' | 'held' | 'booked'; +export type AppointmentOrigin = 'direct_booking' | 'referral'; + +export interface AvailabilitySlot { + id: string; + doctorProfileId: string; + status: SlotStatus; + startTime: string; + endTime: string; + createdAt: string; + updatedAt: string; +} + +export interface PatientAppointment { + id: string; + patientProfileId: string; + doctorProfileId: string; + availabilitySlotId: string; + status: AppointmentStatus; + origin: AppointmentOrigin; + availabilitySlot: AvailabilitySlot; + createdAt: string; + updatedAt: string; +} + +// --- Live visit queue (#32) -------------------------------------------------- +// assumed — docforum-core has no queue endpoint yet; the shape is the smallest +// one that can distinguish "no queue published" from "you are first". + +export type QueuePhase = 'not_open' | 'waiting' | 'being_seen' | 'completed'; + +export interface AppointmentQueue { + appointmentId: string; + phase: QueuePhase; + /** Patients still ahead. null means no queue is published yet — which is + * not the same as being first. */ + peopleAhead: number | null; + /** Minutes. Always rendered as an estimate. */ + estimatedWaitMinutes: number | null; + /** null = this backend has no check-in window, so the UI says "check in on + * the day" instead of inventing an opening time. */ + checkInOpensAt: string | null; + checkedInAt: string | null; +} + +// --- PHI access log (#34) ---------------------------------------------------- +// assumed — docforum-core#40 (audit log read API) is not landed; these mirror +// the event its write-side middleware already records. + +export type AccessActorRole = 'doctor' | 'facility' | 'patient' | 'admin'; +export type AccessRecordType = 'health_profile' | 'appointment' | 'referral' | 'order' | 'dependent'; +export type AccessReason = 'consultation' | 'order_fulfillment' | 'result_delivery' | 'handover_claim' | 'support'; + +export interface AccessLogEvent { + id: string; + occurredAt: string; + actorRole: AccessActorRole; + actorName: string; + recordType: AccessRecordType; + reason: AccessReason; +} + +export interface AccessLogPage { + events: AccessLogEvent[]; + nextCursor: string | null; +} + +// --- Payment history and receipts (#37) -------------------------------------- + +export interface PaymentHistoryEntry extends PaymentIntent { + /** assumed — the real row has no refund deadline column. */ + refundDeadlineAt?: string | null; + /** assumed — the real response carries only `facilityId`, an internal id + * the patient-facing UI must not render. */ + facilityName?: string; +} + +export interface PaymentHistoryPage { + intents: PaymentHistoryEntry[]; + nextCursor: string | null; +}