1/** 2 * Offline UX state machine — TASK-0095 R2 (the S12 gate's named unit test: 3 * "the banner count aggregation (the CRDT + outbox sum)" + the R2 states). 4 * 5 * Pure (no React, no `navigator`, no store) — a hook feeds it live inputs, 6 * a component renders its output. The logic (AGENTS rule 23: domain logic 7 * independent from the UI): 8 * 9 * - the BANNER count = the provider's CRDT-pending edits (`localPendingCount`) 10 * + the outbox pending count — ONE number, the sum (the spec's "one 11 * number, the sum"); 12 * - the banner is VISIBLE when `navigator.onLine` is false OR the provider 13 * is `offline` OR the provider has been `reconnecting` for ≥ 5 s (the 14 * blip threshold — a brief blip < 5 s shows NO banner, only the chip 15 * flashing "Reconnecting…"); 16 * - the top-bar CHIP: online → green "Live", reconnecting → amber, offline 17 * → gray "Offline" (the TASK-0092/0093 chip, finalized here) + the a11y 18 * `aria-label="Connection: offline, 12 changes pending"`. 19 * 20 * `crdtPending` is 0 until the Yjs `RealtimeProvider` is wired into the 21 * editor (TASK-0136 R6); today the web app uses the legacy JSON client, so 22 * the banner count is the outbox count. The input is a plain number so the 23 * provider can be slotted in later without touching this logic. 24 */ 25 26/** The normalized connection phase the UI renders (3 chip states). */ 27export type ConnectionPhase = "online" | "reconnecting" | "offline"; 28 29/** A brief reconnecting blip (< 5 s) → no banner, the chip flashes. */ 30export const RECONNECT_BANNER_THRESHOLD_MS = 5_000; 31 32export interface OfflineStatusInput { 33 /** `navigator.onLine` (false = the browser is offline). */ 34 readonly navigatorOnline: boolean; 35 /** The (normalized) provider connection phase. */ 36 readonly phase: ConnectionPhase; 37 /** The ms clock at which `phase` was entered (the 5 s blip basis). */ 38 readonly phaseSinceMs: number; 39 /** The current ms clock (injected for determinism). */ 40 readonly nowMs: number; 41 /** The provider's CRDT-pending edits (`localPendingCount`; 0 today). */ 42 readonly crdtPending: number; 43 /** The outbox pending count. */ 44 readonly outboxPending: number; 45 /** The outbox failed count (the "Review failed changes" link). */ 46 readonly outboxFailed: number; 47} 48 49export interface OfflineStatus { 50 /** True → render the banner. */ 51 readonly bannerVisible: boolean; 52 /** The ONE pending number (CRDT + outbox sum) — the banner count. */ 53 readonly pendingCount: number; 54 /** The outbox failed count (the "Review failed changes" link). */ 55 readonly failedCount: number; 56 /** True when the failed list is non-empty (show the review link). */ 57 readonly showReviewLink: boolean; 58 /** The top-bar chip state. */ 59 readonly chip: ConnectionPhase; 60 /** A brief reconnecting blip (< 5 s) — the chip flashes, no banner. */ 61 readonly reconnectFlash: boolean; 62 /** The a11y label ("Connection: offline, 12 changes pending"). */ 63 readonly ariaLabel: string; 64} 65 66/** The chip's display word (the aria-label + the visible text). */ 67export const PHASE_LABEL: Record<ConnectionPhase, string> = { 68 online: "Live", 69 reconnecting: "Reconnecting…", 70 offline: "Offline", 71}; 72 73/** 74 * Normalize a raw provider status (the legacy JSON client's `RealtimeStatus` 75 * or the Yjs `ConnectionState`) into the 3-phase chip model. 76 */ 77export function normalizePhase( 78 status: string | null | undefined, 79): ConnectionPhase { 80 switch (status) { 81 case "connected": 82 case "syncing": 83 return "online"; 84 case "reconnecting": 85 case "connecting": 86 return "reconnecting"; 87 case "offline": 88 case "closed": 89 case "disconnected": 90 return "offline"; 91 default: 92 // Unknown / absent (not connected yet) → treat as offline (the safe 93 // direction: the banner shows until we have a live connection). 94 return "offline"; 95 } 96} 97 98export function computeOfflineStatus(input: OfflineStatusInput): OfflineStatus { 99 const reconnectingMs = 100 input.phase === "reconnecting" ? Math.max(0, input.nowMs - input.phaseSinceMs) : 0; 101 const reconnectingTooLong = 102 input.phase === "reconnecting" && reconnectingMs >= RECONNECT_BANNER_THRESHOLD_MS; 103 104 // The banner: the browser is offline, OR the provider is offline, OR the 105 // provider has been reconnecting past the 5 s blip threshold. 106 const bannerVisible = 107 !input.navigatorOnline || input.phase === "offline" || reconnectingTooLong; 108 109 // The chip: the provider's status (online = green "Live", reconnecting = 110 // amber, offline = gray "Offline") — except a browser-level offline forces 111 // the gray "Offline" (the provider cannot be live without a network). A 112 // LONG reconnect (> 5 s) keeps the amber chip while the banner also shows. 113 const chip: ConnectionPhase = !input.navigatorOnline ? "offline" : input.phase; 114 115 // The blip flash: the chip is amber (a transient reconnecting — a < 5 s 116 // blip flashes it with no banner; a longer one keeps it amber + banner). 117 const reconnectFlash = chip === "reconnecting"; 118 119 // The ONE pending number (the spec's "one number, the sum"). 120 const pendingCount = input.crdtPending + input.outboxPending; 121 const failedCount = input.outboxFailed; 122 const showReviewLink = failedCount > 0; 123 124 const ariaLabel = `Connection: ${PHASE_LABEL[chip].replace("…", "")}, ${pendingCount} changes pending`; 125 126 return { 127 bannerVisible, 128 pendingCount, 129 failedCount, 130 showReviewLink, 131 chip, 132 reconnectFlash, 133 ariaLabel, 134 }; 135} 136