Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion react-ystemandchess/src/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";

Expand All @@ -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 <Outlet /> 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 <Navigate to="/login" replace />;
return <Outlet />;
};

/**
* Main routing component that defines all application routes
*
Expand Down Expand Up @@ -135,6 +170,11 @@ const AppRoutes = () => {
/>
}
/>

{/* Analytics section - admin-only; tabs are managed inside AnalyticsLayout */}
<Route path="/analytics" element={<AdminRoute />}>
<Route index element={<AnalyticsLayout />} />
</Route>
</Routes>
);
};
Expand Down
73 changes: 73 additions & 0 deletions react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AnalyticsLayout />);
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(<AnalyticsLayout />);
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(<AnalyticsLayout />);
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(<AnalyticsLayout />);
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(<AnalyticsLayout />);
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 <pre> as JSON
const panel = screen.getByRole("tabpanel");
expect(panel.textContent).toMatch(/active_users/);
});
});
100 changes: 100 additions & 0 deletions react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.tsx
Original file line number Diff line number Diff line change
@@ -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<AnalyticsTab>("individual");
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");

const dateRangeReady = Boolean(startDate && endDate);

const { data, loading, error } = useAnalyticsApi<unknown>({
endpoint: `/analytics/${activeTab}`,
params: { startDate, endDate },
enabled: dateRangeReady,
});

return (
<div className="p-4 md:p-6 max-w-6xl mx-auto">
<h1 className="text-2xl font-semibold mb-4">Analytics</h1>

{/* Tab bar */}
<div role="tablist" aria-label="Analytics views" className="flex border-b border-gray-300 mb-4 overflow-x-auto">
{TABS.map((tab) => {
const selected = activeTab === tab.id;
return (
<button
key={tab.id}
role="tab"
aria-selected={selected}
aria-controls={`analytics-panel-${tab.id}`}
id={`analytics-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition border-b-2 whitespace-nowrap ${
selected
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-600 hover:text-gray-900"
}`}
>
{tab.label}
</button>
);
})}
</div>

{/* Date range filter */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<label className="flex flex-col text-sm">
<span className="text-gray-700 mb-1">Start date</span>
<input
type="date"
value={startDate}
max={endDate || undefined}
onChange={(e) => setStartDate(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</label>
<label className="flex flex-col text-sm">
<span className="text-gray-700 mb-1">End date</span>
<input
type="date"
value={endDate}
min={startDate || undefined}
onChange={(e) => setEndDate(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</label>
</div>

{/* Content panel */}
<section
role="tabpanel"
id={`analytics-panel-${activeTab}`}
aria-labelledby={`analytics-tab-${activeTab}`}
className="bg-white border border-gray-200 rounded-lg p-4 min-h-[12rem]"
>
{!dateRangeReady ? (
<p className="text-gray-500">Select a start and end date to load analytics.</p>
) : loading ? (
<p className="text-gray-500">Loading…</p>
) : error ? (
<p className="text-red-600">Error: {error.message}</p>
) : data ? (
<pre className="text-xs overflow-auto">{JSON.stringify(data, null, 2)}</pre>
) : (
<p className="text-gray-500">No data.</p>
)}
</section>
</div>
);
};

export default AnalyticsLayout;
67 changes: 67 additions & 0 deletions react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading