Health Diary — Patient Web Interface Design
Issue: #2291 Status: Built. The screens below ship in #2417; the failure states were reworked in #2518. Companion docs: Product spec · How a patient uses it · Developer guide · QA guide App:
patient/(Vite + React 19 + React Router 7 + TanStack Query + shadcn/Radix + Tailwind 4) Revised: 2026-09-15 — layout reworked to coexist with the app sidebar; dashboard and notification integration added.Two jobs, one surface: the Health Diary for patients who use the web app, and a working harness that exercises every
/patient/health-diary/*endpoint without a mobile build.
1. Why build this at all
The mobile app is the primary client, but it is a slow loop to develop against: build, install, reproduce, read a device log. A web surface that speaks the identical API gives us:
- A faster debug loop. Every request visible in the network tab, every response inspectable.
- Contract verification before mobile integrates. If the web client can drive all 14 endpoints, the contract is real. Divergences (D-1 in the backend plan) surface here first.
- A place to see the safety rules. Disclaimer rendering, emergency banners, moderation refusals and rate-limit handling are UI problems as much as backend ones.
- Caregiver mode for free. The web app already carries
X-On-Behalf-Ofon every request.
It is a real patient surface, not a test page — but the diagnostics drawer (§8) exists because the debugging job is a stated goal, not an afterthought.
2. What the app already gives us
| Need | Already in patient/src |
|---|---|
| Auth + token refresh | api/client.ts axios instance with refresh queue |
| Caregiver impersonation | stores/dependent-context.store.ts → X-On-Behalf-Of interceptor; ImpersonationBanner; useSwitchSubject wipes the query cache on switch |
| Server state | TanStack Query, keys in lib/query/keys.ts, CACHE_CONFIGS stale times |
| Feature layout | features/<name>/{components,hooks,services,types,pages,constants} |
| UI kit | local shadcn in components/ui — card, button, textarea, scroll-area, dialog, sheet, tabs, badge, skeleton, sonner, tooltip |
| Shell | AppLayout + AppSidebar (16rem, collapsible to a 3rem icon rail) |
| Realtime | PatientRealtimeProvider holds one Ably client for the whole session |
| Provenance UI | features/health-records/components/SourceChip.tsx — already distinguishes self-reported from clinic-sourced data |
| Design tokens | globals.css — Mulish, --primary kulcare blue #078dff, radius 0.5rem, light/dark |
Nothing new is needed at the infrastructure layer. This is a feature folder, a route, a sidebar entry and a services file.
3. Information architecture
Route /health-diary (add HEALTH_DIARY: '/health-diary' to APP_ROUTES), sidebar entry
Health Diary with the NotebookPen icon, placed after My Care — it belongs with care, not
with records, because it is where a patient goes when something is happening now.
/health-diary workspace (opens the most recent thread)
/health-diary?thread=<id> deep link to a thread — the shareable/debuggable URL
/health-diary?folder=<id|slug> folder filter
/health-diary?tab=history entries + summary viewThread and folder live in the query string, not the path: it keeps deep links copy-pasteable into a bug report, and survives the cache wipe on a caregiver-context switch.
4. Layout — living inside the app shell
The constraint. AppLayout already spends 16rem (256px) on AppSidebar before a feature
gets any width. A folders rail plus a thread list plus a conversation would make four vertical
columns: 256 + 240 + 300 = 796px of chrome before the patient reads a word. On a 1366px
laptop — common for our users — the conversation is left with about 520px, and the page reads as
a stack of rails rather than a conversation.
The resolution: two panes, folders as a filter strip.
┌──────────────┬────────────────────┬─────────────────────────────────────┐
│ AppSidebar │ Threads (300px) │ Conversation (fluid) │
│ 16rem │ │ │
│ (existing, │ ┌ folder strip ──┐ │ ┌ patient message ───────────┐ │
│ collapsible)│ │All·General 3·…+│ │ └────────────────────────────┘ │
│ │ └────────────────┘ │ ┌ assistant message ─────────┐ │
│ Dashboard │ Headaches, 3 days │ │ answer … │ │
│ Appointments │ "Let's look at…" │ │ ── disclaimer ── │ │
│ My Care │ Trouble sleeping │ │ [Talk to a Doctor] [Care] │ │
│ ▸ Health │ "How many hours…" │ └────────────────────────────┘ │
│ Diary │ Knee pain │ ┌ composer ◎ ↑ ─────────────┐ │
│ Records … │ │ └─────────────────────────────┘ │
└──────────────┴────────────────────┴─────────────────────────────────────┘Folders in this product are four defaults plus a handful of user-created topics with counts. A horizontal strip of filter chips carries that perfectly well, and it buys back 240px for the thing people actually came to read.
It also matches the phone. The mobile contract names a FolderSelector, not a folder rail.
One mental model across both clients — which matters, because mobile is the primary client and
we do not want two different ideas of what a folder is.
Folder management (create, rename, delete, see the thread count that moved) lives behind the
+ chip and a per-chip context menu, not in permanent furniture.
Breakpoints
| Width | Layout |
|---|---|
| ≥1280px | Two panes as drawn. Sidebar stays as the user left it. |
| 768–1279px | Conversation only, full width. Threads move into a Sheet opened by a Conversations button in the diary header; the folder strip goes with them. |
| <768px | The app sidebar is already a sheet at this size. Thread list is the root view; tapping a thread opens the conversation; back returns. ?thread= still drives it. |
Rejected alternatives, and why
| Option | Why not |
|---|---|
Folders as AppSidebar nav children (the nav already supports children, as UHI Network does) | Folders are user data — created, renamed, deleted, counted, and different for every caregiver context. Global chrome shared by twelve nav items is the wrong home for it: fifteen folders would swamp the sidebar, and switching to a dependent would rewrite the app's navigation. |
| Auto-collapse the sidebar to its icon rail on this route | It stomps a preference the user set deliberately, and it makes the app feel like it is fighting them. The toggle is already there if they want the room. |
| Keep three panes and accept the squeeze | 520px of conversation on the most common laptop width, and four rails competing for the eye. |
The other thing the shell does
AppLayout's main element is h-full overflow-y-auto bg-muted/30 p-6 — page-level scrolling and
24px of padding. A chat surface wants the opposite: no page scroll, and the message list owning
the only scroll container, so the composer stays pinned and the header does not drift.
Fix: give AppLayout an optional contentClassName (defaulting to today's classes) and have the
diary pass p-0 overflow-hidden. Three lines, no other page affected. The diary page is then
flex h-full min-h-0 flex-col with min-h-0 on the scrolling child — not a
calc(100vh - …), because ImpersonationBanner sits above main and changes its height
whenever a caregiver is viewing a dependent.
5. The conversation, in its two difficult states
Ordinary replies are the easy case. Two states are where the product promises are kept or quietly broken:
- Emergency flag. The guidance renders above the answer, carries an icon and a label as well as colour, and must pass AA contrast in both themes. It is the one place in this UI where colour alone carrying the meaning would be a safety problem.
- AI unavailable. The patient's own words stay on screen, the copy says plainly that the note was saved, and retry is right there. A UI that drops the message on error breaks promise P9 that the backend is already keeping.
6. Components
patient/src/features/health-diary/
| Component | Responsibility | Notes |
|---|---|---|
pages/HealthDiary.tsx | Route shell, pane orchestration, URL state | reads ?thread, ?folder, ?tab |
components/FolderStrip.tsx | Horizontal folder chips with counts; create/rename/delete | replaces the rejected folder rail; default folders refuse deletion with a reason |
components/ThreadList.tsx | Paged threads with preview and relative time | dayjs, as elsewhere in the app |
components/ThreadSheet.tsx | The 768–1279px home for the strip + list | shadcn Sheet |
components/Conversation.tsx | Scroll container, upward paging, autoscroll on send | infinite query, direction=before |
components/MessageBubble.tsx | One turn: role, content, timestamp, type | voice turns show transcription source + confidence |
components/Disclaimer.tsx | The line under every assistant turn | never behind a "show more" |
components/EmergencyBanner.tsx | Leads the bubble when safety_flag === 'emergency' | icon + label + colour |
components/CtaRow.tsx | Up to three next-step buttons | create_folder creates and moves in one action |
components/Composer.tsx | Textarea, 2000-char counter, send, mic | mints client_message_id per message |
components/VoiceRecorder.tsx | Capture, 120s cap, size guard, multipart post | browser records WebM/Opus — accepted, see §11 |
components/EntryList.tsx | History tab with folder/tag/date filters | filters live in the URL |
components/SummaryCard.tsx | Period summary + top tags | shows generated_at so a cached summary is honest about its age |
components/DiagnosticsDrawer.tsx | The debug surface (§8) | dev-only by default |
components/HealthDiaryCard.tsx | The dashboard cell (§9) | lives here, imported by the dashboard |
7. Data layer
Mirrors features/health-records: a thin service module per resource, hooks over TanStack Query,
keys centralised.
// lib/query/keys.ts — added
hdFolders: ['health-diary','folders'] as const,
hdThreads: (folderId?: string) => ['health-diary','threads', folderId ?? 'all'] as const,
hdMessages: (threadId: string) => ['health-diary','messages', threadId] as const,
hdEntries: (f: Record<string,unknown>) => ['health-diary','entries', f] as const,
hdSummary: (days: number) => ['health-diary','summary', days] as const,Three behaviours worth specifying, because they are where a chat UI gets it wrong by default:
- Optimistic user turn, never an optimistic answer. The patient's message appears immediately with a pending marker; the assistant slot is a typing placeholder. We never render a speculative reply. If the request fails, their words stay on screen with a retry — and are already saved server-side.
- One idempotency key per composed message. Generated once, held across retries, regenerated only when the composer clears. That is what makes the backend's idempotency real rather than theoretical — the client half of decision D-2.
- Cursor paging, not page numbers.
useInfiniteQuerykeyed on the oldest loaded message id, withhas_moredriving the loader at the top of the scroll container.
Caregiver switching needs nothing new — useSwitchSubject already removes all queries. The
one addition is a composer guard: a draft in progress when the subject changes is discarded with
a visible notice, never silently sent into the wrong person's diary.
8. Diagnostics drawer
A slide-over toggled from the diary header, enabled in dev or with ?debug=1, showing for recent
requests: endpoint, status, duration, request_id, the X-RateLimit-* headers, the idempotency
key and attempt number, thread/message ids, safety_flag, whether a disclaimer was present, the
CTA actions returned, transcription source and confidence, and whether the response envelope
matched the mobile contract.
Two actions: Copy as bug report (endpoint, request id, payload, response, app version) and Replay (same request, fresh idempotency key).
9. Dashboard integration
The dashboard is a bento grid where each card owns its own query (features/dashboard/pages/Dashboard.tsx).
The diary earns one cell, one quick action, and an optional line in the activity feed.
9.1 The card
HealthDiaryCard — a col-span-1 cell with two states:
| State | Contents |
|---|---|
| First run (no threads) | "How are you feeling today?" plus a single-line composer. Submitting creates a thread and navigates to /health-diary?thread=… with the message already sent. |
| Returning | Last thread title + last reply preview + Continue; this month's entry count; up to three most frequent topics as chips; the same one-line composer at the bottom. |
The composer is the point. A dashboard card that only displays diary data is a link with extra steps; a card that accepts the first sentence is the product's front door, and it is where a first-time patient will discover the feature at all.
9.2 One hard rule: the dashboard never triggers an LLM call
GET /summary generates a summary — an LLM call, billed and slow. Mounting it on a dashboard
that every patient loads on every visit would mean an AI call per session per patient, which is
both a cost problem and a latency problem on a page that must feel instant.
So the card reads only cheap list endpoints (/entries, /threads) for its counts and topics,
and links to the summary rather than rendering it. If we ever want the summary on the
dashboard, it must come from the cached health_diary_summaries row and render nothing when no
fresh one exists — never generate on read.
9.3 Where it sits
Adding a seventh card means reflowing the grid. Recommended (a product call, not a technical one):
Row 1 Welcome │ Health Diary │ Quick Actions ← today / act now
Row 2 Vitals │ Medications │ Appointments ← your numbers
Row 3 Activity Feed (2-span) │ Wellness ← what has happenedRow 1 becomes "what do I do now", which is exactly the diary's job, and it puts the card where a first-time user's eye already goes.
9.4 Quick actions and refresh
- Add a Diary action (
NotebookPen) toQuickActions— a one-line addition to the array. - Add
['health-diary']to the dashboard's refresh invalidation list, or the refresh button will silently skip the new card.
9.5 Activity feed — optional, and only if it is honest
ActivityFeed reads useClinicalTimeline, which is clinic-sourced data. Diary entries are
patient-authored. Merging them without distinction would let a self-recorded note read as a
clinical record — a provenance problem, not a cosmetic one.
If we merge, the entries must carry the existing SourceChip marking them self-recorded (the
health-records feature already solved exactly this problem). Client-side merge of the two most
recent sources, sorted by date, capped at five. Recommended as optional for v1: the card is the
important surface; the feed is nice-to-have.
9.6 Caregiver context
No special handling — every request already carries the on-behalf-of header, so the card shows
the dependent's diary while impersonating, and ImpersonationBanner above the page already says
whose data is on screen.
10. Notifications
10.1 What the app has today
- No notification inbox. No bell, no list, no unread count anywhere in
patient/. - Ably is used as a refetch signal only.
PatientRealtimeProvidermounts one Ably client for the session;usePatientAppointmentRealtimesubscribes toprivate:user:{id}, listens fornotification.created, and invalidates appointment queries. The payload is{id, kind}— the client re-fetches rather than trusting pushed data. - The backend has an inbox the app never calls:
GET /notifications/me/inbox, mark-read, mark-all-read, withNotifications::Notificationrows. - Push exists:
Shared::Notifications::PushServiceover AWS SNS, withapp_type: 'patient'and per-device fan-out. - Preferences exist but have no diary bucket:
appointment_reminders,prescription_ready,payment_reminders,marketing, each withsms | push | emailtoggles.
10.2 What should notify — and what should not
| Event | Notify? | Reasoning |
|---|---|---|
| AI reply is ready | No | v1 chat is synchronous — the patient is looking at the screen when it arrives. The reply is the response. (The May plan proposed a push here; that was written against an async design and is wrong for this one.) |
| Thread updated on another device | Refetch hint only | A silent realtime event, no inbox row, no toast. Keeps the web and phone in sync when both are open — including caregiver and patient simultaneously. |
| Summary ready | Later | Only when summary generation moves to a background job. Then it is a real inbox notification. |
| Follow-up nudge ("you mentioned headaches three days ago — how are you now?") | Later, and carefully | Genuinely valuable, and the highest-risk thing in this list. Needs its own opt-out, quiet hours, and the content rule in §10.4. |
| Emergency flag | Never | Promise: we never imply a human is watching. A notification about an emergency the patient already told us about would imply exactly that. |
10.3 The refetch hint, concretely
Publish health_diary.thread.updated with { thread_id } on the recipient's private channel via
Notifications::Realtime.user_channel_for — without creating a Notification row. Inbox
rows are for things a person should see later; this is a cache signal.
Two implementation notes that will otherwise cost a day each:
- Publish to both channels when caregiver and subject differ. The event naturally goes to
the subject's user channel. If a caregiver wrote the message, their own other devices are on a
different channel and will go stale. Publish to both when
acting_user != subject_user. - Do not create a second Ably client.
PatientRealtimeProviderexists precisely because mounting the hook per route was opening and closing a connection on every navigation. A newuseHealthDiaryRealtimehook with its ownnew Ably.Realtime(...)would reintroduce that bug in a different shape. Extend the existing subscription with a second event handler — and this is the moment to renameusePatientAppointmentRealtimetousePatientRealtime, since it is no longer about appointments.
10.4 Rules for any diary notification we do send
- No PHI in the preview. A lock-screen line reading "How is your chest pain today?" is visible to anyone holding the phone. Diary notifications are neutral — "Kulcare · A note from your health diary" — and the content lives behind the deep link.
- Never imply clinical review. No "a doctor has seen your entry", no "we noticed something".
- Deep link into the thread, using the same
?thread=parameter the web uses. - Opt-out must exist before the first one ships. An AI health assistant the patient cannot mute is a support ticket and a trust problem.
10.5 Backend work this implies
| Change | Where | Why |
|---|---|---|
Add health_diary_summary_ready, health_diary_followup to Notification::KINDS | domains/notifications/models/notification.rb | CreateService silently no-ops on an unknown kind — it logs and returns a skipped result. A diary notification with an unlisted kind would vanish without an error. |
Add a health_diary bucket to default preferences | Patient::PreferencesController#default_preferences + preferences.types.ts + the settings UI | No bucket means no opt-out. |
| Publish the thread-updated event | diary send-message service | §10.3 |
10.6 A language gap this surfaced
SUPPORTED_LANGUAGES in patient preferences is ['en','hi','es']. The diary contract requires
en, hi, ml, pa, ta, te, ar — five of which the app cannot express, and one of which (Spanish)
the diary does not support. If the diary sends language from the app's locale preference, five
of the seven promised languages are unreachable from the web client.
Options: expand the preferences allowlist (backend + frontend), or give the diary its own language selector that defaults to the app locale. This affects promise P7 and should be decided before F3.
11. One constraint, one settled question
Audio containers — settled. The contract named AAC/M4A because that is what the React Native
app records, not as a restriction on the endpoint. The endpoint accepts a container allowlist
covering both clients: audio/m4a, audio/mp4, audio/aac from mobile and audio/webm,
audio/ogg (Opus) from browsers, since MediaRecorder in Chrome produces WebM. The validation
exists to reject junk and oversized uploads, not to enforce one codec. Web voice ships in F4 with
no caveat; the transcription provider must accept the browser containers too, which is part of
U9's verification.
Fixtures, now that the backend exists. The UI was built against contract-shaped fixtures before
the backend landed, and they stay as a development and testing tool rather than the only way to run
it. The app has no MSW; its tests mock service modules with vi.mock, and at runtime the service
module returns the fixtures, with an artificial delay, when VITE_HEALTH_DIARY_MOCK=true. Mock
mode is forced off in any production build. The fixtures double as test fixtures, and their failure
scenarios are still the fastest way to reach an error state on screen.
12. Build units
| Unit | Scope | Needs |
|---|---|---|
| F1 | Contract types, service module, query keys, fixture mode, route + sidebar entry, AppLayout contentClassName | — (start now) |
| F2 | Two-pane shell, folder strip, thread list, thread sheet, CRUD | backend U3/U4, or fixtures |
| F3 | Conversation, bubbles, cursor paging, composer, optimistic turn, disclaimer, CTA row | backend U5/U6, or fixtures |
| F4 | Voice capture and upload (WebM/Opus from the browser) | backend U9 |
| F5 | History tab, entry filters, summary card | backend U7/U8 |
| F6 | Diagnostics drawer, caregiver draft guard, state/error tests | F1–F3 |
| F7 | Dashboard card, quick action, refresh keys, optional activity-feed merge | F1, F3 |
| F8 | Realtime refetch hint (extend the existing subscription), preferences bucket | backend §10.5 |
F1 is not blocked on anything and can run alongside backend U1.
13. Accessibility and i18n
- The conversation is a
logregion witharia-live="polite"; new turns are announced once, not per frame. - Every CTA is a real
<button>with a discernible name; the mic has an explicit recording state, not just a colour change. - Full keyboard path: compose → send (Cmd/Ctrl+Enter) → focus moves to the new reply.
prefers-reduced-motiondisables the typing animation.- Arabic is RTL — the conversation pane mirrors when the active language is
ar. The one layout case not already handled anywhere in this app. See also the language gap in §10.6.
14. Tests
Vitest + Testing Library, following Timeline.states.test.tsx:
| Promise | Test |
|---|---|
| P9 | Optimistic turn survives a failed send and offers retry. Every un-sent note survives, not just the newest, and a saved note whose answer failed offers a retry on its own row after a reload. |
| P3 | Disclaimer renders on every assistant turn. |
| P4 | Emergency flag renders the banner above the answer text. |
| P2 | CTA row renders at most three, always includes care options. |
| P8 | Switching caregiver context clears the conversation and discards the draft. |
| P1 | client_message_id is stable across a retry, regenerates after a successful send. |
| — | 429, 503, 400 content_moderated, 422 transcription_failed each render their state. |
| — | Cursor paging requests direction=before with the oldest loaded id. |
| — | Dashboard card renders without calling GET /summary. |