diff --git a/src/app/courses/loading.tsx b/src/app/courses/loading.tsx
index f74f1f9..59c5634 100644
--- a/src/app/courses/loading.tsx
+++ b/src/app/courses/loading.tsx
@@ -1,13 +1,9 @@
-import { CourseCardSkeleton } from "@/components/shared/loading-skeleton";
+import { CourseGridSkeleton } from "@/components/shared/loading-skeleton";
export default function Loading() {
return (
- {Array.from({ length: count }).map((_, i) => {
- if (variant === "text") {
- return
;
- }
- if (variant === "circle") {
- return
;
- }
- return
;
- })}
+
+ Loading…
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
);
}
@@ -52,9 +64,115 @@ export function CourseCardSkeleton() {
);
}
+/**
+ * Responsive grid of course-card placeholders, matching the catalog and
+ * dashboard grids (1 / 2 / 3 columns).
+ */
+export function CourseGridSkeleton({ count = 6 }: { count?: number }) {
+ return (
+
+ Loading courses…
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * Table placeholder with a header row and `rows` body rows. `columns` controls
+ * the cell count; the first column is rendered wider to stand in for a label.
+ */
+export function TableSkeleton({
+ rows = 5,
+ columns = 4,
+}: {
+ rows?: number;
+ columns?: number;
+}) {
+ return (
+
+
Loading table…
+
+ {Array.from({ length: columns }).map((_, i) => (
+
+ ))}
+
+
+ {Array.from({ length: rows }).map((_, r) => (
+
+ {Array.from({ length: columns }).map((_, c) => (
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Form placeholder: `fields` label + input pairs followed by a submit button.
+ */
+export function FormSkeleton({ fields = 4 }: { fields?: number }) {
+ return (
+
+
Loading form…
+ {Array.from({ length: fields }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Profile / account placeholder: avatar, name, meta line, and a details block.
+ */
+export function ProfileSkeleton() {
+ return (
+
+
Loading profile…
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ );
+}
+
export function DashboardSkeleton() {
return (
-
+
+
Loading dashboard…
{Array.from({ length: 3 }).map((_, i) => (
@@ -71,7 +189,7 @@ export function DashboardSkeleton() {
export function CredentialCardSkeleton() {
return (
-
+
@@ -83,7 +201,8 @@ export function CredentialCardSkeleton() {
export function RewardsSkeleton() {
return (
-
+
+
Loading rewards…
@@ -98,7 +217,8 @@ export function RewardsSkeleton() {
export function VerifySkeleton() {
return (
-
+
+
Verifying…
@@ -108,7 +228,8 @@ export function VerifySkeleton() {
export function CourseDetailSkeleton() {
return (
-
+
+ Loading course…
diff --git a/src/components/ui/skeleton.test.tsx b/src/components/ui/skeleton.test.tsx
index 1ad003b..7e6bec9 100644
--- a/src/components/ui/skeleton.test.tsx
+++ b/src/components/ui/skeleton.test.tsx
@@ -8,10 +8,16 @@ import {
} from "@/components/ui/skeleton";
describe("Skeleton", () => {
- it("renders with pulse animation", () => {
+ it("renders with a shimmer animation by default", () => {
const { container } = render();
- expect(container.firstChild).toHaveClass("animate-pulse");
+ expect(container.firstChild).toHaveClass("animate-shimmer");
expect(container.firstChild).toHaveAttribute("aria-hidden", "true");
+ expect(container.firstChild).toHaveAttribute("data-slot", "skeleton");
+ });
+
+ it("supports opting into the pulse animation", () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass("animate-pulse");
});
it("supports text, circle, and rectangle variants", () => {
@@ -33,6 +39,8 @@ describe("Skeleton", () => {
);
- expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThanOrEqual(4);
+ expect(
+ container.querySelectorAll('[data-slot="skeleton"]').length
+ ).toBeGreaterThanOrEqual(4);
});
});
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
index a65d61f..f9a5fa6 100644
--- a/src/components/ui/skeleton.tsx
+++ b/src/components/ui/skeleton.tsx
@@ -1,29 +1,49 @@
import * as React from "react";
import { cn } from "@/lib/utils/cn";
+export type SkeletonAnimation = "shimmer" | "pulse" | "none";
+
export interface SkeletonProps extends React.HTMLAttributes
{
/** Visual shape preset. Defaults to a rounded rectangle. */
variant?: "text" | "circle" | "rectangle";
+ /**
+ * Loading animation. `shimmer` (default) sweeps a highlight band across the
+ * placeholder; `pulse` fades opacity; `none` is static (useful in tests or
+ * when `prefers-reduced-motion` is handled upstream).
+ */
+ animation?: SkeletonAnimation;
}
+const ANIMATION_CLASSES: Record = {
+ // 200%-wide gradient so the shimmer keyframe has room to travel. Falls back
+ // to a static fill when the viewer prefers reduced motion.
+ shimmer:
+ "animate-shimmer bg-[length:200%_100%] bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 motion-reduce:animate-none motion-reduce:bg-gray-200 dark:from-gray-800 dark:via-gray-700 dark:to-gray-800 dark:motion-reduce:bg-gray-800",
+ pulse: "animate-pulse bg-gray-200 motion-reduce:animate-none dark:bg-gray-800",
+ none: "bg-gray-200 dark:bg-gray-800",
+};
+
/**
- * Base pulse skeleton. Pass className to match real content dimensions.
+ * Base skeleton block. Pass className to match real content dimensions.
*
* @example
*
*
*
+ *
*/
function Skeleton({
className,
variant = "rectangle",
+ animation = "shimmer",
...props
}: SkeletonProps) {
return (
) {
+}: Omit
) {
return ;
}
@@ -50,7 +70,7 @@ SkeletonText.displayName = "SkeletonText";
function SkeletonCircle({
className,
...props
-}: React.HTMLAttributes) {
+}: Omit) {
return (
{Array.from({ length: lines }).map((_, i) => (
))}
@@ -90,7 +113,7 @@ SkeletonStack.displayName = "SkeletonStack";
function SkeletonRect({
className,
...props
-}: React.HTMLAttributes) {
+}: Omit) {
return ;
}
@@ -103,7 +126,13 @@ function SkeletonCard({
...props
}: React.HTMLAttributes) {
return (
-
+
{children || (
<>
@@ -119,4 +148,11 @@ function SkeletonCard({
SkeletonCard.displayName = "SkeletonCard";
-export { Skeleton, SkeletonText, SkeletonCircle, SkeletonStack, SkeletonRect, SkeletonCard };
+export {
+ Skeleton,
+ SkeletonText,
+ SkeletonCircle,
+ SkeletonStack,
+ SkeletonRect,
+ SkeletonCard,
+};
diff --git a/src/lib/api/client.test.ts b/src/lib/api/client.test.ts
index 4af496d..05a9589 100644
--- a/src/lib/api/client.test.ts
+++ b/src/lib/api/client.test.ts
@@ -130,3 +130,68 @@ describe("apiClient abort support", () => {
expect(fetchMock).toHaveBeenCalledTimes(4); // initial + 3 retries
});
});
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: "OK",
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ } as unknown as Response;
+}
+
+describe("apiClient request deduplication", () => {
+ it("shares one in-flight request across concurrent callers", async () => {
+ let resolveFetch!: (r: Response) => void;
+ fetchMock.mockImplementation(
+ () => new Promise
((resolve) => { resolveFetch = resolve; })
+ );
+
+ const a = apiClient.get("/dedupe/a");
+ const b = apiClient.get("/dedupe/a");
+ resolveFetch(jsonResponse({ data: "shared" }));
+
+ await expect(a).resolves.toEqual({ data: "shared" });
+ await expect(b).resolves.toEqual({ data: "shared" });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not share requests for different URLs", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ data: "x" }));
+
+ await Promise.all([
+ apiClient.get("/dedupe/b"),
+ apiClient.get("/dedupe/c"),
+ ]);
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("issues a fresh request once the shared one has completed", async () => {
+ fetchMock.mockResolvedValue(jsonResponse({ data: "y" }));
+
+ await apiClient.get("/dedupe/d", undefined, undefined, { bypassCache: true });
+ await apiClient.get("/dedupe/d", undefined, undefined, { bypassCache: true });
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("keeps the shared request alive when one of several callers aborts", async () => {
+ let resolveFetch!: (r: Response) => void;
+ fetchMock.mockImplementation(
+ () => new Promise((resolve) => { resolveFetch = resolve; })
+ );
+
+ const controller = new AbortController();
+ const aborted = apiClient.get("/dedupe/e", undefined, controller.signal);
+ const kept = apiClient.get("/dedupe/e");
+
+ controller.abort();
+ await expect(aborted).rejects.toMatchObject({ name: "AbortError" });
+
+ resolveFetch(jsonResponse({ data: "still-here" }));
+ await expect(kept).resolves.toEqual({ data: "still-here" });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
index f815cde..2fa5bd3 100644
--- a/src/lib/api/client.ts
+++ b/src/lib/api/client.ts
@@ -18,10 +18,51 @@ type CacheEntry = {
const responseCache = new Map();
+/**
+ * In-flight GET requests keyed by cache key. Concurrent callers asking for the
+ * same resource share one network request (and its retries) instead of each
+ * firing their own. The shared request is owned by an internal AbortController
+ * that is aborted only once every caller has detached, so no single consumer
+ * unmounting can cancel a request others are still waiting on.
+ */
+type InFlightEntry = {
+ promise: Promise;
+ controller: AbortController;
+ refs: number;
+ settled: boolean;
+};
+
+const inFlightGets = new Map();
+
function delay(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+/**
+ * Rejects with an AbortError as soon as `signal` fires, otherwise settles with
+ * `promise`. Lets a caller stop awaiting a shared request without cancelling
+ * the underlying request for other callers.
+ */
+function withAbort(promise: Promise, signal?: AbortSignal): Promise {
+ if (!signal) return promise;
+ if (signal.aborted) return Promise.reject(createAbortError());
+ return new Promise((resolve, reject) => {
+ const onAbort = () => reject(createAbortError());
+ signal.addEventListener("abort", onAbort, { once: true });
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
+ promise.then(
+ (value) => {
+ cleanup();
+ resolve(value);
+ },
+ (error) => {
+ cleanup();
+ reject(error);
+ }
+ );
+ });
+}
+
/**
* Builds the error `fetch` throws when its signal fires, so callers can detect
* a manually cancelled request via `error.name === "AbortError"` and skip any
@@ -198,6 +239,9 @@ class ApiClient {
private invalidateCache(): void {
responseCache.clear();
+ // Stop new callers from joining requests that started before this
+ // mutation; those already in flight still resolve for their awaiters.
+ inFlightGets.clear();
}
async get(
@@ -216,6 +260,55 @@ class ApiClient {
}
}
+ if (signal?.aborted) {
+ throw createAbortError();
+ }
+
+ // A `bypassCache` read is deliberately never shared: the caller wants its
+ // own fresh round-trip and owns cancellation directly.
+ if (options?.bypassCache) {
+ return this.executeGet(url, key, jwt, true, signal);
+ }
+
+ let entry = inFlightGets.get(key);
+ if (!entry) {
+ const controller = new AbortController();
+ const created: InFlightEntry = {
+ controller,
+ refs: 0,
+ settled: false,
+ promise: this.executeGet(url, key, jwt, false, controller.signal),
+ };
+ const settle = () => {
+ created.settled = true;
+ if (inFlightGets.get(key) === created) {
+ inFlightGets.delete(key);
+ }
+ };
+ created.promise.then(settle, settle);
+ inFlightGets.set(key, created);
+ entry = created;
+ }
+
+ entry.refs++;
+ try {
+ return (await withAbort(entry.promise, signal)) as ApiResponse;
+ } finally {
+ entry.refs--;
+ // Last caller gone before the request finished — cancel it for real.
+ if (entry.refs <= 0 && !entry.settled) {
+ entry.controller.abort();
+ }
+ }
+ }
+
+ private async executeGet(
+ url: string,
+ key: string,
+ jwt: string | undefined,
+ bypassCache: boolean,
+ signal?: AbortSignal
+ ): Promise> {
try {
const response = await this.fetchWithRetry(
url,
@@ -224,7 +317,7 @@ class ApiClient {
signal
);
const data = await this.handleResponse>(response);
- if (!options?.bypassCache) {
+ if (!bypassCache) {
this.setCached(key, data);
}
return data;
diff --git a/src/lib/hooks/use-async.test.tsx b/src/lib/hooks/use-async.test.tsx
new file mode 100644
index 0000000..4e3f15f
--- /dev/null
+++ b/src/lib/hooks/use-async.test.tsx
@@ -0,0 +1,117 @@
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { useAsync } from "./use-async";
+
+describe("useAsync", () => {
+ it("moves through loading → data on success", async () => {
+ const fn = vi.fn(async () => "ok");
+ const { result } = renderHook(() => useAsync(fn));
+
+ expect(result.current.loading).toBe(false);
+
+ let promise: Promise;
+ act(() => {
+ promise = result.current.execute();
+ });
+ expect(result.current.loading).toBe(true);
+
+ await act(async () => {
+ await promise;
+ });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.data).toBe("ok");
+ expect(result.current.error).toBeNull();
+ });
+
+ it("captures errors instead of throwing", async () => {
+ const fn = vi.fn(async () => {
+ throw new Error("boom");
+ });
+ const { result } = renderHook(() => useAsync(fn));
+
+ await act(async () => {
+ await result.current.execute();
+ });
+
+ expect(result.current.error).toEqual(new Error("boom"));
+ expect(result.current.data).toBeUndefined();
+ });
+
+ it("retries the configured number of times before failing", async () => {
+ const fn = vi.fn(async () => {
+ throw new Error("nope");
+ });
+ const { result } = renderHook(() =>
+ useAsync(fn, { retries: 2, retryDelayMs: 0 })
+ );
+
+ await act(async () => {
+ await result.current.execute();
+ });
+
+ expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
+ expect(result.current.error).toEqual(new Error("nope"));
+ });
+
+ it("forwards execute arguments to the async function after the signal", async () => {
+ const fn = vi.fn(async (_signal: AbortSignal, id: string) => `course-${id}`);
+ const { result } = renderHook(() => useAsync(fn));
+
+ await act(async () => {
+ await result.current.execute("42");
+ });
+
+ expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal), "42");
+ expect(result.current.data).toBe("course-42");
+ });
+
+ it("aborts the in-flight call on unmount", async () => {
+ let seenSignal: AbortSignal | undefined;
+ const fn = vi.fn(
+ (signal: AbortSignal) =>
+ new Promise((resolve) => {
+ seenSignal = signal;
+ signal.addEventListener("abort", () => resolve("late"));
+ })
+ );
+ const { result, unmount } = renderHook(() => useAsync(fn));
+
+ act(() => {
+ void result.current.execute();
+ });
+ unmount();
+
+ expect(seenSignal?.aborted).toBe(true);
+ });
+
+ it("cancels a previous call when execute is invoked again", async () => {
+ const signals: AbortSignal[] = [];
+ const fn = vi.fn(
+ (signal: AbortSignal) =>
+ new Promise((resolve) => {
+ signals.push(signal);
+ signal.addEventListener("abort", () => resolve("cancelled"));
+ })
+ );
+ const { result } = renderHook(() => useAsync(fn));
+
+ act(() => {
+ void result.current.execute();
+ });
+ act(() => {
+ void result.current.execute();
+ });
+
+ expect(signals[0].aborted).toBe(true);
+ expect(signals[1].aborted).toBe(false);
+ });
+
+ it("runs immediately when the immediate option is set", async () => {
+ const fn = vi.fn(async () => "auto");
+ const { result } = renderHook(() => useAsync(fn, { immediate: true }));
+
+ await waitFor(() => expect(result.current.data).toBe("auto"));
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/hooks/use-async.ts b/src/lib/hooks/use-async.ts
new file mode 100644
index 0000000..563bea9
--- /dev/null
+++ b/src/lib/hooks/use-async.ts
@@ -0,0 +1,144 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { isAbortError } from "@/lib/api/client";
+
+export interface UseAsyncOptions {
+ /** Run the async function once on mount (only valid when it takes no args). */
+ immediate?: boolean;
+ /** Extra attempts after the first failure. Aborts are never retried. */
+ retries?: number;
+ /** Base delay between retries in ms; grows exponentially (1x, 2x, 4x…). */
+ retryDelayMs?: number;
+ onSuccess?: (data: TData) => void;
+ onError?: (error: Error) => void;
+}
+
+export interface UseAsyncResult {
+ /** Invoke the async function. Cancels any in-flight call first. */
+ execute: (...args: TArgs) => Promise;
+ /** Abort the in-flight call, if any. */
+ cancel: () => void;
+ /** Re-run the last `execute` call with the same arguments. */
+ retry: () => Promise;
+ /** Clear data/error and abort any in-flight call. */
+ reset: () => void;
+ loading: boolean;
+ error: Error | null;
+ data: TData | undefined;
+}
+
+const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+/**
+ * Manages the loading / error / data lifecycle of an async operation (#292).
+ *
+ * The async function receives an `AbortSignal` as its first argument followed
+ * by whatever is passed to `execute`. Each `execute` cancels the previous call,
+ * and the hook aborts on unmount, so stale results never reach state.
+ *
+ * @example
+ * const { execute, loading, error, data } = useAsync(
+ * (signal, id: string) => getCourse(id, jwt, signal)
+ * );
+ * // later: execute(courseId)
+ */
+export function useAsync(
+ asyncFn: (signal: AbortSignal, ...args: TArgs) => Promise,
+ options: UseAsyncOptions = {}
+): UseAsyncResult {
+ const { immediate = false, retries = 0, retryDelayMs = 500 } = options;
+
+ const [loading, setLoading] = useState(immediate);
+ const [error, setError] = useState(null);
+ const [data, setData] = useState(undefined);
+
+ // Latest values held in refs so `execute` stays referentially stable.
+ const fnRef = useRef(asyncFn);
+ fnRef.current = asyncFn;
+ const optionsRef = useRef(options);
+ optionsRef.current = options;
+ const mountedRef = useRef(true);
+ const controllerRef = useRef(null);
+ const lastArgsRef = useRef(null);
+
+ const cancel = useCallback(() => {
+ controllerRef.current?.abort();
+ controllerRef.current = null;
+ }, []);
+
+ const execute = useCallback(
+ async (...args: TArgs): Promise => {
+ lastArgsRef.current = args;
+ controllerRef.current?.abort();
+ const controller = new AbortController();
+ controllerRef.current = controller;
+
+ if (mountedRef.current) {
+ setLoading(true);
+ setError(null);
+ }
+
+ const maxRetries = optionsRef.current.retries ?? retries;
+ const baseDelay = optionsRef.current.retryDelayMs ?? retryDelayMs;
+
+ for (let attempt = 0; ; attempt++) {
+ try {
+ const result = await fnRef.current(controller.signal, ...args);
+ if (controller.signal.aborted) return undefined;
+ if (mountedRef.current && controllerRef.current === controller) {
+ setData(result);
+ setLoading(false);
+ }
+ optionsRef.current.onSuccess?.(result);
+ return result;
+ } catch (err) {
+ if (isAbortError(err) || controller.signal.aborted) return undefined;
+
+ if (attempt < maxRetries) {
+ await wait(baseDelay * 2 ** attempt);
+ if (controller.signal.aborted) return undefined;
+ continue;
+ }
+
+ const normalized =
+ err instanceof Error ? err : new Error("Async operation failed");
+ if (mountedRef.current && controllerRef.current === controller) {
+ setError(normalized);
+ setLoading(false);
+ }
+ optionsRef.current.onError?.(normalized);
+ return undefined;
+ }
+ }
+ },
+ [retries, retryDelayMs]
+ );
+
+ const retry = useCallback((): Promise => {
+ return execute(...((lastArgsRef.current ?? []) as TArgs));
+ }, [execute]);
+
+ const reset = useCallback(() => {
+ cancel();
+ if (mountedRef.current) {
+ setLoading(false);
+ setError(null);
+ setData(undefined);
+ }
+ }, [cancel]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ if (immediate) {
+ void execute(...([] as unknown[] as TArgs));
+ }
+ return () => {
+ mountedRef.current = false;
+ controllerRef.current?.abort();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return { execute, cancel, retry, reset, loading, error, data };
+}
diff --git a/src/lib/hooks/use-interval.test.tsx b/src/lib/hooks/use-interval.test.tsx
new file mode 100644
index 0000000..3ac65a5
--- /dev/null
+++ b/src/lib/hooks/use-interval.test.tsx
@@ -0,0 +1,84 @@
+import { renderHook, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { useInterval } from "./use-interval";
+
+describe("useInterval", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it("runs the callback on each interval", () => {
+ const cb = vi.fn();
+ renderHook(() => useInterval(cb, 1000));
+
+ expect(cb).not.toHaveBeenCalled();
+ act(() => vi.advanceTimersByTime(3000));
+ expect(cb).toHaveBeenCalledTimes(3);
+ });
+
+ it("pauses when the delay is null", () => {
+ const cb = vi.fn();
+ const { rerender } = renderHook(
+ ({ delay }: { delay: number | null }) => useInterval(cb, delay),
+ { initialProps: { delay: 1000 as number | null } }
+ );
+
+ act(() => vi.advanceTimersByTime(2000));
+ expect(cb).toHaveBeenCalledTimes(2);
+
+ rerender({ delay: null });
+ act(() => vi.advanceTimersByTime(5000));
+ expect(cb).toHaveBeenCalledTimes(2); // no more ticks
+
+ rerender({ delay: 1000 });
+ act(() => vi.advanceTimersByTime(1000));
+ expect(cb).toHaveBeenCalledTimes(3); // resumed
+ });
+
+ it("always calls the latest callback", () => {
+ const first = vi.fn();
+ const second = vi.fn();
+ const { rerender } = renderHook(
+ ({ cb }: { cb: () => void }) => useInterval(cb, 1000),
+ { initialProps: { cb: first } }
+ );
+
+ rerender({ cb: second });
+ act(() => vi.advanceTimersByTime(1000));
+
+ expect(first).not.toHaveBeenCalled();
+ expect(second).toHaveBeenCalledTimes(1);
+ });
+
+ it("clears the interval on unmount", () => {
+ const cb = vi.fn();
+ const { unmount } = renderHook(() => useInterval(cb, 1000));
+
+ unmount();
+ act(() => vi.advanceTimersByTime(5000));
+ expect(cb).not.toHaveBeenCalled();
+ });
+
+ it("fires immediately when the immediate option is set", () => {
+ const cb = vi.fn();
+ renderHook(() => useInterval(cb, 1000, { immediate: true }));
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips ticks while an async callback is still pending", async () => {
+ let resolve!: () => void;
+ const cb = vi.fn(
+ () => new Promise((r) => { resolve = r; })
+ );
+ renderHook(() => useInterval(cb, 1000));
+
+ act(() => vi.advanceTimersByTime(3000));
+ expect(cb).toHaveBeenCalledTimes(1); // still pending, later ticks skipped
+
+ await act(async () => {
+ resolve();
+ await Promise.resolve();
+ });
+ act(() => vi.advanceTimersByTime(1000));
+ expect(cb).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/lib/hooks/use-interval.ts b/src/lib/hooks/use-interval.ts
new file mode 100644
index 0000000..8624c6d
--- /dev/null
+++ b/src/lib/hooks/use-interval.ts
@@ -0,0 +1,62 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+export interface UseIntervalOptions {
+ /** Fire the callback immediately on mount / when the delay changes. */
+ immediate?: boolean;
+ /**
+ * When the callback returns a promise, skip ticks that land while a previous
+ * run is still pending instead of letting them overlap. Defaults to true.
+ */
+ skipWhilePending?: boolean;
+}
+
+/**
+ * Run `callback` every `delay` ms for polling and periodic updates (#293).
+ *
+ * Pass `delay = null` to pause the interval; pass a number again to resume.
+ * The latest callback is always invoked (no stale closures) and the interval
+ * is cleared on unmount and whenever the delay changes.
+ *
+ * @example
+ * // poll reward status every 5s, pause when the tab is hidden
+ * useInterval(refreshRewardStatus, isVisible ? 5000 : null);
+ */
+export function useInterval(
+ callback: () => void | Promise,
+ delay: number | null,
+ options: UseIntervalOptions = {}
+): void {
+ const { immediate = false, skipWhilePending = true } = options;
+ const callbackRef = useRef(callback);
+ callbackRef.current = callback;
+ const pendingRef = useRef(false);
+
+ useEffect(() => {
+ if (delay === null) return;
+
+ let cancelled = false;
+
+ const tick = () => {
+ if (cancelled) return;
+ if (skipWhilePending && pendingRef.current) return;
+
+ const result = callbackRef.current();
+ if (result instanceof Promise) {
+ pendingRef.current = true;
+ result.finally(() => {
+ pendingRef.current = false;
+ });
+ }
+ };
+
+ if (immediate) tick();
+ const id = setInterval(tick, delay);
+
+ return () => {
+ cancelled = true;
+ clearInterval(id);
+ };
+ }, [delay, immediate, skipWhilePending]);
+}
diff --git a/src/tests/shared/loading-skeleton.test.tsx b/src/tests/shared/loading-skeleton.test.tsx
index f7fc402..06ac00c 100644
--- a/src/tests/shared/loading-skeleton.test.tsx
+++ b/src/tests/shared/loading-skeleton.test.tsx
@@ -3,20 +3,30 @@ import { render } from "@testing-library/react";
import {
LoadingSkeleton,
CourseCardSkeleton,
+ CourseGridSkeleton,
+ TableSkeleton,
+ FormSkeleton,
+ ProfileSkeleton,
DashboardSkeleton,
} from "@/components/shared/loading-skeleton";
+const skeletons = (el: HTMLElement) =>
+ el.querySelectorAll('[data-slot="skeleton"]');
+
describe("LoadingSkeleton", () => {
it("renders one skeleton by default", () => {
const { container } = render();
- // default count=1 → one pulse div inside the space-y-3 wrapper
- const pulses = container.querySelectorAll(".animate-pulse");
- expect(pulses).toHaveLength(1);
+ expect(skeletons(container)).toHaveLength(1);
});
it("renders the requested count of items", () => {
const { container } = render();
- expect(container.querySelectorAll(".animate-pulse")).toHaveLength(4);
+ expect(skeletons(container)).toHaveLength(4);
+ });
+
+ it("exposes a polite status role for screen readers", () => {
+ const { getByRole } = render();
+ expect(getByRole("status")).toHaveAttribute("aria-busy", "true");
});
it.each([
@@ -25,30 +35,51 @@ describe("LoadingSkeleton", () => {
["circle", "rounded-full"],
] as const)("variant=%s applies correct shape class", (variant, cls) => {
const { container } = render();
- expect((container.querySelector(".animate-pulse") as HTMLElement).className).toContain(cls);
+ expect((skeletons(container)[0] as HTMLElement).className).toContain(cls);
});
});
describe("CourseCardSkeleton", () => {
- it("renders without crashing", () => {
+ it("renders animated placeholder elements", () => {
const { container } = render();
- expect(container.firstChild).toBeInTheDocument();
+ expect(skeletons(container).length).toBeGreaterThan(0);
});
+});
- it("contains animated pulse elements", () => {
- const { container } = render();
- expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
+describe("CourseGridSkeleton", () => {
+ it("renders the requested number of cards", () => {
+ const { container } = render();
+ // each card contains multiple skeleton blocks
+ expect(container.querySelectorAll(".grid > div")).toHaveLength(3);
});
});
-describe("DashboardSkeleton", () => {
- it("renders without crashing", () => {
- const { container } = render();
- expect(container.firstChild).toBeInTheDocument();
+describe("TableSkeleton", () => {
+ it("renders a header plus the requested rows", () => {
+ const { container } = render();
+ // 4 header cells + 3 rows * 4 cells = 16
+ expect(skeletons(container)).toHaveLength(16);
});
+});
- it("renders stat card placeholders", () => {
+describe("FormSkeleton", () => {
+ it("renders a label/input pair per field plus a submit button", () => {
+ const { container } = render();
+ // 3 * (label + input) + submit button = 7
+ expect(skeletons(container)).toHaveLength(7);
+ });
+});
+
+describe("ProfileSkeleton", () => {
+ it("renders an avatar and detail placeholders", () => {
+ const { container } = render();
+ expect(skeletons(container).length).toBeGreaterThan(4);
+ });
+});
+
+describe("DashboardSkeleton", () => {
+ it("renders stat card and course placeholders", () => {
const { container } = render();
- expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
+ expect(skeletons(container).length).toBeGreaterThan(0);
});
});
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 7c8a7f0..dde1f20 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -87,6 +87,12 @@ const config: Config = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
+ // Skeleton loading sheen: a highlight band sweeps left→right across
+ // the placeholder. Paired with a 200%-wide gradient background.
+ shimmer: {
+ "0%": { backgroundPosition: "200% 0" },
+ "100%": { backgroundPosition: "-200% 0" },
+ },
},
animation: {
"dialog-overlay-show": "dialog-overlay-show 0.2s ease-out",
@@ -99,6 +105,7 @@ const config: Config = {
"content-out": "content-out 150ms ease-in",
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
+ shimmer: "shimmer 1.6s ease-in-out infinite",
},
},
},