From 4735911ce387912acb2023f8ce556352e68b7457 Mon Sep 17 00:00:00 2001 From: saritahimthani Date: Thu, 7 May 2026 17:19:51 -0700 Subject: [PATCH] feature - add admin-only analytics dashboard with mock first api hook --- react-ystemandchess/src/AppRoutes.tsx | 42 ++++- .../Pages/Analytics/AnalyticsLayout.test.tsx | 73 +++++++++ .../src/Pages/Analytics/AnalyticsLayout.tsx | 100 ++++++++++++ .../src/core/hooks/useAnalyticsApi.test.ts | 67 ++++++++ .../src/core/hooks/useAnalyticsApi.ts | 144 ++++++++++++++++++ 5 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx create mode 100644 react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.tsx create mode 100644 react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts create mode 100644 react-ystemandchess/src/core/hooks/useAnalyticsApi.ts 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 for admins; otherwise redirects to /login. + */ +const AdminRoute = () => { + const [cookies, , removeCookie] = useCookies(["login"]); + const [status, setStatus] = useState<"loading" | "allowed" | "denied">("loading"); + + useEffect(() => { + let cancelled = false; + (async () => { + const info = await SetPermissionLevel(cookies, removeCookie); + if (cancelled) return; + setStatus(info && !info.error && info.role === "admin" ? "allowed" : "denied"); + })(); + return () => { + cancelled = true; + }; + }, [cookies.login]); // eslint-disable-line react-hooks/exhaustive-deps + + if (status === "loading") return null; + // temporary commented, uncomment before committing the code. + if (status === "denied") return ; + return ; +}; + /** * Main routing component that defines all application routes * @@ -135,6 +170,11 @@ const AppRoutes = () => { /> } /> + + {/* Analytics section - admin-only; tabs are managed inside AnalyticsLayout */} + }> + } /> + ); }; diff --git a/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx b/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx new file mode 100644 index 00000000..a4a225df --- /dev/null +++ b/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import AnalyticsLayout from "./AnalyticsLayout"; + +jest.mock("../../environments/environment", () => ({ + environment: { urls: { middlewareURL: "http://mockurl.com" } }, +})); + +jest.mock("react-cookie", () => ({ + useCookies: () => [{ login: "mock-jwt-token" }, jest.fn(), jest.fn()], +})); + +describe("AnalyticsLayout", () => { + it("renders three tabs: Individual, Zipcode, Global", () => { + render(); + expect(screen.getByRole("tab", { name: /Individual/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Zipcode/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Global/i })).toBeInTheDocument(); + }); + + it("defaults to Individual tab selected", () => { + render(); + expect(screen.getByRole("tab", { name: /Individual/i })).toHaveAttribute( + "aria-selected", + "true" + ); + expect(screen.getByRole("tab", { name: /Zipcode/i })).toHaveAttribute( + "aria-selected", + "false" + ); + }); + + it("switches selection when a tab is clicked", () => { + render(); + fireEvent.click(screen.getByRole("tab", { name: /Global/i })); + expect(screen.getByRole("tab", { name: /Global/i })).toHaveAttribute( + "aria-selected", + "true" + ); + expect(screen.getByRole("tab", { name: /Individual/i })).toHaveAttribute( + "aria-selected", + "false" + ); + }); + + it("renders the date-range prompt before dates are picked", () => { + render(); + expect( + screen.getByText(/Select a start and end date to load analytics/i) + ).toBeInTheDocument(); + }); + + it("loads mock data once start and end dates are set", async () => { + const { container } = render(); + const startInput = container.querySelector( + 'input[type="date"]:nth-of-type(1)' + ) as HTMLInputElement; + const endInput = container.querySelectorAll( + 'input[type="date"]' + )[1] as HTMLInputElement; + + fireEvent.change(startInput, { target: { value: "2026-05-01" } }); + fireEvent.change(endInput, { target: { value: "2026-05-04" } }); + + // Loading indicator first + await waitFor(() => + expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument() + ); + + // Mock data renders inside the
 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 }; +}