sync-outcome.ts

4.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/** Outcomes where sync did not do what the user asked, so the UI shows red. */23const FAILURES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([24  "no_eligible_host",25  "runner_unavailable",26  "private_relay_unset",27  "vault_locked",28  "relay_unreachable",29  "handshake_failed",30  "vault_id_mismatch",31  "sync_timeout",32  "run_failed",33]);3435export function isSyncFailure(outcome: SyncOutcome): boolean {36  return FAILURES.has(outcome);37}3839export interface SyncOutcomeSummary {40  status?: "idle" | "syncing" | "error";41  outcome?: SyncOutcome;42  entriesSent?: number;43  entriesReceived?: number;44  detail?: string;45  /** When the last attempt ended, success or not. */46  finishedAt?: number;47}4849/**50 * The one indicator light. `busy` pulses green, `ok` is solid green, `error`51 * is red, `idle` is unlit. Nothing that is actually broken may resolve to52 * `ok` — a paired-but-unsyncable device is red, not green.53 */54export type SyncIndicator = "busy" | "ok" | "error" | "idle";5556export function syncIndicator(s: SyncOutcomeSummary, pairedCount: number): SyncIndicator {57  if (s.status === "syncing") return "busy";58  if (s.outcome && isSyncFailure(s.outcome)) return "error";59  if (s.outcome === "completed_changes" || s.outcome === "completed_no_changes") {60    return "ok";61  }62  // Paired with a healthy history but no attempt since this SW woke: still a63  // successful pair, which is what the light is claiming.64  if (pairedCount > 0 && s.outcome === undefined) return "ok";65  return "idle";66}6768const INDICATOR_LABEL: Record<SyncIndicator, string> = {69  busy: "Syncing",70  ok: "Sync healthy",71  error: "Sync problem",72  idle: "Not syncing",73};7475/** Colour alone is never the signal — this is the same claim in words. */76export function syncIndicatorLabel(77  indicator: SyncIndicator,78  s: SyncOutcomeSummary,79): string {80  return `${INDICATOR_LABEL[indicator]} — ${describeSyncOutcome(s)}`;81}8283const COPY: Record<SyncOutcome, string> = {84  completed_changes: "Synced",85  completed_no_changes: "Up to date — nothing to send or receive",86  no_paired_devices: "No devices paired yet",87  no_eligible_host: "Paired, but no device here can accept a sync",88  runner_unavailable: "Sync isn't running in the background — reload Peach",89  sync_disabled: "Sync is switched off",90  private_relay_unset: "Private relay chosen but no address set",91  vault_locked: "Vault was locked — unlock and sync again",92  relay_unreachable: "Couldn't reach the relay",93  handshake_failed: "Reached the relay, but the device didn't answer",94  vault_id_mismatch: "That device holds a different vault",95  sync_timeout: "The device stopped responding mid-sync",96  run_failed: "Sync failed",97};9899/**100 * One plain-English line for a sync outcome. `detail` — the raw internal code101 * or the device inventory behind `no_eligible_host` — is appended rather than102 * hidden: it is the whole diagnostic on a real device, where devtools is not103 * an option.104 */105export function describeSyncOutcome(s: SyncOutcomeSummary): string {106  if (!s.outcome) return "Not synced yet";107  const base = COPY[s.outcome] ?? "Sync failed";108  if (s.outcome === "completed_changes") {109    const moved = [110      (s.entriesSent ?? 0) > 0 ? `${s.entriesSent} sent` : null,111      (s.entriesReceived ?? 0) > 0 ? `${s.entriesReceived} received` : null,112    ].filter(Boolean);113    return moved.length > 0 ? `${base} — ${moved.join(", ")}` : base;114  }115  return s.detail ? `${base} (${s.detail})` : base;116}