diff --git a/src/components/SyncStatus.tsx b/src/components/SyncStatus.tsx new file mode 100644 index 0000000..01ff332 --- /dev/null +++ b/src/components/SyncStatus.tsx @@ -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 = { + 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 ( +
+ {effectiveStatus === "syncing" ? ( + + + + + ) : ( +
+ )} + + {getStatusText()} + + {showActionButton && ( + + )} +
+ ); +} diff --git a/tests/components/SyncStatus.test.tsx b/tests/components/SyncStatus.test.tsx new file mode 100644 index 0000000..f9b3396 --- /dev/null +++ b/tests/components/SyncStatus.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText(/synced just now/i)).toBeInTheDocument(); + }); + }); + + describe("syncing state", () => { + it("shows spinner when syncing", () => { + render(); + + expect(screen.getByTestId("sync-spinner")).toBeInTheDocument(); + }); + + it("shows 'Syncing...' text", () => { + render(); + + expect(screen.getByText("Syncing...")).toBeInTheDocument(); + }); + + it("does not show indicator dot when syncing", () => { + render(); + + 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(); + + 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(); + + // 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(); + + 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( {}} />); + + expect(screen.getByRole("button", { name: /refresh/i })).toBeInTheDocument(); + }); + }); + + describe("error state", () => { + it("shows red indicator for error", () => { + render(); + + const indicator = screen.getByTestId("sync-indicator"); + expect(indicator).toHaveClass("bg-red-500"); + }); + + it("shows error message", () => { + render(); + + expect(screen.getByText(/lore command failed/)).toBeInTheDocument(); + }); + + it("shows retry button", () => { + render( {}} />); + + 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(); + + 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(); + + expect(screen.getByText(/sync failed/i)).toBeInTheDocument(); + }); + }); + + describe("offline state", () => { + it("shows gray indicator when offline", () => { + render(); + + const indicator = screen.getByTestId("sync-indicator"); + expect(indicator).toHaveClass("bg-gray-400"); + }); + + it("shows 'lore unavailable' message", () => { + render(); + + expect(screen.getByText(/lore unavailable/i)).toBeInTheDocument(); + }); + + it("does not show retry button when offline", () => { + render( {}} />); + + // 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(); + + expect(screen.getByText(/2h ago/i)).toBeInTheDocument(); + }); + + it("formats days correctly", () => { + const lastSync = new Date("2026-02-24T12:00:00Z"); // 2 days ago + render(); + + expect(screen.getByText(/2d ago/i)).toBeInTheDocument(); + }); + }); + + describe("no onRetry callback", () => { + it("hides retry button when no onRetry provided for error", () => { + render(); + + 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(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + }); +});