sync-outcome.ts

5.0 KB · raw

1// Why a sync run ended, in a form both the service worker and the popup can2// name. Before this existed, "no eligible host", "runner not wired" and3// "synced fine, nothing to do" were all a bare `return` — indistinguishable4// to the user and to us. A failed sync and a successful no-op must never5// look the same again.67export type SyncOutcome =8  | "completed_changes"9  | "completed_no_changes"10  | "no_paired_devices"11  | "no_eligible_host"12  | "runner_unavailable"13  | "sync_disabled"14  | "private_relay_unset"15  | "vault_locked"16  | "relay_unreachable"17  | "handshake_failed"18  | "vault_id_mismatch"19  | "sync_timeout"20  | "run_failed";2122/**23 * Outcomes where sync did not do what the user asked, so the UI shows red.24 *25 * Network-class failures (relay/handshake/timeout) are NOT in this set —26 * with a 10-minute background alarm the normal state for most minutes is27 * "the relay didn't answer this time". That is the network, not the app,28 * and the next alarm tick will retry. They live in `WARNINGS` so the29 * indicator goes yellow instead of red.30 */31const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([32  "no_eligible_host",33  "runner_unavailable",34  "private_relay_unset",35  "vault_locked",36  "vault_id_mismatch",37  "run_failed",38]);3940/**41 * Network-class outcomes — the dial did not reach the peer this attempt.42 * The alarm keeps trying, so this is warn (yellow), not error (red).43 */44const WARNINGS: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([45  "relay_unreachable",46  "handshake_failed",47  "sync_timeout",48]);4950export function isSyncFailure(outcome: SyncOutcome): boolean {51  return FAILURES.has(outcome);52}5354export function isSyncWarning(outcome: SyncOutcome): boolean {55  return WARNINGS.has(outcome);56}5758export interface SyncOutcomeSummary {59  status?: "idle" | "syncing" | "error";60  outcome?: SyncOutcome;61  entriesSent?: number;62  entriesReceived?: number;63  detail?: string;64  /** When the last attempt ended, success or not. */65  finishedAt?: number;66}6768/**69 * The one indicator light. `busy` pulses green, `ok` is solid green, `warn`70 * is yellow (a transient network miss — the next alarm tick retries),71 * `error` is red (the app is actually broken), `idle` is unlit. Nothing72 * that is actually broken may resolve to `ok` — a paired-but-unsyncable73 * device is red, not green. A dial that didn't reach the peer is yellow,74 * because the alarm will try again.75 */76export type SyncIndicator = "busy" | "ok" | "warn" | "error" | "idle";7778export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {79  if (s.status === "syncing") return "busy";80  if (s.outcome && isSyncFailure(s.outcome)) return "error";81  if (s.outcome && isSyncWarning(s.outcome)) return "warn";82  if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {83    return "ok";84  }85  // Paired with a healthy history but no attempt since this SW woke: still a86  // successful pair, which is what the light is claiming.87  if (pairedCount > 0 && s.outcome === undefined) return "ok";88  return "idle";89}9091const INDICATOR_LABEL: Record<SyncIndicator, string> = {92  busy: "Syncing",93  ok: "Sync healthy",94  warn: "Last sync attempt missed — will retry",95  error: "Sync problem",96  idle: "Not syncing",97};9899/** Colour alone is never the signal — this is the same claim in words. */100export function syncIndicatorLabel(101  indicator: SyncIndicator,102  s: SyncOutcomeSummary,103): string {104  return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`;105}106107const COPY: Record<SyncOutcome, string> = {108  completed_changes: "Synced",109  completed_no_changes: "Up to date — nothing to send or receive",110  no_paired_devices: "No devices paired yet",111  no_eligible_host: "Paired, but no device here can accept a sync",112  runner_unavailable: "Sync isn't running in the background — reload Peach",113  sync_disabled: "Sync is switched off",114  private_relay_unset: "Private relay chosen but no address set",115  vault_locked: "Vault was locked — unlock and sync again",116  relay_unreachable: "Couldn't reach the relay",117  handshake_failed: "Reached the relay, but the device didn't answer",118  vault_id_mismatch: "That device holds a different vault",119  sync_timeout: "The device stopped responding mid-sync",120  run_failed: "Sync failed",121};122123/**124 * One plain-English line for a sync outcome. `detail` — the raw internal code125 * or the device inventory behind `no_eligible_host` — is appended rather than126 * hidden: it is the whole diagnostic on a real device, where devtools is not127 * an option.128 */129export function describeSyncOutcome(s: SyncOutcomeSummary): string {130  if (!s.outcome) return "Not synced yet";131  const base = COPY[s.outcome] ?? "Sync failed";132  if (s.outcome === "completed_changes") {133    const moved = [134      (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null,135      (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null,136    ].filter(Boolean);137    return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base;138  }139  return s.detail ? `${base} (${s.detail})` : base;140}