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>
);
}

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SyncStatus } from "@/components/SyncStatus";
describe("SyncStatus", () => {
beforeEach(() => {
// Mock Date.now for consistent time-based tests
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-26T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("synced state", () => {
it("shows green indicator when synced", () => {
const lastSync = new Date("2026-02-26T11:58:00Z"); // 2 minutes ago
render(<SyncStatus status="synced" lastSync={lastSync} />);
const indicator = screen.getByTestId("sync-indicator");
expect(indicator).toHaveClass("bg-green-500");
});
it("shows relative time for recent sync", () => {
const lastSync = new Date("2026-02-26T11:58:00Z"); // 2 minutes ago
render(<SyncStatus status="synced" lastSync={lastSync} />);
expect(screen.getByText(/synced 2m ago/i)).toBeInTheDocument();
});
it("shows seconds for very recent sync", () => {
const lastSync = new Date("2026-02-26T11:59:30Z"); // 30 seconds ago
render(<SyncStatus status="synced" lastSync={lastSync} />);
expect(screen.getByText(/synced.*30s ago/i)).toBeInTheDocument();
});
it("shows 'just now' for immediate sync", () => {
const lastSync = new Date("2026-02-26T12:00:00Z"); // right now
render(<SyncStatus status="synced" lastSync={lastSync} />);
expect(screen.getByText(/synced just now/i)).toBeInTheDocument();
});
});
describe("syncing state", () => {
it("shows spinner when syncing", () => {
render(<SyncStatus status="syncing" />);
expect(screen.getByTestId("sync-spinner")).toBeInTheDocument();
});
it("shows 'Syncing...' text", () => {
render(<SyncStatus status="syncing" />);
expect(screen.getByText("Syncing...")).toBeInTheDocument();
});
it("does not show indicator dot when syncing", () => {
render(<SyncStatus status="syncing" />);
expect(screen.queryByTestId("sync-indicator")).not.toBeInTheDocument();
});
});
describe("stale state", () => {
it("shows amber indicator when explicitly stale", () => {
const lastSync = new Date("2026-02-26T11:40:00Z"); // 20 minutes ago
render(<SyncStatus status="stale" lastSync={lastSync} />);
const indicator = screen.getByTestId("sync-indicator");
expect(indicator).toHaveClass("bg-amber-500");
});
it("auto-detects stale when synced status but time > 15 minutes", () => {
const lastSync = new Date("2026-02-26T11:40:00Z"); // 20 minutes ago
render(<SyncStatus status="synced" lastSync={lastSync} />);
// Should show amber (stale) even though status is "synced"
const indicator = screen.getByTestId("sync-indicator");
expect(indicator).toHaveClass("bg-amber-500");
});
it("shows 'Last sync' text for stale", () => {
const lastSync = new Date("2026-02-26T11:40:00Z"); // 20 minutes ago
render(<SyncStatus status="synced" lastSync={lastSync} />);
expect(screen.getByText(/last sync 20m ago/i)).toBeInTheDocument();
});
it("shows refresh button when stale", () => {
const lastSync = new Date("2026-02-26T11:40:00Z");
render(<SyncStatus status="synced" lastSync={lastSync} onRetry={() => {}} />);
expect(screen.getByRole("button", { name: /refresh/i })).toBeInTheDocument();
});
});
describe("error state", () => {
it("shows red indicator for error", () => {
render(<SyncStatus status="error" error="lore command failed" />);
const indicator = screen.getByTestId("sync-indicator");
expect(indicator).toHaveClass("bg-red-500");
});
it("shows error message", () => {
render(<SyncStatus status="error" error="lore command failed" />);
expect(screen.getByText(/lore command failed/)).toBeInTheDocument();
});
it("shows retry button", () => {
render(<SyncStatus status="error" error="failed" onRetry={() => {}} />);
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("calls onRetry when retry button clicked", async () => {
// Use real timers for this test since userEvent needs them
vi.useRealTimers();
const user = userEvent.setup();
const onRetry = vi.fn();
render(<SyncStatus status="error" error="failed" onRetry={onRetry} />);
await user.click(screen.getByRole("button", { name: /retry/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
// Restore fake timers for subsequent tests
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-26T12:00:00Z"));
});
it("shows generic message when no error provided", () => {
render(<SyncStatus status="error" />);
expect(screen.getByText(/sync failed/i)).toBeInTheDocument();
});
});
describe("offline state", () => {
it("shows gray indicator when offline", () => {
render(<SyncStatus status="offline" />);
const indicator = screen.getByTestId("sync-indicator");
expect(indicator).toHaveClass("bg-gray-400");
});
it("shows 'lore unavailable' message", () => {
render(<SyncStatus status="offline" />);
expect(screen.getByText(/lore unavailable/i)).toBeInTheDocument();
});
it("does not show retry button when offline", () => {
render(<SyncStatus status="offline" onRetry={() => {}} />);
// Offline doesn't get a retry button (user needs to fix lore first)
expect(screen.queryByRole("button", { name: /retry/i })).not.toBeInTheDocument();
});
});
describe("relative time formatting", () => {
it("formats hours correctly", () => {
const lastSync = new Date("2026-02-26T10:00:00Z"); // 2 hours ago
render(<SyncStatus status="stale" lastSync={lastSync} />);
expect(screen.getByText(/2h ago/i)).toBeInTheDocument();
});
it("formats days correctly", () => {
const lastSync = new Date("2026-02-24T12:00:00Z"); // 2 days ago
render(<SyncStatus status="stale" lastSync={lastSync} />);
expect(screen.getByText(/2d ago/i)).toBeInTheDocument();
});
});
describe("no onRetry callback", () => {
it("hides retry button when no onRetry provided for error", () => {
render(<SyncStatus status="error" error="failed" />);
expect(screen.queryByRole("button", { name: /retry/i })).not.toBeInTheDocument();
});
it("hides refresh button when no onRetry provided for stale", () => {
const lastSync = new Date("2026-02-26T11:40:00Z");
render(<SyncStatus status="synced" lastSync={lastSync} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
});