skills/web-pwa-offline-first/examples/sync.md

28 KiB

Synchronization Patterns

Conflict resolution and synchronization patterns for offline-first applications. See SKILL.md for concepts.


Pattern 18: Last-Write-Wins (LWW) Resolution

The simplest conflict resolution strategy where the most recent change wins.

// sync/last-write-wins.ts

interface SyncableEntity {
  id: string;
  _lastModified: number;
  _serverTimestamp?: number;
  _localVersion: string;
  _serverVersion?: string;
  _syncStatus: "synced" | "pending" | "conflicted";
}

interface ConflictResult<T> {
  resolved: T;
  winner: "local" | "server";
  loser: T;
}

// Named constants
const CLOCK_DRIFT_TOLERANCE_MS = 5000;

/**
 * Resolves conflict using Last-Write-Wins strategy.
 * Server timestamp takes precedence if available, falls back to client timestamps.
 */
function resolveWithLWW<T extends SyncableEntity>(
  local: T,
  server: T,
): ConflictResult<T> {
  // Prefer server timestamps for comparison (more reliable)
  const localTime = local._serverTimestamp ?? local._lastModified;
  const serverTime = server._serverTimestamp ?? server._lastModified;

  // Account for minor clock drift - if within tolerance, server wins
  const timeDiff = Math.abs(localTime - serverTime);
  const localIsNewer = localTime > serverTime + CLOCK_DRIFT_TOLERANCE_MS;

  if (localIsNewer) {
    return {
      resolved: {
        ...local,
        _syncStatus: "synced" as const,
        _serverVersion: server._serverVersion,
      },
      winner: "local",
      loser: server,
    };
  }

  return {
    resolved: {
      ...server,
      _syncStatus: "synced" as const,
      _localVersion: local._localVersion,
    },
    winner: "server",
    loser: local,
  };
}

export { resolveWithLWW };
export type { ConflictResult };

Why good: Simple and predictable, handles clock drift, preserves version info from both sides


Pattern 19: Field-Level Merge

Merge non-conflicting field changes, only conflict on same-field edits.

// sync/field-merge.ts

interface FieldChange {
  field: string;
  localValue: unknown;
  serverValue: unknown;
  localTimestamp: number;
  serverTimestamp: number;
}

interface MergeResult<T> {
  merged: T;
  autoResolved: FieldChange[];
  conflicts: FieldChange[];
}

// Fields that should never be merged (sync metadata)
const SYNC_METADATA_FIELDS = [
  "_syncStatus",
  "_lastModified",
  "_localVersion",
  "_serverVersion",
  "_serverTimestamp",
  "_deletedAt",
  "id",
] as const;

/**
 * Performs field-level merge, identifying conflicts only where
 * both local and server changed the same field.
 */
function mergeFields<T extends Record<string, unknown>>(
  base: T, // Last known common state
  local: T, // Local changes
  server: T, // Server changes
  localTimestamp: number,
  serverTimestamp: number,
): MergeResult<T> {
  const merged = { ...base } as T;
  const autoResolved: FieldChange[] = [];
  const conflicts: FieldChange[] = [];

  const allFields = new Set([...Object.keys(local), ...Object.keys(server)]);

  for (const field of allFields) {
    // Skip sync metadata
    if (
      SYNC_METADATA_FIELDS.includes(
        field as (typeof SYNC_METADATA_FIELDS)[number],
      )
    ) {
      continue;
    }

    const baseValue = base[field];
    const localValue = local[field];
    const serverValue = server[field];

    const localChanged = !deepEqual(baseValue, localValue);
    const serverChanged = !deepEqual(baseValue, serverValue);

    if (!localChanged && !serverChanged) {
      // No changes
      continue;
    }

    if (localChanged && !serverChanged) {
      // Only local changed - use local
      (merged as Record<string, unknown>)[field] = localValue;
      autoResolved.push({
        field,
        localValue,
        serverValue,
        localTimestamp,
        serverTimestamp,
      });
    } else if (!localChanged && serverChanged) {
      // Only server changed - use server
      (merged as Record<string, unknown>)[field] = serverValue;
      autoResolved.push({
        field,
        localValue,
        serverValue,
        localTimestamp,
        serverTimestamp,
      });
    } else if (deepEqual(localValue, serverValue)) {
      // Both changed to same value - no conflict
      (merged as Record<string, unknown>)[field] = localValue;
    } else {
      // True conflict - both changed to different values
      conflicts.push({
        field,
        localValue,
        serverValue,
        localTimestamp,
        serverTimestamp,
      });
      // Default to server for merged result, but flag as conflict
      (merged as Record<string, unknown>)[field] = serverValue;
    }
  }

  return { merged, autoResolved, conflicts };
}

