Skip to content

05 Web Application Architecture

Anubha Parashar edited this page Oct 10, 2026 · 1 revision

The HealthBook AI web app (internal code name: BabyCare) is a no-build, framework-free progressive web app in web/. Plain JavaScript files load in a fixed order from index.html, share one global state object, and build on each other by wrapping a small set of global functions. There is no bundler, no transpiler and no third-party browser SDK; the files the browser runs are the files in the repository.

Responsibilities

  • Render every signed-in view (Today, Records, Medicines, Hospital, Vaccinations, Timeline, Lab results, Daily Health, Care Calendar, Lifetime Health, Share, Export, Vitals, profiles) from data returned by the API.
  • Talk to the API through one request() helper that handles both authentication modes, the CSRF header, errors and cancellation.
  • Provide an installable offline shell through a service worker. The worker never caches API responses or patient records.
  • Receive generic Web Push reminders.
  • Serve the separate, login-free doctor page (doctor.html) used by Share With Doctor.

Inputs and outputs

Input Output
window.BABYCARE_CONFIG.apiBase from config.js API origin, and from it the auth mode (cookie or bearer)
JSON from /api/... HTML strings written into #app
User clicks (data-action, data-tab) and form submits execute(action, id, button) / submitForm(form) calls
Push events from the browser push service A notification with fixed generic text
URL fragment #t= on doctor.html, #reset-password= on / Share package view, password reset flow

Script order and module layering

index.html loads config.js synchronously, then these deferred scripts in this order: app.js, features.js, history.js, lifetime.js, charts.js, analytics.js, share.js, care.js, exports.js, profiles.js, password.js. Deferred scripts run in document order, so each file can rely on the globals defined by the files before it.

app.js defines the core: the state object S, NAV and ICONS, small helpers (esc, fmt, el), request(), load(), reload(), render(), layout(), modalHtml(), execute() and submitForm(), plus the document-level click, submit and change listeners that dispatch to them. A later module keeps a reference to the previous function and puts its own in its place:

const dcBaseRender = render;
render = function () { /* handle care/calendar/camera tabs */ dcBaseRender(); };

A module handles the actions, tabs, forms and modals it owns and passes everything else down the chain. Two files work differently. charts.js defines the pure chart library PC (functions that return SVG/HTML strings) and wraps nothing. analytics.js replaces view functions (todayView, resultsView, vaccinesView, episodesView, growthView) rather than the dispatch functions.

flowchart TD
  CFG["config.js<br/>BABYCARE_CONFIG.apiBase"] --> APP["app.js<br/>S, request, load, reload,<br/>render, execute, submitForm, modalHtml"]
  APP --> FEAT["features.js<br/>wraps reload, layout, render, modalHtml, execute, submitForm"]
  FEAT --> HIST["history.js<br/>wraps reload, modalHtml, execute, submitForm"]
  HIST --> LIFE["lifetime.js<br/>wraps modalHtml, execute, submitForm, render, reload"]
  LIFE --> CHARTS["charts.js<br/>defines PC chart library, wraps nothing"]
  CHARTS --> ANA["analytics.js<br/>replaces todayView, resultsView,<br/>vaccinesView, episodesView, growthView"]
  ANA --> SHARE["share.js<br/>wraps render, reload, execute"]
  SHARE --> CARE["care.js<br/>wraps modalHtml, render, reload, execute, submitForm"]
  CARE --> EXP["exports.js<br/>wraps render, execute"]
  EXP --> PROF["profiles.js<br/>wraps render, execute, submitForm"]
  PROF --> PW["password.js<br/>wraps render, execute, submitForm"]
  PW --> DISPATCH["Document listeners in app.js call<br/>the outermost render, execute, submitForm"]
Loading

Because the listeners call the global names when an event fires, they always reach the outermost wrapper (password.js), and the call then works its way down to app.js.

Global state S

S holds the session marker (token), user, patients, the selected profile pid, the active tab, the open modal, and the per-profile collections fetched by reload() (docs, meds, visits, vaccines, observations, reminders, today). Feature modules attach their own namespaces, for example S.dc (Daily Health), S.pv (Vitals), S.pwReset and S.pwForgot. reload() remembers the pid it started with and throws away its results if the user switched profiles while the requests were in flight.

The request() helper

Concern Behaviour
Base URL API_BASE + '/api' + path. API_BASE comes from config.js
Auth mode TOKEN_MODE is true when API_BASE is non-empty. Cross-origin (static site plus a separate API): Authorization: Bearer <token>, token kept in sessionStorage, credentials: 'omit'. Same-origin: an httpOnly cookie, credentials: 'same-origin', and only a babycare_signed_in flag in sessionStorage
CSRF Every request sends X-Requested-With: BabyCare. The API requires this header on cookie-authenticated writes
Caching cache: 'no-store'
401 On any non-/auth/ path: clear the token and flags, re-render the sign-in view, throw "Session expired"
Errors Throws an Error with .detail, .status and .retryAfter (parsed from Retry-After)
Cancellation Accepts signal. An AbortError is re-thrown unchanged so callers can ignore it

In token mode, <img src="/api/..."> cannot carry the bearer header. A MutationObserver (protectImages) fetches those images with the header and swaps in blob: URLs.

Explicit load lifecycle in care.js

