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

1123 lines
28 KiB
Markdown

# Synchronization Patterns
> Conflict resolution and synchronization patterns for offline-first applications. See [SKILL.md](../SKILL.md) for concepts.
---
## Pattern 18: Last-Write-Wins (LWW) Resolution
The simplest conflict resolution strategy where the most recent change wins.
```typescript
// 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.
```typescript
// 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.
```tsx
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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
```typescript
// 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.
```typescript
// 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.
```tsx
// 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