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 (
+
+ );
+}
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.
+
+ 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 (
+
+ );
+}
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 (
+
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.
+
+ {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 (
+
+ );
+}
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.
+