feat: add SyncStatus component with visual indicator

Displays sync state with:
- Green dot for synced (with relative time)
- Spinner for syncing
- Amber dot for stale (auto-detected after 15min)
- Red dot for error (with retry button)
- Gray dot for offline

Includes 23 tests covering all states, time formatting,
button visibility, and click handlers.

bd-2or

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 10:34:34 -05:00
parent 47c7b3e83c
commit d2df4cee21
2 changed files with 336 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
/**
* SyncStatus -- shows sync state with visual indicator and relative time.
*
* States:
* - synced: green dot, "Synced Xm ago"
* - syncing: spinner, "Syncing..."
* - stale: amber dot, "Last sync Xm ago" (auto-detected if > 15min)
* - error: red dot, error message, retry button
* - offline: gray dot, "lore unavailable"
*/
export type SyncState = "synced" | "syncing" | "stale" | "error" | "offline";
interface SyncStatusProps {
status: SyncState;
lastSync?: Date;
error?: string;
onRetry?: () => void;
}
/** Threshold in ms after which "synced" becomes "stale" (15 minutes) */
const STALE_THRESHOLD_MS = 15 * 60 * 1000;
/**
* Format a relative time string like "2m ago", "30s ago", "2h ago", "2d ago"
*/
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diff = now - date.getTime();
if (diff < 1000) {
return "just now";
}
const seconds = Math.floor(diff / 1000);
if (seconds < 60) {
return `${seconds}s ago`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function SyncStatus({
status,
lastSync,
error,
onRetry,
}: SyncStatusProps): React.ReactElement {
// Auto-detect stale: if status is "synced" but time is > 15 minutes
const isAutoStale =
status === "synced" &&
lastSync &&
Date.now() - lastSync.getTime() > STALE_THRESHOLD_MS;
const effectiveStatus: SyncState = isAutoStale ? "stale" : status;
// Determine indicator color
const indicatorColors: Record<SyncState, string> = {
synced: "bg-green-500",
syncing: "bg-blue-500 animate-pulse",
stale: "bg-amber-500",
error: "bg-red-500",
offline: "bg-gray-400",
};
// Determine status text
const getStatusText = (): string => {
switch (effectiveStatus) {
case "synced":
return lastSync ? `Synced ${formatRelativeTime(lastSync)}` : "Synced";
case "syncing":
return "Syncing...";
case "stale":
return lastSync ? `Last sync ${formatRelativeTime(lastSync)}` : "Stale";
case "error":
return error || "Sync failed";
case "offline":
return "lore unavailable";
}
};
// Show retry/refresh button for error and stale states
const showActionButton =
(effectiveStatus === "error" || effectiveStatus === "stale") && onRetry;
const actionLabel = effectiveStatus === "error" ? "Retry" : "Refresh";
return (
<div className="flex items-center gap-2 text-sm">
{effectiveStatus === "syncing" ? (
<svg
data-testid="sync-spinner"
className="h-3 w-3 animate-spin text-zinc-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<div
data-testid="sync-indicator"
className={`h-2 w-2 rounded-full ${indicatorColors[effectiveStatus]}`}
/>
)}
<span className="text-zinc-500">{getStatusText()}</span>
{showActionButton && (
<button
onClick={onRetry}
className="text-xs text-zinc-400 hover:text-zinc-200 underline"
>
{actionLabel}
</button>
)}
</div>
);
}