Skip to content

Web App

Ankit Upadhyay edited this page Aug 13, 2026 · 1 revision

Web app

This page maps the staff EMR application: every route, how the shell and command palette work, and exactly what mock mode does. It is for anyone working in apps/web.

The 26-route application described here is on the feat/emr-app branch and has not merged to dev. On dev, apps/web is still the landing skeleton with pillar cards.

Shape

Next.js 16 App Router, React 19. Every route follows the same two-file pattern:

  • page.tsx is a server component that owns metadata and nothing else.
  • <Name>Screen.tsx is a 'use client' component that owns the screen.

The split exists because @openrunic/ui components use React state, which the server-component condition does not provide. Route files contain no logic beyond awaiting params.

There are no route handlers, no middleware.ts, and no route groups. layout.tsx exists only at the root.

The 26 routes

Front desk and scheduling

Route What it does
/ Public landing page. Three pillar cards and a compliance footer. The only route that does not use the app shell.
/schedule The day view. Provider columns with a live "now" rule, a day pager, a right rail, an available-slot finder, a booking modal, and check-in.
/schedule/flow-board The patient flow board. Five status columns with counts, two clocks per patient, one-click status advance, room assignment, and filters for provider, room, and delayed-only.
/patients Patient search. One dominant input searching given, family, preferred name, and MRN, plus four saved views phrased as questions. The zero-result state offers registration.
/patients/new Registration. Four required fields. Duplicate detection runs while the name is typed, and a strong match blocks the save until the user states an override rather than clicking through a second time.
/patients/[id]/insurance Coverage and eligibility. One card per coverage in priority order, reordered with buttons rather than drag. Eligibility verification has four designed outcomes including a payer outage, each with its own tone.

Chart and documentation

Route What it does
/patients/[id] Chart home. A persistent, undismissable patient context rail, six tabs each carrying a count, and actions to print the summary or open the visit note.
/encounters/[id] The visit workspace. A block-editor note with slash commands, a signature block, content hashing, and draft handling, with the chart rail always present.
/inbox The typed inbox. Five streams, an assignment filter, and rows that finish their disposition inline. Every row carries an SLA state; the list sorts overdue first, then due soonest.

Orders and results

Route What it does
/orders The order ledger. Eight columns, a status filter, order age, and a flag for a transmitted-but-unacknowledged order with a retry.
/orders/new The order composer, the largest screen in the app. The catalogue is ranked against the patient's problems before typing starts, the picker never loses focus, and warnings are tiered so a critical alert holds the signature until a reason is given. Pend and sign are separate buttons.
/results The results sign-off queue, abnormal first. Values are rendered against reference ranges in words. Bulk signing is available, and critical values are excluded from the batch.

Billing

Route What it does
/billing The front door. Four headline numbers, links to the five workbenches, and an aged-balance card. It holds no state and offers no verbs of its own.
/billing/charges The fee sheet. Procedure panels stay on screen, diagnosis justification uses pointer letters, and the scrub panel prevents marking a sheet ready with an unjustified line. Removed lines are struck through and restorable.
/billing/claims The claim workbench, with a detail drawer. Status chips are the primary navigation. Rows blocked by the scrubber are unselectable and say why. There are no files anywhere; acknowledgements are events in the claim's own history.
/billing/remittance Remittance posting. Auto-post is the default, and non-matching lines are lifted into an exception queue above the ledger with their disposition in the row. No file upload anywhere.
/billing/statements Statements and ageing. Ageing buckets, dunning stage, text-to-pay and card-on-file. The preview drawer is mandatory before a run, because the escalation wording is only visible there.
/billing/payments Checkout and allocation on one desk. The remainder is the most prominent number, and the payment button stays disabled until the allocation balances.

Reports and administration

Route What it does
/reports A practice dashboard plus a report shell. Stat tiles tint only when a threshold is breached, and every number links into the workbench that owns it. Below is a filterable visit report with pinned totals and CSV export.
/admin The admin hub. A list of six cards, each heading a real link. Not a dashboard.
/admin/users Users and roles. A permission matrix over roles and capabilities, plain-language role summaries, and per-user grants shown as labelled exceptions. Nobody is deleted; the confirm dialog deactivates.
/admin/facilities Facilities. Identity, billing attributes such as place-of-service code and NPI, the hours grid the slot engine reads, and the rooms the flow board reads, all on one screen.
/admin/forms The form builder. Three panes at wide widths, preview always one toggle away, published versions immutable and saying so. See Form engine.
/admin/audit The audit viewer. Deliberately has no edit control of any kind. Filters by action, actor, purpose of use, and date; the detail drawer renders the hash chain so tamper evidence is visible. CSV export.
/admin/integrations Partner seams: prescribing, clearinghouse, labs, payments, fax, text, and video. A banner lists broken seams, test-connection is a first-class action, and credentials appear as a secret reference and never as a value.
/admin/developer The developer platform in three tabs: API keys shown once and revocable rather than deletable, SMART on FHIR apps with launch history, and webhook subscriptions with per-delivery outcomes written as sentences.

The shell

apps/web/src/components/shell/AppShell.tsx composes every screen except the landing page:

CommandProvider
└── SideNav (rail from lg, off-canvas drawer below, focus-trapped)
    └── TopBar (area name, facility tag, Demo data badge, command trigger, user)
        └── main#main-content
            ├── breadcrumb, h1, description, actions, content
            └── aside (page context rail)
    └── CommandPalette

