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:
141
src/components/SyncStatus.tsx
Normal file
141
src/components/SyncStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user