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
14 changes: 12 additions & 2 deletions src/app/(authorized)/classes/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PageLayout>
<ClassListView />
<PageLayout
open={!!classId}
onOpenChange={(open) => {
if (!open) setClassId(null);
}}
>
<ClassListView classId={classId} setClassId={setClassId} />
</PageLayout>
);
}
18 changes: 16 additions & 2 deletions src/app/(authorized)/coverage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<PageLayout>
<CoveragePageProvider>
<PageLayout
open={!!coverageId}
onOpenChange={(open) => {
if (!open) setCoverageId(null);
}}
>
<CoveragePageProvider
coverageId={coverageId}
setCoverageId={setCoverageId}
>
<PageLayoutAside>
<Suspense fallback={<SkeletonAside />}>
<CoverageAside />
Expand Down
12 changes: 9 additions & 3 deletions src/app/(authorized)/schedule/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,11 +29,17 @@ export default function SchedulePage() {
.withDefault("list")
.withOptions({ clearOnDefault: false }),
);
const [shiftId, setShiftId] = useQueryState("shiftId", parseAsString);

return (
<PageLayout>
<PageLayout
open={!!shiftId}
onOpenChange={(open) => {
if (!open) setShiftId(null);
}}
>
<FullCalendarProvider>
<SchedulePageProvider>
<SchedulePageProvider shiftId={shiftId} setShiftId={setShiftId}>
<PageLayoutHeader hideShadow border="always">
<PageLayoutHeaderContent className="items-center">
<PageLayoutHeaderTitle>Schedule</PageLayoutHeaderTitle>
Expand Down
1 change: 1 addition & 0 deletions src/app/(authorized)/schedule/schedule-calendar-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function ScheduleCalendarView() {

return (
<FullCalendar
initialDate={selectedDate}
eventClick={handleEventClick}
events={scheduleShifts.map((shift) => ({
id: shift.id,
Expand Down
2 changes: 1 addition & 1 deletion src/components/classes/list/class-details-aside.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function ClassDetailsAside() {
{ classId: selectedClassId ?? "" },
{
enabled: !!selectedClassId,
suspense: !!selectedClassId,
suspense: true,
meta: { suppressToast: true },
},
);
Expand Down
45 changes: 31 additions & 14 deletions src/components/classes/list/class-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<URLSearchParams>;
};

export function ClassListView({ classId, setClassId }: ClassListViewProps) {
const contentScrollRef = useRef<HTMLDivElement>(null);
const [selectedClassId, setSelectedClassId] = useState<string | null>(null);
const [selectedTermId, setSelectedTermId] = useState<string | null>(null);
const [queryTerm, setQueryTerm] = useQueryState(
"term",
Expand All @@ -69,16 +71,31 @@ 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) {
setSelectedTermId((prev) => {
if (prev === linkedClass.termId) return prev;
setQueryTerm(linkedClass.termId);
return linkedClass.termId;
});
}
}, [linkedClass?.termId, setQueryTerm]);
Comment on lines +90 to +98
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side effect inside a setState updater function

setQueryTerm(linkedClass.termId) is called inside the setSelectedTermId updater function. React's updater functions are expected to be pure — they must not produce side effects. In React Strict Mode (enabled by default in development), React intentionally invokes updaters twice to surface these violations; this will cause setQueryTerm (and its URL mutation) to fire twice on every effect run.

Perform both state mutations directly instead of nesting one inside the other:

Suggested change
useEffect(() => {
if (linkedClass?.termId) {
setSelectedTermId((prev) => {
if (prev === linkedClass.termId) return prev;
setQueryTerm(linkedClass.termId);
return linkedClass.termId;
});
}
}, [linkedClass?.termId, setQueryTerm]);
useEffect(() => {
if (linkedClass?.termId && linkedClass.termId !== selectedTermId) {
setSelectedTermId(linkedClass.termId);
void setQueryTerm(linkedClass.termId);
}
}, [linkedClass?.termId, selectedTermId, setQueryTerm, setSelectedTermId]);


const canCreateTerm = usePermission({ permission: { terms: ["create"] } });

Expand Down Expand Up @@ -139,22 +156,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,
Expand Down
26 changes: 18 additions & 8 deletions src/components/coverage/list/coverage-aside.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -39,12 +42,19 @@ const timeFormatter = new Intl.DateTimeFormat("en-US", {
});

export function CoverageAside() {
const { selectedItem, closeAside, goToNext, goToPrev } = useCoveragePage();
const { selectedCoverageId, goToNext, goToPrev, hasNext, hasPrev } =
useCoveragePage();
const { user } = useAuth();

useEffect(() => {
if (!selectedItem) closeAside();
}, [selectedItem, closeAside]);
const { data } = clientApi.coverage.byId.useQuery(
{ coverageRequestId: selectedCoverageId ?? "" },
{
enabled: !!selectedCoverageId,
suspense: true,
meta: { suppressToast: true },
},
);
const selectedItem = data as CoverageListItem | undefined;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe type cast may hide server/client type mismatch

data as CoverageListItem | undefined forcefully asserts the tRPC response to CoverageListItem. The server returns a union of getListCoverageRequestBase / getListCoverageRequestWithReason, and CoverageListItem is defined as ListCoverageRequestBase | ListCoverageRequestWithReason. If the inferred tRPC return type doesn't match — for instance, because of a date serialization difference where tRPC returns a string but the model expects a Date — the cast will silence the error and cause a silent runtime failure.

Remove the cast and fix any TypeScript errors surfaced by the raw data type instead, or add a Zod/tRPC validation schema on the client side.


if (!selectedItem) return null;

Expand Down Expand Up @@ -208,10 +218,10 @@ export function CoverageAside() {

<AsideFooter>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => goToPrev()}>
<Button variant="ghost" disabled={!hasPrev} onClick={() => goToPrev()}>
<ChevronLeft></ChevronLeft>
</Button>
<Button variant="ghost" onClick={() => goToNext()}>
<Button variant="ghost" disabled={!hasNext} onClick={() => goToNext()}>
<ChevronRight></ChevronRight>
</Button>
</div>
Expand Down
84 changes: 45 additions & 39 deletions src/components/coverage/list/coverage-page-context.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"use client";

import { usePageAside } from "@/components/page-layout";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type PropsWithChildren,
} from "react";
Expand All @@ -18,13 +18,15 @@ export type CoverageListItem =
| ListCoverageRequestWithReason;

type CoveragePageContextValue = {
selectedItem: CoverageListItem | null;
selectedCoverageId: string | null;
sortedItems: CoverageListItem[];
openAsideFor: (item: CoverageListItem) => void;
closeAside: () => void;
setSortedItems: (items: CoverageListItem[]) => void;
goToNext: () => void;
goToPrev: () => void;
hasNext: boolean;
hasPrev: boolean;
};

const CoveragePageContext = createContext<CoveragePageContextValue | null>(
Expand All @@ -39,67 +41,71 @@ export function useCoveragePage() {
return ctx;
}

export function CoveragePageProvider({ children }: PropsWithChildren) {
const { setOpen } = usePageAside();
const [selectedCoverageRequest, setSelectedCoverageRequest] =
useState<CoverageListItem | null>(null);
type CoveragePageProviderProps = PropsWithChildren<{
coverageId: string | null;
setCoverageId: (id: string | null) => Promise<URLSearchParams>;
}>;

export function CoveragePageProvider({
coverageId,
setCoverageId,
children,
}: CoveragePageProviderProps) {
const [coverageRequests, setCoverageRequests] = useState<CoverageListItem[]>(
[],
);

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]);

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 (
<CoveragePageContext.Provider
value={{
selectedItem: selectedCoverageRequest,
selectedCoverageId: coverageId,
sortedItems: coverageRequests,
openAsideFor,
closeAside,
setSortedItems: setCoverageRequests,
goToNext,
goToPrev,
hasNext,
hasPrev,
}}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function FillCoverageButton({
const { mutate: fillCoverageRequest, isPending } =
clientApi.coverage.fillCoverageRequest.useMutation({
onSuccess: () => {
void apiUtils.coverage.list.invalidate();
void apiUtils.coverage.invalidate();
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
});

Expand Down
Loading