4 · Web app
apps/web: a static single-page app built with Vite, React and TypeScript, served by
the server as files, installable as a PWA. View web in the
diagrams.
Layout
apps/web/src/
app/ router, layout shell, auth gate, theme, error boundary. Knows no domain.
replica/ store (applies changes with domain's apply), snapshot loader, feed client, IndexedDB persistence, sync status
commands/ one file per mutation: predict → call api-client → reconcile / roll back
features/ one folder per screen family: schedule, agenda, proposals, voting, people, meetings, notifications, settings, admin, venue
ui/ design system: tokens, primitives, form fields, modal stack, toasts
pwa/ service worker, install prompt, push subscription, kiosk keep-awake
i18n/ catalogs and formatting helpers that wrap domain/time
Rules: features read from replica and domain and call commands; they never
call api-client directly. replica and commands know nothing about React
components. app imports features; nothing imports app.
The replica
One event at a time. Shape mirrors the snapshot contract:
type Replica = {
seq: number;
event: Event;
days: Map<Id, Day>;
places: Map<Id, Place>;
sessions: Map<Id, Session>;
rsvps: Map<Id, Rsvp>;
proposals: Map<Id, ProposalPublic>;
people: Map<Id, ProfilePublic>;
me: {
participation;
votes: Map;
marks: Map;
meetings: Map;
notifications: Map;
availability;
preferences;
};
sync: {
state: "live" | "reconnecting" | "offline" | "stale";
lastSyncedAt: Instant;
};
};
- Loading:
GET snapshot→ store →GET feed?since=seq. If a persisted replica exists, render it first withsync.state = 'stale', then resume the feed from itsseq; the snapshot is only refetched on410 Gone. apply(replica, change)is a pure reducer indomain(it is also how the server’s tests check that a change payload is sufficient). It upserts the subject from the payload, deletes on*Deleted, and bumpsseq. Changes withseq ≤ replica.seqare ignored, which makes replay after a reconnect safe.- Persistence: the replica is written to IndexedDB (debounced) keyed by
(event, person); cleared on logout. A different person on the same browser never sees a previous person’s replica. - The store is a small reactive store (Zustand-style) with memoized selectors;
derived views (
agenda,grid,shiftsBelowMinimum, filters) come fromdomainand are memoized per input identity. Components subscribe to selectors, so a change to one session re-renders the blocks that show it, not the grid.
This answers the reservation in ADR 0006 that a stale schedule is worse than none: the replica is never presented as current unless the feed is live, and once it is, staleness lasts as long as the reconnect.
Commands and optimistic updates
export const rsvp = command({
predict: (replica, actor, input) => {
const verdict = policies.scheduling.canRsvp(actor, input, contextFrom(replica), clock);
return verdict.allow ? [changes.RsvpAdded.predict(input, actor)] : deny(verdict);
},
call: (api, input, key) => api.POST('/events/{slug}/sessions/{id}/rsvp', { ..., headers: { 'Idempotency-Key': key } }),
});
- Predict with the same policy the server will run. A denial disables the control and shows the reason code translated; nothing is sent.
- Apply the predicted change locally, keyed by a client-generated
idempotency key and flagged
pending. The UI shows it at once. - Call the API with that key. The confirmed change carries the key
(
command_key), so whichever arrives first, the feed message or the response,applyreplaces the pending change with the real one. On a network error the command retries with the same key; on a4xxit removes the prediction and shows the problem’s code. - Only
commands/may write to the replica outsideapply. There is exactly one optimistic mechanism, used by every mutation, which is what ends the class of bugs in #463.
Edits carry expectedVersion; a 409 shows “changed by someone else” with the
current values.
Screens as functions of the replica
Every feature renders from selectors plus local UI state (open modal, filter, scroll). Notable consequences:
- Schedule grid and agenda are two projections of the same
sessions,rsvps,marks,meetings. The clash view attendees asked for isagenda().clashGroups; the unfilled shifts list isshiftsBelowMinimum(); filters (mine, starred, not hidden, place, tag, text) are pure and instant. No endpoint is added for any of them. - The grid on a phone uses a column-count strategy (visible places chosen by the viewer, saved as a preference) and wraps titles, instead of a fixed column width; a “now” strip shows what is running across all places at this minute.
- Modals are routes (
?session={id}as a search param over the current screen), so back returns to the previous modal and deep links open the right screen behind them (#856, #994). - Venue: a place popover shows floor and directions; the venue screen shows the map with pins; a session block’s place chip opens the popover.
- Kiosk is
?kiosk=1on the schedule: the same replica, auto-scroll to now, keep-awake, a place filter. It never needs a page refresh because the feed keeps it current. - Admin is a section of the same app, shown when the actor has an organizer or site-admin role. It uses the same commands.
PWA
- Service worker precaches the shell (hashed assets) and serves it offline; API responses are never cached by the worker, because the replica already is the cache and knows its own staleness.
- Push: one subscription per device, bound to the person, listed in settings. A push carries only a notification id; the app fetches the record when opened, so push payloads hold no content.
- An update check compares the served version with the running one and offers a reload (#788).
UI foundations
- Design tokens (surface, fg, line, brand, semantic, chrome) as today, contrast- tested in both themes; state never encoded by colour alone.
- Every control has an accessible name; E2E tests locate by role and name.
- Time is formatted only through
i18n/format, which wrapsdomain/timewith the person’s zone, locale and 12/24-hour preference. - Forms validate against the same zod contract the server uses, per field, with the server’s problem details mapped back onto fields (#639).