diff --git a/react-ystemandchess/src/AppRoutes.tsx b/react-ystemandchess/src/AppRoutes.tsx
index a962f78d..dd31d8eb 100644
--- a/react-ystemandchess/src/AppRoutes.tsx
+++ b/react-ystemandchess/src/AppRoutes.tsx
@@ -13,7 +13,12 @@
*/
// React and routing imports
-import { Route, Routes } from "react-router-dom";
+import { useEffect, useState } from "react";
+import { Navigate, Outlet, Route, Routes } from "react-router-dom";
+import { useCookies } from "react-cookie";
+
+// Auth utility for role-based route guards
+import { SetPermissionLevel } from "./globals";
// Page component imports - organized by category
// Home and main pages
@@ -51,6 +56,9 @@ import StudentInventory from "./features/student/student-inventory/StudentInvent
import NewMentorProfile from "./features/mentor/mentor-profile/NewMentorProfile";
import NewStudentProfile from "./features/student/student-profile/NewStudentProfile";
+// Analytics dashboard (admin-only)
+import AnalyticsLayout from "./Pages/Analytics/AnalyticsLayout";
+
// Static assets and default data
import userPortraitImg from "./assets/images/user-portrait-placeholder.svg";
@@ -60,6 +68,33 @@ import userPortraitImg from "./assets/images/user-portrait-placeholder.svg";
*/
const userName = "Nimesh Patel";
+/**
+ * Route guard that restricts access to admin users.
+ * Resolves the role from the login JWT via SetPermissionLevel.
+ * Renders nested routes via
as JSON
+ const panel = screen.getByRole("tabpanel");
+ expect(panel.textContent).toMatch(/active_users/);
+ });
+});
diff --git a/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.tsx b/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.tsx
new file mode 100644
index 00000000..7a6de03a
--- /dev/null
+++ b/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.tsx
@@ -0,0 +1,100 @@
+import { useState } from "react";
+import { useAnalyticsApi } from "../../core/hooks/useAnalyticsApi";
+
+export type AnalyticsTab = "individual" | "zipcode" | "global";
+
+const TABS: Array<{ id: AnalyticsTab; label: string }> = [
+ { id: "individual", label: "Individual" },
+ { id: "zipcode", label: "Zipcode" },
+ { id: "global", label: "Global" },
+];
+
+const AnalyticsLayout = () => {
+ const [activeTab, setActiveTab] = useState("individual");
+ const [startDate, setStartDate] = useState("");
+ const [endDate, setEndDate] = useState("");
+
+ const dateRangeReady = Boolean(startDate && endDate);
+
+ const { data, loading, error } = useAnalyticsApi({
+ endpoint: `/analytics/${activeTab}`,
+ params: { startDate, endDate },
+ enabled: dateRangeReady,
+ });
+
+ return (
+
+ Analytics
+
+ {/* Tab bar */}
+
+ {TABS.map((tab) => {
+ const selected = activeTab === tab.id;
+ return (
+
+ );
+ })}
+
+
+ {/* Date range filter */}
+
+
+
+
+
+ {/* Content panel */}
+
+ {!dateRangeReady ? (
+ Select a start and end date to load analytics.
+ ) : loading ? (
+ Loading…
+ ) : error ? (
+ Error: {error.message}
+ ) : data ? (
+ {JSON.stringify(data, null, 2)}
+ ) : (
+ No data.
+ )}
+
+
+ );
+};
+
+export default AnalyticsLayout;
diff --git a/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts b/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts
new file mode 100644
index 00000000..816a49d3
--- /dev/null
+++ b/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts
@@ -0,0 +1,67 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { useAnalyticsApi } from "./useAnalyticsApi";
+
+// Mock environment so the hook doesn't import a real backend URL.
+jest.mock("../../environments/environment", () => ({
+ environment: { urls: { middlewareURL: "http://mockurl.com" } },
+}));
+
+// Mock react-cookie so useCookies returns a controllable value.
+jest.mock("react-cookie", () => ({
+ useCookies: () => [{ login: "mock-jwt-token" }, jest.fn(), jest.fn()],
+}));
+
+describe("useAnalyticsApi", () => {
+ describe("mock-mode (default)", () => {
+ it("returns initial state synchronously: data=null, loading=false, error=null", () => {
+ const { result } = renderHook(() =>
+ useAnalyticsApi({ endpoint: "/analytics/individual", enabled: false })
+ );
+ expect(result.current.data).toBeNull();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("resolves to MOCK_ANALYTICS_DATA when enabled", async () => {
+ const { result } = renderHook(() =>
+ useAnalyticsApi({ endpoint: "/analytics/individual" })
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.error).toBeNull();
+ expect(Array.isArray(result.current.data)).toBe(true);
+ expect((result.current.data as any[]).length).toBeGreaterThan(0);
+ expect((result.current.data as any[])[0]).toHaveProperty("date");
+ expect((result.current.data as any[])[0]).toHaveProperty("metric");
+ expect((result.current.data as any[])[0]).toHaveProperty("value");
+ });
+
+ it("skips fetching when enabled=false", async () => {
+ const { result } = renderHook(() =>
+ useAnalyticsApi({ endpoint: "/analytics/individual", enabled: false })
+ );
+
+ // Wait a tick to ensure no async work updates state.
+ await new Promise((r) => setTimeout(r, 50));
+ expect(result.current.data).toBeNull();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("re-fetches when endpoint changes", async () => {
+ const { result, rerender } = renderHook(
+ ({ endpoint }) => useAnalyticsApi({ endpoint }),
+ { initialProps: { endpoint: "/analytics/individual" } }
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ const firstData = result.current.data;
+
+ rerender({ endpoint: "/analytics/global" });
+ // After dep change, loading should flip true then resolve again.
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ // In mock mode the payload is identical, but the effect did re-run.
+ expect(result.current.data).toEqual(firstData);
+ });
+ });
+});
diff --git a/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts b/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts
new file mode 100644
index 00000000..22afbe6e
--- /dev/null
+++ b/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts
@@ -0,0 +1,144 @@
+import { useEffect, useState } from "react";
+import { useCookies } from "react-cookie";
+import { environment } from "../../environments/environment";
+
+/**
+ * Cookie that holds the JWT used for analytics API auth.
+ * The Day 2 spec referenced `auth_token` as an example; this app stores the
+ * JWT under `login` (see App.tsx / globals.ts), so we read from there.
+ * Centralized as a constant so swapping cookie sources is a one-line change.
+ */
+const AUTH_COOKIE_NAME = "login";
+
+/**
+ * When true, the hook resolves to MOCK_ANALYTICS_DATA after a short delay
+ * instead of hitting the network. Flip to false once the backend endpoint
+ * is ready.
+ */
+const USE_MOCK = true;
+
+/** Base URL for analytics endpoints; sourced from environment config. */
+export const ANALYTICS_API_BASE = environment.urls.middlewareURL;
+
+export interface AnalyticsRecord {
+ date: string;
+ metric: string;
+ value: number;
+}
+
+export type AnalyticsData = AnalyticsRecord[];
+
+export interface UseAnalyticsApiOptions {
+ /** Path appended to ANALYTICS_API_BASE, e.g. "/analytics/individual". */
+ endpoint: string;
+ /** Optional query string parameters. Undefined/empty values are stripped. */
+ params?: Record;
+ /** When false, the hook skips the fetch entirely. Defaults to true. */
+ enabled?: boolean;
+}
+
+export interface UseAnalyticsApiResult {
+ data: T | null;
+ loading: boolean;
+ error: Error | null;
+}
+
+const MOCK_ANALYTICS_DATA: AnalyticsData = [
+ { date: "2026-05-01", metric: "active_users", value: 142 },
+ { date: "2026-05-02", metric: "active_users", value: 158 },
+ { date: "2026-05-03", metric: "active_users", value: 161 },
+ { date: "2026-05-04", metric: "active_users", value: 173 },
+];
+
+const buildQuery = (params?: UseAnalyticsApiOptions["params"]): string => {
+ if (!params) return "";
+ const entries = Object.entries(params).filter(
+ ([, v]) => v !== undefined && v !== "",
+ );
+ if (entries.length === 0) return "";
+ const search = new URLSearchParams(entries.map(([k, v]) => [k, String(v)]));
+ return `?${search.toString()}`;
+};
+
+/**
+ * Loads analytics data with mock-first behavior.
+ *
+ * - Returns mocked data while USE_MOCK is true (great for unblocking UI work).
+ * - When USE_MOCK is false: reads JWT from `login` cookie, sends it as
+ * `Authorization: Bearer `, and fetches from
+ * `${ANALYTICS_API_BASE}${endpoint}` with optional query params.
+ * - Re-runs when endpoint, params, enabled, or the auth cookie change.
+ * - Aborts inflight requests on unmount/dep change to avoid setState leaks.
+ *
+ * @returns { data, loading, error } with initial state { null, false, null }.
+ */
+export function useAnalyticsApi(
+ { endpoint, params, enabled = true }: UseAnalyticsApiOptions,
+): UseAnalyticsApiResult {
+ const [cookies] = useCookies([AUTH_COOKIE_NAME]);
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const paramsKey = JSON.stringify(params ?? {});
+
+ useEffect(() => {
+ if (!enabled) {
+ setLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+ const controller = new AbortController();
+
+ const fetchData = async (): Promise => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ if (USE_MOCK) {
+ // Simulate latency so loading states are observable in dev.
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ if (cancelled) return;
+ setData(MOCK_ANALYTICS_DATA as unknown as T);
+ return;
+ }
+
+ const token: string | undefined = cookies[AUTH_COOKIE_NAME];
+ if (!token) {
+ throw new Error("Missing authentication token");
+ }
+
+ const url = `${ANALYTICS_API_BASE}${endpoint}${buildQuery(params)}`;
+ const response = await fetch(url, {
+ headers: { Authorization: `Bearer ${token}` },
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Request failed (${response.status})`);
+ }
+
+ const json = (await response.json()) as T;
+ if (cancelled) return;
+ setData(json);
+ } catch (err: unknown) {
+ if (cancelled) return;
+ if (err instanceof DOMException && err.name === "AbortError") return;
+ setError(err instanceof Error ? err : new Error(String(err)));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+
+ fetchData();
+
+ return () => {
+ cancelled = true;
+ controller.abort();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [endpoint, paramsKey, enabled, cookies[AUTH_COOKIE_NAME]]);
+
+ return { data, loading, error };
+}