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>
|
||||
);
|
||||
}
|
||||
195
tests/components/SyncStatus.test.tsx
Normal file
195
tests/components/SyncStatus.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user