// Simple deep equality check
function deepEqual(a: unknown, b: unknown): boolean {
  if (a === b) return true;
  if (typeof a !== typeof b) return false;
  if (a === null || b === null) return a === b;
  if (typeof a !== "object") return a === b;

  const aObj = a as Record<string, unknown>;
  const bObj = b as Record<string, unknown>;
  const aKeys = Object.keys(aObj);
  const bKeys = Object.keys(bObj);

  if (aKeys.length !== bKeys.length) return false;

  return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
}

export { mergeFields };
export type { FieldChange, MergeResult };

Why good: Only conflicts where both sides changed the same field, preserves non-conflicting changes from both sides, returns structured conflict info for UI


Pattern 20: Conflict Resolution UI

Present conflicts to users and allow manual resolution.

// components/conflict-resolver.tsx
import { useState, type ReactNode } from "react";
import type { FieldChange, MergeResult } from "../sync/field-merge";

interface ConflictResolverProps<T> {
  entityName: string;
  localEntity: T;
  serverEntity: T;
  conflicts: FieldChange[];
  onResolve: (resolved: T) => Promise<void>;
  onCancel: () => void;
  renderField?: (field: string, value: unknown) => ReactNode;
}

type Resolution = "local" | "server";

function ConflictResolver<T extends Record<string, unknown>>({
  entityName,
  localEntity,
  serverEntity,
  conflicts,
  onResolve,
  onCancel,
  renderField = (field, value) => JSON.stringify(value),
}: ConflictResolverProps<T>) {
  const [resolutions, setResolutions] = useState<Map<string, Resolution>>(
    new Map(),
  );
  const [isSubmitting, setIsSubmitting] = useState(false);

  const allResolved = conflicts.every((c) => resolutions.has(c.field));

  const handleFieldResolution = (field: string, choice: Resolution) => {
    setResolutions((prev) => new Map(prev).set(field, choice));
  };

  const handleSubmit = async () => {
    if (!allResolved) return;

    setIsSubmitting(true);
    try {
      // Build resolved entity
      const resolved = { ...serverEntity };
      for (const [field, choice] of resolutions) {
        if (choice === "local") {
          (resolved as Record<string, unknown>)[field] = localEntity[field];
        }
      }

      await onResolve(resolved as T);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div
      role="dialog"
      aria-labelledby="conflict-title"
      aria-describedby="conflict-description"
    >
      <h2 id="conflict-title">Resolve Sync Conflict</h2>
      <p id="conflict-description">
        The {entityName} was modified both locally and on the server. Choose
        which version to keep for each field.
      </p>

      <div role="list" aria-label="Conflicting fields">
        {conflicts.map((conflict) => (
          <div key={conflict.field} role="listitem" data-field={conflict.field}>
            <h3>{formatFieldName(conflict.field)}</h3>

            <fieldset>
              <legend>Choose version for {conflict.field}</legend>

              <label>
                <input
                  type="radio"
                  name={`conflict-${conflict.field}`}
                  value="local"
                  checked={resolutions.get(conflict.field) === "local"}
                  onChange={() =>
                    handleFieldResolution(conflict.field, "local")
                  }
                />
                <span>Your changes</span>
                <span aria-label="Local value">
                  {renderField(conflict.field, conflict.localValue)}
                </span>
                <time
                  dateTime={new Date(conflict.localTimestamp).toISOString()}
                >
                  {formatTimestamp(conflict.localTimestamp)}
                </time>
              </label>

              <label>
                <input
                  type="radio"
                  name={`conflict-${conflict.field}`}
                  value="server"
                  checked={resolutions.get(conflict.field) === "server"}
                  onChange={() =>
                    handleFieldResolution(conflict.field, "server")
                  }
                />
                <span>Server version</span>
                <span aria-label="Server value">
                  {renderField(conflict.field, conflict.serverValue)}
                </span>
                <time
                  dateTime={new Date(conflict.serverTimestamp).toISOString()}
                >
                  {formatTimestamp(conflict.serverTimestamp)}
                </time>
              </label>
            </fieldset>
          </div>
        ))}
      </div>

      <div role="group" aria-label="Actions">
        <button type="button" onClick={onCancel} disabled={isSubmitting}>
          Cancel
        </button>
        <button
          type="button"
          onClick={handleSubmit}
          disabled={!allResolved || isSubmitting}
          aria-describedby={!allResolved ? "resolve-all-hint" : undefined}
        >
          {isSubmitting ? "Saving..." : "Apply Resolution"}
        </button>
        {!allResolved && (
          <span id="resolve-all-hint">
            Please resolve all {conflicts.length - resolutions.size} remaining
            conflicts
          </span>
        )}
      </div>
    </div>
  );
}

function formatFieldName(field: string): string {
  return field
    .replace(/([A-Z])/g, " $1")
    .replace(/^./, (str) => str.toUpperCase())
    .trim();
}

function formatTimestamp(ts: number): string {
  return new Intl.DateTimeFormat(undefined, {
    dateStyle: "short",
    timeStyle: "short",
  }).format(new Date(ts));
}

export { ConflictResolver };
export type { ConflictResolverProps };

Why good: Accessible with proper ARIA, per-field resolution, disabled submit until all resolved, custom field rendering support


Pattern 21: Version Vector for Causality

Track concurrent modifications using version vectors instead of timestamps.

// sync/version-vector.ts

/**
 * A version vector tracks the "logical clock" of each node/client
 * that has modified an entity. Enables detecting true concurrent
 * modifications vs sequential updates.
 */
type VersionVector = Record<string, number>;

interface VersionedEntity {
  id: string;
  _versionVector: VersionVector;
  _nodeId: string; // This client's node ID
}

// Get or create persistent node ID for this client
function getNodeId(): string {
  const STORAGE_KEY = "offline-first-node-id";
  let nodeId = localStorage.getItem(STORAGE_KEY);

  if (!nodeId) {
    nodeId = crypto.randomUUID();
    localStorage.setItem(STORAGE_KEY, nodeId);
  }

  return nodeId;
}

const NODE_ID = getNodeId();

/**
 * Increment the local node's version in the vector
 */
function incrementVersion(vector: VersionVector): VersionVector {
  return {
    ...vector,
    [NODE_ID]: (vector[NODE_ID] ?? 0) + 1,
  };
}

/**
 * Merge two version vectors, taking max of each node's version
 */
function mergeVectors(a: VersionVector, b: VersionVector): VersionVector {
  const merged: VersionVector = { ...a };

  for (const [node, version] of Object.entries(b)) {
    merged[node] = Math.max(merged[node] ?? 0, version);
  }

  return merged;
}

type CompareResult = "before" | "after" | "concurrent" | "equal";

/**
 * Compare two version vectors to determine causality:
 * - 'before': a happened before b
 * - 'after': a happened after b
 * - 'concurrent': a and b are concurrent (conflict)
 * - 'equal': a and b are identical
 */
function compareVectors(a: VersionVector, b: VersionVector): CompareResult {
  let aBeforeB = true;
  let bBeforeA = true;

  const allNodes = new Set([...Object.keys(a), ...Object.keys(b)]);

  for (const node of allNodes) {
    const aVersion = a[node] ?? 0;
    const bVersion = b[node] ?? 0;

    if (aVersion > bVersion) {
      bBeforeA = false;
    }
    if (bVersion > aVersion) {
      aBeforeB = false;
    }
  }

  if (aBeforeB && bBeforeA) {
    return "equal";
  }
  if (aBeforeB) {
    return "before";
  }
  if (bBeforeA) {
    return "after";
  }
  return "concurrent";
}

/**
 * Check if entity needs sync based on version comparison
 */
function needsSync(
  local: VersionedEntity,
  server: VersionedEntity,
): {
  needsUpload: boolean;
  needsDownload: boolean;
  hasConflict: boolean;
} {
  const comparison = compareVectors(
    local._versionVector,
    server._versionVector,
  );

  return {
    needsUpload: comparison === "after",
    needsDownload: comparison === "before",
    hasConflict: comparison === "concurrent",
  };
}

export {
  getNodeId,
  NODE_ID,
  incrementVersion,
  mergeVectors,
  compareVectors,
  needsSync,
};
export type { VersionVector, VersionedEntity, CompareResult };

Why good: Detects true concurrent edits vs sequential updates, no clock synchronization needed, deterministic conflict detection, persistent node ID


Pattern 22: Delta Sync Protocol

Sync only changed items since last sync using server-provided cursors.

// sync/delta-sync.ts

interface SyncCursor {
  collection: string;
  lastSyncTimestamp: number;
  lastServerId?: string; // For pagination
}

interface DeltaSyncResult<T> {
  items: T[];
  hasMore: boolean;
  nextCursor: SyncCursor;
  serverTimestamp: number;
}

// Named constants
const DEFAULT_BATCH_SIZE = 100;
const CURSOR_STORAGE_KEY = "sync-cursors";

class DeltaSyncManager {
  private cursors: Map<string, SyncCursor>;

  constructor() {
    this.cursors = this.loadCursors();
  }

  private loadCursors(): Map<string, SyncCursor> {
    try {
      const stored = localStorage.getItem(CURSOR_STORAGE_KEY);
      if (stored) {
        const parsed = JSON.parse(stored) as Record<string, SyncCursor>;
        return new Map(Object.entries(parsed));
      }
    } catch {
      console.warn("Failed to load sync cursors");
    }
    return new Map();
  }

  private saveCursors(): void {
    const obj = Object.fromEntries(this.cursors);
    localStorage.setItem(CURSOR_STORAGE_KEY, JSON.stringify(obj));
  }

  getCursor(collection: string): SyncCursor {
    return (
      this.cursors.get(collection) ?? {
        collection,
        lastSyncTimestamp: 0,
      }
    );
  }

  updateCursor(cursor: SyncCursor): void {
    this.cursors.set(cursor.collection, cursor);
    this.saveCursors();
  }

  async fetchDelta<T>(
    collection: string,
    fetcher: (
      cursor: SyncCursor,
      batchSize: number,
    ) => Promise<DeltaSyncResult<T>>,
    options: { batchSize?: number } = {},
  ): Promise<T[]> {
    const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
    const allItems: T[] = [];
    let cursor = this.getCursor(collection);

    // Fetch all pages
    while (true) {
      const result = await fetcher(cursor, batchSize);
      allItems.push(...result.items);

      // Update cursor
      this.updateCursor(result.nextCursor);
      cursor = result.nextCursor;

      if (!result.hasMore) {
        break;
      }
    }

    return allItems;
  }

  resetCursor(collection: string): void {
    this.cursors.delete(collection);
    this.saveCursors();
  }
}

// Example API fetcher implementation
async function fetchTodosDelta(
  cursor: SyncCursor,
  batchSize: number,
): Promise<DeltaSyncResult<Todo>> {
  const params = new URLSearchParams({
    since: cursor.lastSyncTimestamp.toString(),
    limit: batchSize.toString(),
    ...(cursor.lastServerId && { after: cursor.lastServerId }),
  });

  const response = await fetch(`/api/todos/sync?${params}`);

  if (!response.ok) {
    throw new Error(`Sync failed: ${response.status}`);
  }

  const data = await response.json();

  return {
    items: data.items,
    hasMore: data.hasMore,
    serverTimestamp: data.serverTimestamp,
    nextCursor: {
      collection: "todos",
      lastSyncTimestamp: data.serverTimestamp,
      lastServerId:
        data.items.length > 0
          ? data.items[data.items.length - 1].id
          : cursor.lastServerId,
    },
  };
}

export { DeltaSyncManager, fetchTodosDelta };
export type { SyncCursor, DeltaSyncResult };

Why good: Only fetches changes since last sync (efficient), paginated for large datasets, cursor persistence survives app restarts


Pattern 23: Background Sync with Service Worker

Queue sync operations for reliable background execution.

IMPORTANT: Browser Support Limitation The Background Sync API is experimental and only supported in Chrome/Edge (Chromium-based browsers). Firefox and Safari do NOT support this API. Always implement a fallback using the online event listener.

Status: Experimental - HTTPS required, Service Worker dependent, specification still under development.

// service-worker/sync-handler.ts
// This code runs in the service worker context

interface SyncTag {
  tag: string;
  collection: string;
}

const SYNC_TAG_PREFIX = "sync-";

// Register for background sync
async function registerBackgroundSync(collection: string): Promise<void> {
  if (!("sync" in self.registration)) {
    console.warn("Background sync not supported");
    return;
  }

  const tag = `${SYNC_TAG_PREFIX}${collection}`;

  try {
    await self.registration.sync.register(tag);
  } catch {
    // Background sync registration failed - fallback will handle sync
  }
}

// Handle sync event in service worker
self.addEventListener("sync", (event: SyncEvent) => {
  if (!event.tag.startsWith(SYNC_TAG_PREFIX)) return;

  const collection = event.tag.slice(SYNC_TAG_PREFIX.length);

  event.waitUntil(
    processSyncQueue(collection).catch((error) => {
      console.error(`Background sync failed for ${collection}:`, error);
      // Return rejected promise to retry
      throw error;
    }),
  );
});

async function processSyncQueue(collection: string): Promise<void> {
  // Open IndexedDB from service worker
  const db = await openDatabase();

  // Get pending operations for this collection
  const operations = await db.getAll("syncQueue");
  const collectionOps = operations.filter((op) => op.collection === collection);

  // Process each operation
  for (const op of collectionOps) {
    try {
      await executeOperation(op);
      await db.delete("syncQueue", op.id);
    } catch (error) {
      // Update retry count
      await db.put("syncQueue", {
        ...op,
        retryCount: op.retryCount + 1,
        lastError: error instanceof Error ? error.message : "Unknown",
      });

      // Re-throw to signal failure to sync manager
      if (op.retryCount >= 4) {
        // Max retries exceeded - leave in queue for manual handling
        continue;
      }
      throw error;
    }
  }
}

async function executeOperation(op: QueuedOperation): Promise<void> {
  const methods: Record<string, string> = {
    CREATE: "POST",
    UPDATE: "PUT",
    DELETE: "DELETE",
    UPSERT: "PUT",
  };

  const response = await fetch(`/api/${op.collection}`, {
    method: methods[op.type],
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(op.data),
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
}

// Export for TypeScript (self.registration.sync types)
declare global {
  interface ServiceWorkerRegistration {
    readonly sync: SyncManager;
  }

  interface SyncManager {
    register(tag: string): Promise<void>;
  }

  interface SyncEvent extends ExtendableEvent {
    readonly tag: string;
  }
}

export { registerBackgroundSync };

Client-Side Registration

// client/register-sync.ts

async function requestSyncWhenOnline(collection: string): Promise<void> {
  if (!navigator.onLine) {
    // Will sync when online via background sync or fallback
    return;
  }

  // If online, trigger immediate sync
  const registration = await navigator.serviceWorker.ready;

  if ("sync" in registration) {
    await registration.sync.register(`sync-${collection}`);
  } else {
    // Fallback: sync immediately without service worker
    await syncCollection(collection);
  }
}

// Fallback for browsers without Background Sync API (Firefox, Safari)
function setupSyncFallback(syncFn: () => Promise<void>): void {
  window.addEventListener("online", async () => {
    try {
      await syncFn();
    } catch (error) {
      console.error("Sync fallback failed:", error);
    }
  });
}

export { requestSyncWhenOnline, setupSyncFallback };

Why good: Sync continues even if app is closed (Chrome/Edge only), browser retries automatically on failure, queue persisted in IndexedDB, graceful fallback for unsupported browsers

Browser Support:

  • Chrome/Edge: Full Background Sync support
  • Firefox/Safari: Must use online event fallback

Pattern 24: Pull-Push Sync Strategy

Full bidirectional sync with server as source of truth for conflicts.

// sync/pull-push-sync.ts

interface SyncResult {
  uploaded: number;
  downloaded: number;
  conflicts: number;
  errors: string[];
}

// Named constants
const SYNC_BATCH_SIZE = 50;

class PullPushSync<T extends SyncableEntity> {
  constructor(
    private readonly localDb: LocalTable<T>,
    private readonly apiEndpoint: string,
    private readonly conflictResolver: ConflictResolver<T>,
  ) {}

  async sync(): Promise<SyncResult> {
    const result: SyncResult = {
      uploaded: 0,
      downloaded: 0,
      conflicts: 0,
      errors: [],
    };

    try {
      // Step 1: Pull - fetch server changes first
      const serverChanges = await this.pull();
      result.downloaded = serverChanges.length;

      // Step 2: Merge - apply server changes, detect conflicts
      const conflicts = await this.merge(serverChanges);
      result.conflicts = conflicts.length;

      // Step 3: Push - upload local changes
      const uploaded = await this.push();
      result.uploaded = uploaded;
    } catch (error) {
      result.errors.push(
        error instanceof Error ? error.message : "Unknown error",
      );
    }

    return result;
  }

  private async pull(): Promise<T[]> {
    // Get last sync timestamp
    const lastSync = await this.getLastSyncTimestamp();

    const response = await fetch(
      `${this.apiEndpoint}/changes?since=${lastSync}`,
    );

    if (!response.ok) {
      throw new Error(`Pull failed: ${response.status}`);
    }

    const data = await response.json();
    return data.items as T[];
  }

  private async merge(serverItems: T[]): Promise<T[]> {
    const conflicts: T[] = [];

    for (const serverItem of serverItems) {
      const localItem = await this.localDb.get(serverItem.id);

      if (!localItem) {
        // New item from server
        await this.localDb.put({
          ...serverItem,
          _syncStatus: "synced",
        });
        continue;
      }

      if (localItem._syncStatus === "synced") {
        // No local changes - just update
        await this.localDb.put({
          ...serverItem,
          _syncStatus: "synced",
        });
        continue;
      }

      // Local has changes - check for conflict
      if (serverItem._serverVersion !== localItem._serverVersion) {
        // True conflict - both changed
        const resolved = await this.conflictResolver.resolve(
          localItem,
          serverItem,
        );

        if (resolved) {
          await this.localDb.put(resolved);
        } else {
          // Mark as conflicted for manual resolution
          await this.localDb.put({
            ...localItem,
            _syncStatus: "conflicted",
          });
          conflicts.push(localItem);
        }
      } else {
        // Server version matches - local changes are newer
        // Keep local, will push in next step
      }
    }

    return conflicts;
  }

  private async push(): Promise<number> {
    const pendingItems = await this.localDb.getByStatus("pending");
    let uploaded = 0;

    // Batch uploads
    for (let i = 0; i < pendingItems.length; i += SYNC_BATCH_SIZE) {
      const batch = pendingItems.slice(i, i + SYNC_BATCH_SIZE);

      const response = await fetch(`${this.apiEndpoint}/batch`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items: batch }),
      });

      if (!response.ok) {
        throw new Error(`Push failed: ${response.status}`);
      }

      const result = await response.json();

      // Update local items with server versions
      for (const item of result.items) {
        await this.localDb.put({
          ...item,
          _syncStatus: "synced",
        });
        uploaded++;
      }
    }

    return uploaded;
  }

  private async getLastSyncTimestamp(): Promise<number> {
    const key = `lastSync:${this.apiEndpoint}`;
    const stored = localStorage.getItem(key);
    return stored ? parseInt(stored, 10) : 0;
  }
}