The root layout.tsx renders a skip link and imports @openrunic/ui/styles.css before ./globals.css. That order is load-bearing: the app layer must win ties against the library.

Navigation is one flat group, not nested. navigation.ts declares eight primary areas in workflow order rather than alphabetically: Schedule, Flow Board, Patients, Inbox, Orders, Billing, Reports, Admin. Thirteen further routes are reachable through the palette but have no rail row. The active area is chosen by longest matching href prefix, so /patients/<id> still lights Patients.

The command palette

Opened with Cmd-K or Ctrl-K, from a single window keydown listener that toggles rather than only opening. There is also a real labelled button in the top bar, so the feature is discoverable without knowing the shortcut.

It is a real <dialog> containing an ARIA combobox. Focus never leaves the input; the highlighted row is published through aria-activedescendant. Arrow keys wrap, Home and End jump, Enter runs, Escape closes, Tab is trapped, and focus returns to the trigger on close.

Three groups, in this order:

Patients. Live search with a 150 ms debounce, six results, each showing MRN and date of birth as the hint.

Go to. The 21 navigation targets, each with synonym keywords so a partial or misspelled query still lands.

Actions. Registered per screen and live only while that screen is mounted. Examples include finding an available slot, walking in a patient, jumping to a chart tab, printing a summary, and opening the chart from a note.

Matching is deterministic and tiered: prefix, then word start, then contains, then subsequence, stable by registration order within a tier. There is no fuzzy scoring that reorders results between keystrokes.

Screens register their commands through a one-line ScreenCommands component rendered inside AppShell, because the provider lives inside the shell and calling the hook in the screen body would throw.

Mock mode

Two environment variables, both NEXT_PUBLIC_, read in apps/web/src/lib/api/config.ts:

Variable Default Effect
NEXT_PUBLIC_API_MODE mock Only the literal string live switches. Anything else, including unset, is mock.
NEXT_PUBLIC_API_BASE_URL http://localhost:4000 Base URL when live.

Mode is resolved once at module load. There is no runtime toggle.

The mock client is not a stub. It satisfies the same client interface as the HTTP client and applies the same filters, sorts, and pagination the API applies: the same search fields, the same sort keys, the same default and maximum page sizes, and the same totalPages floor of one. It fails the same way too, rejecting a miss with an error carrying a real RFC 9457 problem document.

Two details worth knowing. It adds 140 ms of artificial latency outside tests, so loading states are actually visible in a browser and instant in a suite. And it caches a lowercased search haystack per patient in a WeakMap, because the palette searches on every keystroke.

Writes are deliberately not implemented in the mock layer. The stated reason is that a fixture which accepts writes teaches screens to trust state the server never saw. Every screen that appears to write holds the change in local component state and says so, usually with a toast and an undo.

Mock mode is surfaced rather than hidden: a persistent "Demo data" badge in the top bar, a caption on the schedule screen, a fixed clock so screenshots are reproducible, and fixture-backed browser tab titles.

What live mode actually changes

Only two of the five data clients switch. api (patients and appointments) and chartApi (chart summary and notes) become HTTP clients. adminApi, worklist (orders, results, inbox), and billing are hardcoded to fixtures with no live branch at all, because the API answers 501 for those aggregates.

Authentication is not wired: getToken returns null, so no Authorization header is sent and the API answers 401. The screens render that honestly through their error state rather than falling back to fixtures.

Theming

Tokens come from @openrunic/ui. globals.css is the app layer, sectioned by comment banner per feature area, and its stated policy is no hex values, no off-scale spacing, and no invented shadows.

It adds only a handful of root values: widened font fallback stacks and shell geometry (gutter, rail width, top bar height). One documented stopgap exists, a caution status colour built with color-mix, flagged in a comment as a proposed library token because the library ships three status tiers and this screen needed four.

There is no dark mode. No prefers-color-scheme handling, no data-theme, no theme provider. The theme colour is hardcoded to bone.

Fonts and brand marks are self-hosted by policy and are not vendored into the repository. Without the binaries present, the fallback stack carries the interface and only the optical-size axis is lost.

Tests and configuration

vitest.config.mts uses the istanbul coverage provider and deliberately omits @vitejs/plugin-react. The plugin's second transform pass double-instruments files under istanbul and roughly halves reported coverage; the config uses esbuild's automatic JSX runtime instead. There are no thresholds in the config, because CI enforces floors on the merged coverage map rather than per shard. See Testing strategy.

next.config.ts sets poweredByHeader: false, disables the agent-rules file generation, and disables typed routes with a written justification: type-check runs before build in CI, and the palette pushes hrefs built at runtime.

What is incomplete

  1. All writes are session-local. No screen persists anything.
  2. Three of five data clients have no live path. Setting live mode does not change admin, reports, orders, results, inbox, or billing.
  3. Authentication is not wired. The user name in the top bar is a hardcoded default.
  4. Rail badge counts are declared but not passed through. The library item type supports a badge; the shell maps only label and icon.
  5. /patients/[id]/insurance is not registered in the palette, unlike every other route.
  6. The command palette forks focus handling into the app rather than using a library primitive, with an in-file note proposing a dialog and combobox pair for @openrunic/ui.
  7. apps/web/README.md is stale. It describes the app as route skeletons, which the code contradicts.

Related pages

Clone this wiki locally