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