/** * Offline UX state machine — TASK-0095 R2 (the S12 gate's named unit test: * "the banner count aggregation (the CRDT + outbox sum)" + the R2 states). * * Pure (no React, no `navigator`, no store) — a hook feeds it live inputs, * a component renders its output. The logic (AGENTS rule 23: domain logic * independent from the UI): * * - the BANNER count = the provider's CRDT-pending edits (`localPendingCount`) * + the outbox pending count — ONE number, the sum (the spec's "one * number, the sum"); * - the banner is VISIBLE when `navigator.onLine` is false OR the provider * is `offline` OR the provider has been `reconnecting` for ≥ 5 s (the * blip threshold — a brief blip < 5 s shows NO banner, only the chip * flashing "Reconnecting…"); * - the top-bar CHIP: online → green "Live", reconnecting → amber, offline * → gray "Offline" (the TASK-0092/0093 chip, finalized here) + the a11y * `aria-label="Connection: offline, 12 changes pending"`. * * `crdtPending` is 0 until the Yjs `RealtimeProvider` is wired into the * editor (TASK-0136 R6); today the web app uses the legacy JSON client, so * the banner count is the outbox count. The input is a plain number so the * provider can be slotted in later without touching this logic. */ /** The normalized connection phase the UI renders (3 chip states). */ export type ConnectionPhase = "online" | "reconnecting" | "offline"; /** A brief reconnecting blip (< 5 s) → no banner, the chip flashes. */ export const RECONNECT_BANNER_THRESHOLD_MS = 5_000; export interface OfflineStatusInput { /** `navigator.onLine` (false = the browser is offline). */ readonly navigatorOnline: boolean; /** The (normalized) provider connection phase. */ readonly phase: ConnectionPhase; /** The ms clock at which `phase` was entered (the 5 s blip basis). */ readonly phaseSinceMs: number; /** The current ms clock (injected for determinism). */ readonly nowMs: number; /** The provider's CRDT-pending edits (`localPendingCount`; 0 today). */ readonly crdtPending: number; /** The outbox pending count. */ readonly outboxPending: number; /** The outbox failed count (the "Review failed changes" link). */ readonly outboxFailed: number; } export interface OfflineStatus { /** True → render the banner. */ readonly bannerVisible: boolean; /** The ONE pending number (CRDT + outbox sum) — the banner count. */ readonly pendingCount: number; /** The outbox failed count (the "Review failed changes" link). */ readonly failedCount: number; /** True when the failed list is non-empty (show the review link). */ readonly showReviewLink: boolean; /** The top-bar chip state. */ readonly chip: ConnectionPhase; /** A brief reconnecting blip (< 5 s) — the chip flashes, no banner. */ readonly reconnectFlash: boolean; /** The a11y label ("Connection: offline, 12 changes pending"). */ readonly ariaLabel: string; } /** The chip's display word (the aria-label + the visible text). */ export const PHASE_LABEL: Record = { online: "Live", reconnecting: "Reconnecting…", offline: "Offline", }; /** * Normalize a raw provider status (the legacy JSON client's `RealtimeStatus` * or the Yjs `ConnectionState`) into the 3-phase chip model. */ export function normalizePhase( status: string | null | undefined, ): ConnectionPhase { switch (status) { case "connected": case "syncing": return "online"; case "reconnecting": case "connecting": return "reconnecting"; case "offline": case "closed": case "disconnected": return "offline"; default: // Unknown / absent (not connected yet) → treat as offline (the safe // direction: the banner shows until we have a live connection). return "offline"; } } export function computeOfflineStatus(input: OfflineStatusInput): OfflineStatus { const reconnectingMs = input.phase === "reconnecting" ? Math.max(0, input.nowMs - input.phaseSinceMs) : 0; const reconnectingTooLong = input.phase === "reconnecting" && reconnectingMs >= RECONNECT_BANNER_THRESHOLD_MS; // The banner: the browser is offline, OR the provider is offline, OR the // provider has been reconnecting past the 5 s blip threshold. const bannerVisible = !input.navigatorOnline || input.phase === "offline" || reconnectingTooLong; // The chip: the provider's status (online = green "Live", reconnecting = // amber, offline = gray "Offline") — except a browser-level offline forces // the gray "Offline" (the provider cannot be live without a network). A // LONG reconnect (> 5 s) keeps the amber chip while the banner also shows. const chip: ConnectionPhase = !input.navigatorOnline ? "offline" : input.phase; // The blip flash: the chip is amber (a transient reconnecting — a < 5 s // blip flashes it with no banner; a longer one keeps it amber + banner). const reconnectFlash = chip === "reconnecting"; // The ONE pending number (the spec's "one number, the sum"). const pendingCount = input.crdtPending + input.outboxPending; const failedCount = input.outboxFailed; const showReviewLink = failedCount > 0; const ariaLabel = `Connection: ${PHASE_LABEL[chip].replace("…", "")}, ${pendingCount} changes pending`; return { bannerVisible, pendingCount, failedCount, showReviewLink, chip, reconnectFlash, ariaLabel, }; }