care.js (Daily Health, Care Calendar, Today's care, Home camera) uses an explicit request lifecycle held in DCL.

flowchart TD
  NAV["Explicit event<br/>tab click, date change, profile change,<br/>mutation, manual retry"] --> ENS["dcEnsure<br/>decide which resources the current view needs"]
  ENS --> LOAD["dcLoad key, path, apply"]
  LOAD --> PIDCHK{"DCL.pid is not equal to S.pid?"}
  PIDCHK -->|"yes"| RESET["dcReset<br/>abort controllers, clear cache, gen plus 1"]
  PIDCHK -->|"no"| CACHE{"Cached?"}
  RESET --> CACHE
  CACHE -->|"yes"| APPLY["apply cached data to S.dc"]
  CACHE -->|"no"| INFL{"Already in flight?"}
  INFL -->|"yes"| SHARED["return the same promise"]
  INFL -->|"no"| COOL{"429 cooldown active?"}
  COOL -->|"yes"| TIMER["single retry timer, no request"]
  COOL -->|"no"| FETCH["request with AbortController signal"]
  FETCH -->|"ok and gen and pid unchanged"| STORE["cache.set, apply to S.dc"]
  FETCH -->|"429"| SETCOOL["cooldownUntil = Retry-After or 30 s"]
  FETCH -->|"stale or aborted"| DROP["discard response"]
  STORE --> RENDER["render<br/>pure: reads S, writes HTML"]
  APPLY --> RENDER
  SETCOOL --> RENDER
Loading
  • Cache keys are scoped by profile, resource and parameter (for example day:<pid>:<date>, month:..., analytics:...).
  • dcInvalidate(prefixes) runs after mutations (dcRefresh) and on reload(). Nothing else empties the cache.
  • dcReset() runs on logout, on a profile change and when a render finds no token. It aborts every outstanding AbortController, increments gen so late responses are dropped, and clears the cache, the in-flight map, the error and the cooldown timer.
  • When a 429 arrives, dcStatus() shows a quiet card ("The server is busy. Retrying automatically in N s.") with a manual Retry button. One timer calls dcEnsure() again. Nothing retries in a loop.

Why render must be pure

In care.js, render() and every view function (careView, calendarView, cameraView and the rest) only read S and return HTML. They never start a request. Loads are triggered by navigation (a data-tab click listener calls dcEnsure() after app.js has rendered), by actions, by form submits and by reload().

An earlier version did start requests inside views when state looked stale. If a load failed, the state stayed stale, so every re-render triggered another fetch. Under a persistent 429 this became a render, fetch, fail, render loop running at network speed. The loop kept the per-IP rate limit window full and locked the whole client out. The explicit lifecycle above replaced it, and tests/browser/request_storm.spec.js covers it (dashboard, Daily Health, mutation, date change, profile change, logout and mocked 429). Render also snapshots and restores any open dialog's inputs (dcSnapModal / dcRestoreModal), so a background load does not erase text the user has typed.

Service worker (sw.js)

  • On install it precaches the shell (/, the CSS and JS files, /icon.svg) under a versioned cache name. On activate it deletes older caches.
  • On fetch it ignores non-GET requests and any path under /api/, so those go straight to the network. Other GETs are network-first, with the cache as a fallback only when offline.
  • On push it shows title and body from the payload, falling back to the generic sentence "You have a confirmed care reminder. Open HealthBook AI to review it." It uses tag: 'babycare-reminder' and keeps only notification_id as data. notificationclick focuses or opens the app.

enableWebPush() in app.js reads /api/push/web/config, subscribes with the VAPID public key and posts the subscription with consent_generic_push: true. See Notifications and Reminder Architecture.

config.js and the doctor page

config.js is generated by scripts/configure_deployment.py. It sets apiBase (for example https://<api>.onrender.com) only when the page is served from the static hosting hostnames (https://<project>.web.app). A local server keeps same-origin /api and cookie mode.

doctor.html and doctor.js form a separate, login-free page. It reads the share token from the URL fragment (#t=...), which the browser does not send in the page request. It calls /api/share/public/{token}, asks for a PIN on 401 and sends it as X-Share-Pin, and shows clear messages for 404/410 (expired or revoked) and 429 (too many PIN attempts). It loads only config.js, analytics.css and share.css, not the app modules, and sets noindex and no-referrer.

Security boundaries

  • The service worker never caches /api/, and the API sends Cache-Control: no-store on every response.
  • The CSP (script-src 'self') blocks inline and third-party scripts. All HTML built from data goes through esc().
  • Tokens are kept in sessionStorage, never localStorage. In cookie mode, JavaScript cannot read the session cookie.

Clinical safety rules

The web app only displays what the API returns. charts.js draws only values the API returned and infers nothing on the client. Labels keep "Prescribed ≠ Scheduled ≠ Given", "OCR extracted ≠ clinically verified" and "appointment attended ≠ treatment administered" visible in the UI. A browser notification is never proof that a dose was given.

Implementation

web/index.html, web/config.js, web/app.js, web/features.js, web/history.js, web/lifetime.js, web/charts.js, web/analytics.js, web/share.js, web/care.js, web/exports.js, web/profiles.js, web/password.js, web/sw.js, web/doctor.html, web/doctor.js, tests/browser/request_storm.spec.js.

Related pages

System Architecture · Authentication and Session Security · Daily Health and Care Calendar · Share With Doctor Architecture · Notifications and Reminder Architecture · API Design · Testing and Validation

Clone this wiki locally