diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts index ee163d95..b1097fda 100644 --- a/frontend/e2e/global-setup.ts +++ b/frontend/e2e/global-setup.ts @@ -126,19 +126,26 @@ export default async function globalSetup(config: FullConfig) { fs.mkdirSync(path.dirname(ADMIN_AUTH_FILE), { recursive: true }); const authEntries = [ - { loginFn: loginAsStudent, authFile: STUDENT_AUTH_FILE }, - { loginFn: loginAsStaff, authFile: STAFF_AUTH_FILE }, - { loginFn: loginAsAdmin, authFile: ADMIN_AUTH_FILE }, - { loginFn: loginAsOfficer, authFile: OFFICER_AUTH_FILE }, - { loginFn: loginAsPoliceAdmin, authFile: POLICE_AUTH_FILE }, + { label: "student", loginFn: loginAsStudent, authFile: STUDENT_AUTH_FILE }, + { label: "staff", loginFn: loginAsStaff, authFile: STAFF_AUTH_FILE }, + { label: "admin", loginFn: loginAsAdmin, authFile: ADMIN_AUTH_FILE }, + { label: "officer", loginFn: loginAsOfficer, authFile: OFFICER_AUTH_FILE }, + { + label: "police admin", + loginFn: loginAsPoliceAdmin, + authFile: POLICE_AUTH_FILE, + }, ]; - const stale = authEntries.filter( - ({ authFile }) => !isAuthFileValid(authFile) - ); + const stale = authEntries.filter(({ authFile }) => { + const valid = isAuthFileValid(authFile); + if (valid) console.log(`[auth] using cached ${authFile}`); + return !valid; + }); if (stale.length > 0) { const browser = await chromium.launch({ chromiumSandbox: false }); - for (const { loginFn, authFile } of stale) { + for (const { label, loginFn, authFile } of stale) { + console.log(`[auth] generating new ${label} auth at ${authFile}`); await saveAuthState(browser, baseURL, loginFn, authFile); } await browser.close(); diff --git a/frontend/e2e/test/student/dashboard-tracker.spec.ts b/frontend/e2e/test/student/dashboard-tracker.spec.ts index 89f8fc34..bf125b22 100644 --- a/frontend/e2e/test/student/dashboard-tracker.spec.ts +++ b/frontend/e2e/test/student/dashboard-tracker.spec.ts @@ -1,4 +1,5 @@ -import { format } from "date-fns"; +import { type Locator } from "@playwright/test"; +import { format, parse } from "date-fns"; import { STUDENT_AUTH_FILE } from "../../global-setup"; import { resetDatabase } from "../../helpers/db.helpers"; import { @@ -14,6 +15,8 @@ import { formatDateInput } from "../../helpers/seed.helpers"; // Helpers // --------------------------------------------------------------------------- +const PARTY_DATE_TIME_FORMAT = "M/d/yyyy @ h:mm a"; + async function openActivePartyMenu(page: Page) { // Skip today's party cards — a party at e.g. 18:30 today becomes locked after // that time, causing "This party has already occurred" errors. Target the first @@ -42,6 +45,27 @@ async function setValidPartyDateTime(page: Page, daysAhead: number) { await page.getByLabel("Party Time").fill("20:00"); } +async function getPartyCardTimes(panel: Locator): Promise { + const dateTexts = await panel + .locator(".border-b.border-gray-200 .content-bold p") + .allTextContents(); + + return dateTexts.map((text) => + parse(text.trim(), PARTY_DATE_TIME_FORMAT, new Date()).getTime() + ); +} + +function expectOrderedClosestToNow(times: number[]) { + const now = Date.now(); + const distances = times.map((time) => Math.abs(time - now)); + + expect(distances).toEqual([...distances].sort((a, b) => a - b)); +} + +function expectOrderedNewestFirst(times: number[]) { + expect(times).toEqual([...times].sort((a, b) => b - a)); +} + // --------------------------------------------------------------------------- // Suite // --------------------------------------------------------------------------- @@ -81,6 +105,18 @@ test.describe("Dashboard tracker — student1", () => { await expect(firstCard.locator("svg").first()).toBeVisible(); }); + test("active tab orders party cards closest to now", async ({ page }) => { + const activePanel = page.getByRole("tabpanel", { name: "Active" }); + await expect(activePanel).toBeVisible(); + await expect( + activePanel.locator(".border-b.border-gray-200").first() + ).toBeVisible(); + + const times = await getPartyCardTimes(activePanel); + expect(times.length).toBeGreaterThan(1); + expectOrderedClosestToNow(times); + }); + test("past tab: party cards have no action menu", async ({ page }) => { await page.getByRole("tab", { name: "Past" }).click(); @@ -99,6 +135,50 @@ test.describe("Dashboard tracker — student1", () => { page.getByRole("button", { name: "Party actions" }) ).toHaveCount(0); }); + + test("past tab loads more party cards when scrolled", async ({ page }) => { + await page.getByRole("tab", { name: "Past" }).click(); + await expect(page.getByRole("tab", { name: "Past" })).toHaveAttribute( + "aria-selected", + "true" + ); + + const pastPanel = page.getByRole("tabpanel", { name: "Past" }); + await expect(pastPanel).toBeVisible(); + + const cards = pastPanel.locator(".border-b.border-gray-200"); + await expect(cards.first()).toBeVisible(); + const initialCount = await cards.count(); + + const scrollContainer = pastPanel.locator(":scope > div").first(); + await scrollContainer.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + + await expect + .poll(() => cards.count(), { + message: "Past parties should load another page after scrolling", + }) + .toBeGreaterThan(initialCount); + }); + + test("past tab orders party cards newest first", async ({ page }) => { + await page.getByRole("tab", { name: "Past" }).click(); + await expect(page.getByRole("tab", { name: "Past" })).toHaveAttribute( + "aria-selected", + "true" + ); + + const pastPanel = page.getByRole("tabpanel", { name: "Past" }); + await expect(pastPanel).toBeVisible(); + await expect( + pastPanel.locator(".border-b.border-gray-200").first() + ).toBeVisible(); + + const times = await getPartyCardTimes(pastPanel); + expect(times.length).toBeGreaterThan(1); + expectOrderedNewestFirst(times); + }); }); // ------------------------------------------------------------------------- diff --git a/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx b/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx index 87eac093..a9fe1c12 100644 --- a/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx +++ b/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx @@ -7,6 +7,7 @@ import { Card } from "@/components/ui/card"; import { SkeletonText } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useSnackbar } from "@/contexts/SnackbarContext"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; import { NestedIncidentStudentDto } from "@/lib/api/incident/incident.types"; import { hasActiveHold } from "@/lib/api/location/location.service"; import { useDeleteParty } from "@/lib/api/party/party.queries"; @@ -23,6 +24,9 @@ import { useState } from "react"; import RegistrationIncidentCard from "./RegistrationIncidentCard"; import RegistrationPartyCard from "./RegistrationPartyCard"; +const PAST_PAGE_SIZE = 1; +const INITIAL_PAST_VISIBLE_COUNT = 5; + const EMPTY_CLASS = "flex h-full items-center justify-center px-12 text-center content-sub text-base!"; @@ -100,6 +104,9 @@ export default function RegistrationTracker(): React.JSX.Element { const [activeTab, setActiveTab] = useState("active"); const [editParty, setEditParty] = useState(null); const [deleteParty, setDeleteParty] = useState(null); + const [pastScrollRoot, setPastScrollRoot] = useState( + null + ); const { openSnackbar } = useSnackbar(); const deletePartyMutation = useDeleteParty(); @@ -132,6 +139,16 @@ export default function RegistrationTracker(): React.JSX.Element { : undefined; const { activeParties, pastParties } = splitParties(partiesQuery.data ?? []); + const [pastVisibleCount, pastSentinelRef] = useInfiniteScroll( + pastParties.length, + PAST_PAGE_SIZE, + { + initialCount: INITIAL_PAST_VISIBLE_COUNT, + root: pastScrollRoot, + rootMargin: "200px 0px", + } + ); + const visiblePastParties = pastParties.slice(0, pastVisibleCount); const hasNoParties = (partiesQuery.data?.length ?? 0) === 0; const showPartySmartPrompt = hasNoParties && !courseCompleted; @@ -182,17 +199,24 @@ export default function RegistrationTracker(): React.JSX.Element { pastParties.length === 0 ? (

Your party history will appear here.

) : ( - pastParties.map((party) => ( - - )) + <> + {visiblePastParties.map((party) => ( + + ))} + {pastVisibleCount < pastParties.length && ( +
+ +
+ )} + ), }, { @@ -260,7 +284,10 @@ export default function RegistrationTracker(): React.JSX.Element { {tabs.map(({ value, children }) => ( -
+
{isPartiesPending ? ( ) : isPartiesError ? ( diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx index 3b60bb78..48093836 100644 --- a/frontend/src/components/ui/skeleton.tsx +++ b/frontend/src/components/ui/skeleton.tsx @@ -5,10 +5,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
); @@ -19,8 +16,8 @@ function SkeletonAvatar({ className, ...props }: React.ComponentProps<"div">) {
- - + +
); diff --git a/frontend/src/hooks/useInfiniteScroll.ts b/frontend/src/hooks/useInfiniteScroll.ts new file mode 100644 index 00000000..cab3265b --- /dev/null +++ b/frontend/src/hooks/useInfiniteScroll.ts @@ -0,0 +1,136 @@ +import { + type RefCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +type InfiniteScrollOptions = { + initialCount?: number; + root?: Element | null; + rootMargin?: string; + threshold?: number | number[]; +}; + +function getVerticalRootMargin(rootMargin: string): { + top: number; + bottom: number; +} { + const parts = rootMargin.trim().split(/\s+/); + const [top, right = top, bottom = top, left = right] = parts; + void left; + + const toPixels = (value: string | undefined) => + value?.endsWith("px") ? Number.parseFloat(value) : 0; + + return { + top: toPixels(top), + bottom: toPixels(bottom), + }; +} + +export function useInfiniteScroll( + total: number, + pageSize: number, + { + initialCount = pageSize, + root = null, + rootMargin = "0px", + threshold = 0, + }: InfiniteScrollOptions = {} +): [number, RefCallback] { + const [visibleCount, setVisibleCount] = useState(initialCount); + const observerRef = useRef(null); + const nodeRef = useRef(null); + const rootRef = useRef(root); + const rootMarginRef = useRef(rootMargin); + const thresholdRef = useRef(threshold); + const totalRef = useRef(total); + const pageSizeRef = useRef(pageSize); + const sentinelRef = useRef>((node) => { + nodeRef.current = node; + + if (observerRef.current) { + observerRef.current?.disconnect(); + observerRef.current = null; + } + if (!node) return; + + observerRef.current = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisibleCount((prev) => + Math.min(prev + pageSizeRef.current, totalRef.current) + ); + } + }, + { + root: rootRef.current, + rootMargin: rootMarginRef.current, + threshold: thresholdRef.current, + } + ); + observerRef.current.observe(node); + }).current; + + useEffect(() => { + totalRef.current = total; + pageSizeRef.current = pageSize; + }); + + useEffect(() => { + setVisibleCount((prev) => { + if (prev > total) return Math.max(total, pageSize); + if (prev < initialCount) return initialCount; + if (pageSize !== pageSizeRef.current) return pageSize; + return prev; + }); + }, [initialCount, total, pageSize]); + + useEffect(() => { + rootRef.current = root; + rootMarginRef.current = rootMargin; + thresholdRef.current = threshold; + + const node = nodeRef.current; + if (!node) return; + + sentinelRef(null); + sentinelRef(node); + }, [root, rootMargin, sentinelRef, threshold]); + + useLayoutEffect(() => { + const node = nodeRef.current; + if (!node || visibleCount >= total) return; + + const nodeRect = node.getBoundingClientRect(); + const rootRect = rootRef.current?.getBoundingClientRect() ?? { + top: 0, + bottom: window.innerHeight, + }; + const { top, bottom } = getVerticalRootMargin(rootMarginRef.current); + const isVisible = + nodeRect.bottom >= rootRect.top - top && + nodeRect.top <= rootRect.bottom + bottom; + + if (isVisible) { + setVisibleCount((prev) => + Math.min(prev + pageSizeRef.current, totalRef.current) + ); + return; + } + + sentinelRef(null); + sentinelRef(node); + }, [sentinelRef, total, visibleCount]); + + useEffect(() => { + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + }; + }, []); + + return [visibleCount, sentinelRef]; +}