export { PullPushSync };
export type { SyncResult };

Why good: Full bidirectional sync, batched uploads for efficiency, clear conflict handling, preserves both versions for manual resolution


Pattern 25: Sync Status Indicators

Visual feedback for sync state at both entity and app levels.

// components/sync-status-indicator.tsx

type EntitySyncStatus = "synced" | "pending" | "conflicted" | "error";

interface EntitySyncIndicatorProps {
  status: EntitySyncStatus;
  lastSynced?: number;
  size?: "sm" | "md";
}

function EntitySyncIndicator({
  status,
  lastSynced,
  size = "sm",
}: EntitySyncIndicatorProps) {
  const labels: Record<EntitySyncStatus, string> = {
    synced: "Synced",
    pending: "Saving...",
    conflicted: "Conflict detected",
    error: "Sync failed",
  };

  const icons: Record<EntitySyncStatus, string> = {
    synced: "✓",
    pending: "↻",
    conflicted: "⚠",
    error: "✕",
  };

  return (
    <span
      role="status"
      aria-label={labels[status]}
      data-sync-status={status}
      data-size={size}
      title={
        lastSynced
          ? `Last synced: ${new Date(lastSynced).toLocaleString()}`
          : undefined
      }
    >
      <span aria-hidden="true">{icons[status]}</span>
      {status === "pending" && (
        <span className="sr-only">Saving changes...</span>
      )}
    </span>
  );
}

