From 88da1b4377481ed6ca169870cba4ce6c04108e77 Mon Sep 17 00:00:00 2001 From: VidurShah Date: Thu, 25 Jun 2026 00:31:30 -0400 Subject: [PATCH 1/3] regi-card-render limits --- .../tracker/RegistrationTracker.tsx | 37 ++++++++++----- frontend/src/hooks/useInfiniteScroll.ts | 46 +++++++++++++++++++ 2 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 frontend/src/hooks/useInfiniteScroll.ts diff --git a/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx b/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx index 87eac093..5ef57472 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,8 @@ import { useState } from "react"; import RegistrationIncidentCard from "./RegistrationIncidentCard"; import RegistrationPartyCard from "./RegistrationPartyCard"; +const PAST_PAGE_SIZE = 1; + const EMPTY_CLASS = "flex h-full items-center justify-center px-12 text-center content-sub text-base!"; @@ -132,6 +135,11 @@ export default function RegistrationTracker(): React.JSX.Element { : undefined; const { activeParties, pastParties } = splitParties(partiesQuery.data ?? []); + const [pastVisibleCount, pastSentinelRef] = useInfiniteScroll( + pastParties.length, + PAST_PAGE_SIZE + ); + const visiblePastParties = pastParties.slice(0, pastVisibleCount); const hasNoParties = (partiesQuery.data?.length ?? 0) === 0; const showPartySmartPrompt = hasNoParties && !courseCompleted; @@ -182,17 +190,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 && ( +
+ +
+ )} + ), }, { diff --git a/frontend/src/hooks/useInfiniteScroll.ts b/frontend/src/hooks/useInfiniteScroll.ts new file mode 100644 index 00000000..ee0261e6 --- /dev/null +++ b/frontend/src/hooks/useInfiniteScroll.ts @@ -0,0 +1,46 @@ +import { useEffect, useRef, useState } from "react"; + +export function useInfiniteScroll( + total: number, + pageSize: number +): [number, React.RefCallback] { + const [visibleCount, setVisibleCount] = useState(pageSize); + const observerRef = useRef(null); + const totalRef = useRef(total); + const pageSizeRef = useRef(pageSize); + + useEffect(() => { + totalRef.current = total; + pageSizeRef.current = pageSize; + }); + + useEffect(() => { + setVisibleCount((prev) => { + if (prev > total) return Math.max(total, pageSize); + if (pageSize !== pageSizeRef.current) return pageSize; + return prev; + }); + }, [total, pageSize]); + + const sentinelRef = (node: Element | null) => { + 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) + ); + } + }, + { threshold: 0 } + ); + observerRef.current.observe(node); + }; + + return [visibleCount, sentinelRef]; +} From bf4aab4cf382e6ed7dad625cfad55b45ed96586d Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Fri, 26 Jun 2026 14:08:01 -0400 Subject: [PATCH 2/3] fix: specific viewport breaks scroll updates --- .../tracker/RegistrationTracker.tsx | 16 ++- frontend/src/components/ui/skeleton.tsx | 9 +- frontend/src/hooks/useInfiniteScroll.ts | 132 +++++++++++++++--- 3 files changed, 128 insertions(+), 29 deletions(-) diff --git a/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx b/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx index 5ef57472..a9fe1c12 100644 --- a/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx +++ b/frontend/src/app/(student)/_components/tracker/RegistrationTracker.tsx @@ -25,6 +25,7 @@ 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!"; @@ -103,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(); @@ -137,7 +141,12 @@ export default function RegistrationTracker(): React.JSX.Element { const { activeParties, pastParties } = splitParties(partiesQuery.data ?? []); const [pastVisibleCount, pastSentinelRef] = useInfiniteScroll( pastParties.length, - PAST_PAGE_SIZE + PAST_PAGE_SIZE, + { + initialCount: INITIAL_PAST_VISIBLE_COUNT, + root: pastScrollRoot, + rootMargin: "200px 0px", + } ); const visiblePastParties = pastParties.slice(0, pastVisibleCount); @@ -275,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 index ee0261e6..cab3265b 100644 --- a/frontend/src/hooks/useInfiniteScroll.ts +++ b/frontend/src/hooks/useInfiniteScroll.ts @@ -1,30 +1,58 @@ -import { useEffect, useRef, useState } from "react"; +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 -): [number, React.RefCallback] { - const [visibleCount, setVisibleCount] = useState(pageSize); + 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; - useEffect(() => { - totalRef.current = total; - pageSizeRef.current = pageSize; - }); - - useEffect(() => { - setVisibleCount((prev) => { - if (prev > total) return Math.max(total, pageSize); - if (pageSize !== pageSizeRef.current) return pageSize; - return prev; - }); - }, [total, pageSize]); - - const sentinelRef = (node: Element | null) => { if (observerRef.current) { - observerRef.current.disconnect(); + observerRef.current?.disconnect(); observerRef.current = null; } if (!node) return; @@ -37,10 +65,72 @@ export function useInfiniteScroll( ); } }, - { threshold: 0 } + { + 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]; } From 373bad43941aa4881c9ff974cbd75410412fed18 Mon Sep 17 00:00:00 2001 From: Nicolas Asanov Date: Fri, 26 Jun 2026 14:24:09 -0400 Subject: [PATCH 3/3] tests: add infinite scroll e2e tests --- frontend/e2e/global-setup.ts | 25 ++++-- .../test/student/dashboard-tracker.spec.ts | 82 ++++++++++++++++++- 2 files changed, 97 insertions(+), 10 deletions(-) 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); + }); }); // -------------------------------------------------------------------------