From ab0c0000f3af7dde460e3f60b8a715c8de34720c Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Sun, 1 Mar 2026 22:57:53 -0800 Subject: [PATCH 1/3] fix(calendar-view): persist current date when moving from list to week views --- src/app/(authorized)/schedule/schedule-calendar-view.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/(authorized)/schedule/schedule-calendar-view.tsx b/src/app/(authorized)/schedule/schedule-calendar-view.tsx index 036035de..eddd51f1 100644 --- a/src/app/(authorized)/schedule/schedule-calendar-view.tsx +++ b/src/app/(authorized)/schedule/schedule-calendar-view.tsx @@ -35,6 +35,7 @@ export function ScheduleCalendarView() { return ( ({ id: shift.id, From 610a8e294fb948cd4328855b7101e2ae2660cb03 Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Tue, 3 Mar 2026 22:31:23 -0800 Subject: [PATCH 2/3] feat: aside state persisted in query params --- src/app/(authorized)/classes/page.tsx | 14 +++- src/app/(authorized)/coverage/page.tsx | 18 ++++- src/app/(authorized)/schedule/page.tsx | 12 +++- .../classes/list/class-list-view.tsx | 42 +++++++---- .../coverage/list/coverage-aside.tsx | 22 ++++-- .../coverage/list/coverage-page-context.tsx | 71 +++++++++---------- .../primitives/fill-coverage-button.tsx | 2 +- .../primitives/withdraw-coverage-button.tsx | 2 +- src/components/page-layout.tsx | 24 +++++-- .../schedule/schedule-page-context.tsx | 49 +++++++++---- src/models/class.ts | 1 + src/server/api/routers/coverage-router.ts | 25 +++++++ 12 files changed, 197 insertions(+), 85 deletions(-) diff --git a/src/app/(authorized)/classes/page.tsx b/src/app/(authorized)/classes/page.tsx index 4f0e36d7..35746524 100644 --- a/src/app/(authorized)/classes/page.tsx +++ b/src/app/(authorized)/classes/page.tsx @@ -1,10 +1,20 @@ +"use client"; + import { ClassListView } from "@/components/classes/list/class-list-view"; import { PageLayout } from "@/components/page-layout"; +import { parseAsString, useQueryState } from "nuqs"; export default function ClassesPage() { + const [classId, setClassId] = useQueryState("classId", parseAsString); + return ( - - + { + if (!open) setClassId(null); + }} + > + ); } diff --git a/src/app/(authorized)/coverage/page.tsx b/src/app/(authorized)/coverage/page.tsx index e084cdb8..910acc50 100644 --- a/src/app/(authorized)/coverage/page.tsx +++ b/src/app/(authorized)/coverage/page.tsx @@ -14,11 +14,25 @@ import { Suspense } from "react"; import { CoveragePageProvider } from "@/components/coverage/list/coverage-page-context"; import { CoverageAside } from "@/components/coverage/list/coverage-aside"; import { SkeletonAside } from "@/components/ui/skeleton"; +import { parseAsString, useQueryState } from "nuqs"; export default function CoveragePage() { + const [coverageId, setCoverageId] = useQueryState( + "coverageId", + parseAsString, + ); + return ( - - + { + if (!open) setCoverageId(null); + }} + > + }> diff --git a/src/app/(authorized)/schedule/page.tsx b/src/app/(authorized)/schedule/page.tsx index 3ccc7703..975cd703 100644 --- a/src/app/(authorized)/schedule/page.tsx +++ b/src/app/(authorized)/schedule/page.tsx @@ -12,7 +12,7 @@ import { import { SchedulePageControls } from "@/components/schedule/schedule-page-controls"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { cn } from "@/lib/utils"; -import { parseAsStringEnum, useQueryState } from "nuqs"; +import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs"; import { Activity, Suspense } from "react"; import { SchedulePageProvider } from "../../../components/schedule/schedule-page-context"; import { ShiftDetailsAside } from "../../../components/schedule/shift-details-aside"; @@ -29,11 +29,17 @@ export default function SchedulePage() { .withDefault("list") .withOptions({ clearOnDefault: false }), ); + const [shiftId, setShiftId] = useQueryState("shiftId", parseAsString); return ( - + { + if (!open) setShiftId(null); + }} + > - + Schedule diff --git a/src/components/classes/list/class-list-view.tsx b/src/components/classes/list/class-list-view.tsx index 33713b00..ae913500 100644 --- a/src/components/classes/list/class-list-view.tsx +++ b/src/components/classes/list/class-list-view.tsx @@ -26,7 +26,6 @@ import { PageLayoutHeader, PageLayoutHeaderContent, PageLayoutHeaderTitle, - usePageAside, } from "@/components/page-layout"; import { ClassList } from "./content/class-list"; import { ClassListSkeleton } from "./class-list-skeleton"; @@ -57,10 +56,13 @@ export function useClassesPage() { return ctx; } -export function ClassListView() { - const { setOpen } = usePageAside(); +type ClassListViewProps = { + classId: string | null; + setClassId: (id: string | null) => Promise; +}; + +export function ClassListView({ classId, setClassId }: ClassListViewProps) { const contentScrollRef = useRef(null); - const [selectedClassId, setSelectedClassId] = useState(null); const [selectedTermId, setSelectedTermId] = useState(null); const [queryTerm, setQueryTerm] = useQueryState( "term", @@ -69,16 +71,28 @@ export function ClassListView() { const openAsideFor = useCallback( (id: string) => { - setSelectedClassId(id); - setOpen(true); + setClassId(id); }, - [setOpen, setSelectedClassId], + [setClassId], ); const closeAside = useCallback(() => { - setSelectedClassId(null); - setOpen(false); - }, [setOpen, setSelectedClassId]); + setClassId(null); + }, [setClassId]); + + // When a classId is present (e.g. from URL), fetch the class and navigate + // to its term so the list shows the correct term's classes. + const { data: linkedClass } = clientApi.class.byId.useQuery( + { classId: classId ?? "" }, + { enabled: !!classId }, + ); + + useEffect(() => { + if (linkedClass?.termId && linkedClass.termId !== selectedTermId) { + setSelectedTermId(linkedClass.termId); + setQueryTerm(linkedClass.termId); + } + }, [linkedClass?.termId]); const canCreateTerm = usePermission({ permission: { terms: ["create"] } }); @@ -139,22 +153,22 @@ export function ClassListView() { const contextValue = useMemo( () => ({ - selectedClassId, + selectedClassId: classId, selectedTermId, queryTerm, hasTerms, - setSelectedClassId, + setSelectedClassId: setClassId, setSelectedTermId: handleSelectTerm, openAsideFor, closeAside, contentScrollRef, }), [ - selectedClassId, + classId, selectedTermId, queryTerm, hasTerms, - setSelectedClassId, + setClassId, handleSelectTerm, openAsideFor, closeAside, diff --git a/src/components/coverage/list/coverage-aside.tsx b/src/components/coverage/list/coverage-aside.tsx index 79c0ea99..67d5bbd0 100644 --- a/src/components/coverage/list/coverage-aside.tsx +++ b/src/components/coverage/list/coverage-aside.tsx @@ -18,13 +18,16 @@ import { WithPermission } from "@/components/utils/with-permission"; import { CoverageRequestCategory, CoverageStatus } from "@/models/api/coverage"; import { useAuth } from "@/providers/client-auth-provider"; import { ChevronLeft, ChevronRight } from "lucide-react"; -import { useCoveragePage } from "./coverage-page-context"; +import { + useCoveragePage, + type CoverageListItem, +} from "./coverage-page-context"; import { FillCoverageButton } from "@/components/coverage/primitives/fill-coverage-button"; import { WithdrawCoverageButton } from "@/components/coverage/primitives/withdraw-coverage-button"; -import { useEffect } from "react"; import { Separator } from "@/components/ui/separator"; import { UserList } from "@/components/users/user-list"; import { Button } from "@/components/ui/button"; +import { clientApi } from "@/trpc/client"; const dateFormatter = new Intl.DateTimeFormat("en-US", { month: "long", @@ -39,12 +42,19 @@ const timeFormatter = new Intl.DateTimeFormat("en-US", { }); export function CoverageAside() { - const { selectedItem, closeAside, goToNext, goToPrev } = useCoveragePage(); + const { selectedCoverageId, goToNext, goToPrev } = + useCoveragePage(); const { user } = useAuth(); - useEffect(() => { - if (!selectedItem) closeAside(); - }, [selectedItem, closeAside]); + const { data } = clientApi.coverage.byId.useQuery( + { coverageRequestId: selectedCoverageId ?? "" }, + { + enabled: !!selectedCoverageId, + suspense: !!selectedCoverageId, + meta: { suppressToast: true }, + }, + ); + const selectedItem = data as CoverageListItem | undefined; if (!selectedItem) return null; diff --git a/src/components/coverage/list/coverage-page-context.tsx b/src/components/coverage/list/coverage-page-context.tsx index 1e3dfd68..71a8ab04 100644 --- a/src/components/coverage/list/coverage-page-context.tsx +++ b/src/components/coverage/list/coverage-page-context.tsx @@ -1,6 +1,5 @@ "use client"; -import { usePageAside } from "@/components/page-layout"; import { createContext, useCallback, @@ -18,7 +17,7 @@ export type CoverageListItem = | ListCoverageRequestWithReason; type CoveragePageContextValue = { - selectedItem: CoverageListItem | null; + selectedCoverageId: string | null; sortedItems: CoverageListItem[]; openAsideFor: (item: CoverageListItem) => void; closeAside: () => void; @@ -39,61 +38,55 @@ export function useCoveragePage() { return ctx; } -export function CoveragePageProvider({ children }: PropsWithChildren) { - const { setOpen } = usePageAside(); - const [selectedCoverageRequest, setSelectedCoverageRequest] = - useState(null); +type CoveragePageProviderProps = PropsWithChildren<{ + coverageId: string | null; + setCoverageId: (id: string | null) => Promise; +}>; + +export function CoveragePageProvider({ + coverageId, + setCoverageId, + children, +}: CoveragePageProviderProps) { const [coverageRequests, setCoverageRequests] = useState( [], ); const openAsideFor = useCallback( (item: CoverageListItem) => { - setSelectedCoverageRequest(item); - setOpen(true); + setCoverageId(item.id); }, - [setOpen], + [setCoverageId], ); const closeAside = useCallback(() => { - setSelectedCoverageRequest(null); - setOpen(false); - }, [setOpen]); + setCoverageId(null); + }, [setCoverageId]); const goToNext = useCallback(() => { - setSelectedCoverageRequest((current) => { - if (!current) return current; - const currentIndex = coverageRequests.findIndex( - (item) => item.id === current.id, - ); - if (currentIndex < coverageRequests.length - 1) { - const next = coverageRequests[currentIndex + 1]!; - setOpen(true); - return next; - } - return current; - }); - }, [coverageRequests, setOpen]); + if (!coverageId) return; + const currentIndex = coverageRequests.findIndex( + (item) => item.id === coverageId, + ); + if (currentIndex >= 0 && currentIndex < coverageRequests.length - 1) { + setCoverageId(coverageRequests[currentIndex + 1]!.id); + } + }, [coverageRequests, coverageId, setCoverageId]); const goToPrev = useCallback(() => { - setSelectedCoverageRequest((current) => { - if (!current) return current; - const currentIndex = coverageRequests.findIndex( - (item) => item.id === current.id, - ); - if (currentIndex > 0) { - const prev = coverageRequests[currentIndex - 1]!; - setOpen(true); - return prev; - } - return current; - }); - }, [coverageRequests, setOpen]); + if (!coverageId) return; + const currentIndex = coverageRequests.findIndex( + (item) => item.id === coverageId, + ); + if (currentIndex > 0) { + setCoverageId(coverageRequests[currentIndex - 1]!.id); + } + }, [coverageRequests, coverageId, setCoverageId]); return ( { - void apiUtils.coverage.list.invalidate(); + void apiUtils.coverage.invalidate(); }, }); diff --git a/src/components/coverage/primitives/withdraw-coverage-button.tsx b/src/components/coverage/primitives/withdraw-coverage-button.tsx index 795ee27e..b92f7638 100644 --- a/src/components/coverage/primitives/withdraw-coverage-button.tsx +++ b/src/components/coverage/primitives/withdraw-coverage-button.tsx @@ -18,7 +18,7 @@ export function WithdrawCoverageButton({ const { mutate: cancelCoverageRequest, isPending } = clientApi.coverage.cancelCoverageRequest.useMutation({ onSuccess: () => { - void apiUtils.coverage.list.invalidate(); + void apiUtils.coverage.invalidate(); }, }); diff --git a/src/components/page-layout.tsx b/src/components/page-layout.tsx index 632adb9e..10b8453e 100644 --- a/src/components/page-layout.tsx +++ b/src/components/page-layout.tsx @@ -40,13 +40,20 @@ function PageLayout({ asideWidth = "448px", mainMinWidth = "412px", defaultOpen = false, + open: controlledOpen, + onOpenChange, ...props }: React.ComponentProps<"div"> & { asideWidth?: string; mainMinWidth?: string; defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [isOpen, setIsOpen] = React.useState(defaultOpen); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + const [isPageScrolled, setIsPageScrolled] = React.useState(false); const [asideCount, setAsideCount] = React.useState(0); const [headerHeight, setHeaderHeight] = React.useState(0); @@ -57,14 +64,23 @@ function PageLayout({ }, []); const hasAside = asideCount > 0; - const toggle = React.useCallback(() => setIsOpen((v) => !v), []); + + const setOpen = React.useCallback( + (value: boolean) => { + if (!isControlled) setUncontrolledOpen(value); + onOpenChange?.(value); + }, + [isControlled, onOpenChange], + ); + + const toggle = React.useCallback(() => setOpen(!isOpen), [setOpen, isOpen]); const state = isOpen && hasAside ? "open" : "closed"; const ctx = React.useMemo( () => ({ isOpen, - setOpen: setIsOpen, + setOpen, toggle, registerAside, hasAside, @@ -77,7 +93,7 @@ function PageLayout({ }), [ isOpen, - setIsOpen, + setOpen, toggle, registerAside, hasAside, diff --git a/src/components/schedule/schedule-page-context.tsx b/src/components/schedule/schedule-page-context.tsx index 0c863bf6..b7c87ce9 100644 --- a/src/components/schedule/schedule-page-context.tsx +++ b/src/components/schedule/schedule-page-context.tsx @@ -1,10 +1,11 @@ "use client"; -import { usePageAside } from "@/components/page-layout"; +import { clientApi } from "@/trpc/client"; import { createContext, useCallback, useContext, + useEffect, useMemo, useState, type PropsWithChildren, @@ -30,33 +31,55 @@ export function useSchedulePage() { return ctx; } -export function SchedulePageProvider({ children }: PropsWithChildren) { - const { setOpen } = usePageAside(); - const [selectedShiftId, setSelectedShiftId] = useState(null); +type SchedulePageProviderProps = PropsWithChildren<{ + shiftId: string | null; + setShiftId: (id: string | null) => Promise; +}>; + +export function SchedulePageProvider({ + shiftId, + setShiftId, + children, +}: SchedulePageProviderProps) { const [selectedDate, setSelectedDate] = useState(new Date()); + // When a shiftId is present (e.g. from URL), fetch the shift and navigate + // to its date so the list/calendar shows the correct month. + const { data: shiftData } = clientApi.shift.byId.useQuery( + { shiftId: shiftId ?? "" }, + { enabled: !!shiftId }, + ); + + useEffect(() => { + if (shiftData?.startAt) { + const shiftDate = + shiftData.startAt instanceof Date + ? shiftData.startAt + : new Date(shiftData.startAt); + setSelectedDate(shiftDate); + } + }, [shiftData]); + const openAsideFor = useCallback( - (shiftId: string) => { - setSelectedShiftId(shiftId); - setOpen(true); + (id: string) => { + setShiftId(id); }, - [setOpen], + [setShiftId], ); const closeAside = useCallback(() => { - setSelectedShiftId(null); - setOpen(false); - }, [setOpen]); + setShiftId(null); + }, [setShiftId]); const value = useMemo( () => ({ - selectedShiftId, + selectedShiftId: shiftId, selectedDate, setSelectedDate, openAsideFor, closeAside, }), - [selectedShiftId, selectedDate, setSelectedDate, openAsideFor, closeAside], + [shiftId, selectedDate, setSelectedDate, openAsideFor, closeAside], ); return ( diff --git a/src/models/class.ts b/src/models/class.ts index 404e08c5..893842ed 100644 --- a/src/models/class.ts +++ b/src/models/class.ts @@ -44,6 +44,7 @@ export function buildClass( export function getSingleClass(c: Class) { return { id: c.id, + termId: c.termId, name: c.name, description: c.description, image: c.image, diff --git a/src/server/api/routers/coverage-router.ts b/src/server/api/routers/coverage-router.ts index 53d2e09d..edc23f87 100644 --- a/src/server/api/routers/coverage-router.ts +++ b/src/server/api/routers/coverage-router.ts @@ -3,11 +3,36 @@ import { CreateCoverageRequest, ListCoverageRequestsInput, } from "@/models/api/coverage"; +import { + getListCoverageRequestBase, + getListCoverageRequestWithReason, +} from "@/models/coverage"; import { Role } from "@/models/interfaces"; +import { hasPermission } from "@/lib/auth/extensions/permissions"; import { authorizedProcedure } from "@/server/api/procedures"; import { createTRPCRouter } from "@/server/api/trpc"; export const coverageRouter = createTRPCRouter({ + byId: authorizedProcedure({ + permission: { coverage: ["view"] }, + }) + .input(CoverageRequestIdInput) + .query(async ({ input, ctx }) => { + const currentUser = ctx.currentSessionService.requireUser(); + const request = await ctx.coverageService.getCoverageRequestById( + input.coverageRequestId, + ); + + if ( + hasPermission({ + role: currentUser.role as Role, + permission: { shifts: ["view-all"] }, + }) + ) { + return getListCoverageRequestWithReason(request); + } + return getListCoverageRequestBase(request); + }), list: authorizedProcedure({ permission: { coverage: ["view"] }, }) From 45463e4035ce388ddcc6974fb9a43d1d1a3aa8c5 Mon Sep 17 00:00:00 2001 From: theosiemensrhodes Date: Tue, 3 Mar 2026 23:23:34 -0800 Subject: [PATCH 3/3] fix: greptile reported bugs --- src/components/classes/list/class-details-aside.tsx | 2 +- src/components/classes/list/class-list-view.tsx | 11 +++++++---- src/components/coverage/list/coverage-aside.tsx | 8 ++++---- .../coverage/list/coverage-page-context.tsx | 13 +++++++++++++ src/components/schedule/schedule-page-context.tsx | 6 ++++-- src/components/schedule/shift-details-aside.tsx | 2 +- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/components/classes/list/class-details-aside.tsx b/src/components/classes/list/class-details-aside.tsx index 7b847b90..74b3ba4e 100644 --- a/src/components/classes/list/class-details-aside.tsx +++ b/src/components/classes/list/class-details-aside.tsx @@ -42,7 +42,7 @@ export function ClassDetailsAside() { { classId: selectedClassId ?? "" }, { enabled: !!selectedClassId, - suspense: !!selectedClassId, + suspense: true, meta: { suppressToast: true }, }, ); diff --git a/src/components/classes/list/class-list-view.tsx b/src/components/classes/list/class-list-view.tsx index ae913500..a9265afd 100644 --- a/src/components/classes/list/class-list-view.tsx +++ b/src/components/classes/list/class-list-view.tsx @@ -88,11 +88,14 @@ export function ClassListView({ classId, setClassId }: ClassListViewProps) { ); useEffect(() => { - if (linkedClass?.termId && linkedClass.termId !== selectedTermId) { - setSelectedTermId(linkedClass.termId); - setQueryTerm(linkedClass.termId); + if (linkedClass?.termId) { + setSelectedTermId((prev) => { + if (prev === linkedClass.termId) return prev; + setQueryTerm(linkedClass.termId); + return linkedClass.termId; + }); } - }, [linkedClass?.termId]); + }, [linkedClass?.termId, setQueryTerm]); const canCreateTerm = usePermission({ permission: { terms: ["create"] } }); diff --git a/src/components/coverage/list/coverage-aside.tsx b/src/components/coverage/list/coverage-aside.tsx index 67d5bbd0..25fa8934 100644 --- a/src/components/coverage/list/coverage-aside.tsx +++ b/src/components/coverage/list/coverage-aside.tsx @@ -42,7 +42,7 @@ const timeFormatter = new Intl.DateTimeFormat("en-US", { }); export function CoverageAside() { - const { selectedCoverageId, goToNext, goToPrev } = + const { selectedCoverageId, goToNext, goToPrev, hasNext, hasPrev } = useCoveragePage(); const { user } = useAuth(); @@ -50,7 +50,7 @@ export function CoverageAside() { { coverageRequestId: selectedCoverageId ?? "" }, { enabled: !!selectedCoverageId, - suspense: !!selectedCoverageId, + suspense: true, meta: { suppressToast: true }, }, ); @@ -218,10 +218,10 @@ export function CoverageAside() {
- -
diff --git a/src/components/coverage/list/coverage-page-context.tsx b/src/components/coverage/list/coverage-page-context.tsx index 71a8ab04..e676de42 100644 --- a/src/components/coverage/list/coverage-page-context.tsx +++ b/src/components/coverage/list/coverage-page-context.tsx @@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, + useMemo, useState, type PropsWithChildren, } from "react"; @@ -24,6 +25,8 @@ type CoveragePageContextValue = { setSortedItems: (items: CoverageListItem[]) => void; goToNext: () => void; goToPrev: () => void; + hasNext: boolean; + hasPrev: boolean; }; const CoveragePageContext = createContext( @@ -83,6 +86,14 @@ export function CoveragePageProvider({ } }, [coverageRequests, coverageId, setCoverageId]); + const currentIndex = useMemo(() => { + if (!coverageId) return -1; + return coverageRequests.findIndex((item) => item.id === coverageId); + }, [coverageRequests, coverageId]); + + const hasNext = currentIndex >= 0 && currentIndex < coverageRequests.length - 1; + const hasPrev = currentIndex > 0; + return ( {children} diff --git a/src/components/schedule/schedule-page-context.tsx b/src/components/schedule/schedule-page-context.tsx index b7c87ce9..83dd4a33 100644 --- a/src/components/schedule/schedule-page-context.tsx +++ b/src/components/schedule/schedule-page-context.tsx @@ -56,9 +56,11 @@ export function SchedulePageProvider({ shiftData.startAt instanceof Date ? shiftData.startAt : new Date(shiftData.startAt); - setSelectedDate(shiftDate); + setSelectedDate((prev) => + prev.toISOString() === shiftDate.toISOString() ? prev : shiftDate, + ); } - }, [shiftData]); + }, [shiftData?.startAt]); const openAsideFor = useCallback( (id: string) => { diff --git a/src/components/schedule/shift-details-aside.tsx b/src/components/schedule/shift-details-aside.tsx index 0faaa214..7e25adc2 100644 --- a/src/components/schedule/shift-details-aside.tsx +++ b/src/components/schedule/shift-details-aside.tsx @@ -39,7 +39,7 @@ export function ShiftDetailsAside() { { shiftId: selectedShiftId ?? "" }, { enabled: !!selectedShiftId, - suspense: !!selectedShiftId, + suspense: true, meta: { suppressToast: true }, }, );