// App-level sync indicator
interface AppSyncStatusProps {
  isOnline: boolean;
  pendingCount: number;
  lastSyncTime: number | null;
  onForceSync?: () => void;
  isSyncing?: boolean;
}

function AppSyncStatus({
  isOnline,
  pendingCount,
  lastSyncTime,
  onForceSync,
  isSyncing = false,
}: AppSyncStatusProps) {
  return (
    <div role="status" aria-live="polite" data-testid="app-sync-status">
      {!isOnline ? (
        <span data-status="offline">
          <span aria-hidden="true"></span>
          Offline - changes saved locally
        </span>
      ) : pendingCount > 0 ? (
        <span data-status="syncing">
          <span aria-hidden="true"></span>
          {isSyncing ? "Syncing..." : `${pendingCount} changes pending`}
        </span>
      ) : (
        <span data-status="synced">
          <span aria-hidden="true"></span>
          All changes synced
          {lastSyncTime && (
            <time dateTime={new Date(lastSyncTime).toISOString()}>
              {formatRelativeTime(lastSyncTime)}
            </time>
          )}
        </span>
      )}

      {isOnline && pendingCount > 0 && !isSyncing && onForceSync && (
        <button type="button" onClick={onForceSync} aria-label="Sync now">
          Sync now
        </button>
      )}
    </div>
  );
}

const MS_PER_MINUTE = 60_000;
const MS_PER_HOUR = 3_600_000;
const MS_PER_DAY = 86_400_000;
const MINUTES_PER_HOUR = 60;
const HOURS_PER_DAY = 24;

function formatRelativeTime(timestamp: number): string {
  const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
  const diff = timestamp - Date.now();
  const diffMinutes = Math.round(diff / MS_PER_MINUTE);
  const diffHours = Math.round(diff / MS_PER_HOUR);
  const diffDays = Math.round(diff / MS_PER_DAY);

  if (Math.abs(diffMinutes) < MINUTES_PER_HOUR) {
    return rtf.format(diffMinutes, "minute");
  }
  if (Math.abs(diffHours) < HOURS_PER_DAY) {
    return rtf.format(diffHours, "hour");
  }
  return rtf.format(diffDays, "day");
}

export { EntitySyncIndicator, AppSyncStatus };
export type { EntitySyncStatus, EntitySyncIndicatorProps, AppSyncStatusProps };

Why good: Semantic data attributes for styling, accessible labels, relative time formatting, force sync option for users