diff --git a/app/api/people/feature/route.ts b/app/api/people/feature/route.ts new file mode 100644 index 0000000..6ed20f1 --- /dev/null +++ b/app/api/people/feature/route.ts @@ -0,0 +1,9 @@ +import { peoplePageFlag } from "@/flags"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return Response.json({ + enabled: await peoplePageFlag(), + }); +} diff --git a/app/api/schedule/[planPersonId]/route.ts b/app/api/schedule/[planPersonId]/route.ts index c3ff6e2..1918269 100644 --- a/app/api/schedule/[planPersonId]/route.ts +++ b/app/api/schedule/[planPersonId]/route.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { after } from "next/server"; import { ApiError } from "@/lib/http/api-error"; import { handlePlanningCenterRoute } from "@/lib/http/planning-center-route"; import { logger } from "@/lib/logger"; @@ -32,34 +33,36 @@ export async function DELETE( return handlePlanningCenterRoute(request, async (authContext) => { let planPersonId: string | null = null; - const recordRemoveEventSafely = async (event: { + const recordRemoveEventSafely = (event: { success: boolean; statusCode: number; errorCode: string | null; metadata?: Record; }) => { - try { - await recordActivityEvent({ - eventType: "schedule_remove", - actorUserId: authContext.session.user.id, - actorAccountId: authContext.accountId, - requestId, - path: activityRequestContext.path, - method: activityRequestContext.method, - ipAddress: activityRequestContext.ipAddress, - userAgent: activityRequestContext.userAgent, - success: event.success, - statusCode: event.statusCode, - errorCode: event.errorCode, - metadata: { - planPersonId, - ...event.metadata, - }, - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - log.warn({ err }, "Failed to record schedule remove activity event"); - } + after(async () => { + try { + await recordActivityEvent({ + eventType: "schedule_remove", + actorUserId: authContext.session.user.id, + actorAccountId: authContext.accountId, + requestId, + path: activityRequestContext.path, + method: activityRequestContext.method, + ipAddress: activityRequestContext.ipAddress, + userAgent: activityRequestContext.userAgent, + success: event.success, + statusCode: event.statusCode, + errorCode: event.errorCode, + metadata: { + planPersonId, + ...event.metadata, + }, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + log.warn({ err }, "Failed to record schedule remove activity event"); + } + }); }; try { @@ -84,7 +87,7 @@ export async function DELETE( } log.info({ planPersonId }, "PlanPerson removed successfully"); - await recordRemoveEventSafely({ + recordRemoveEventSafely({ success: true, statusCode: 200, errorCode: null, @@ -94,13 +97,13 @@ export async function DELETE( return { success: true }; } catch (error) { if (error instanceof ApiError) { - await recordRemoveEventSafely({ + recordRemoveEventSafely({ success: false, statusCode: error.status, errorCode: error.code, }); } else { - await recordRemoveEventSafely({ + recordRemoveEventSafely({ success: false, statusCode: 500, errorCode: "INTERNAL_SERVER_ERROR", diff --git a/app/api/schedule/[planPersonId]/status/route.ts b/app/api/schedule/[planPersonId]/status/route.ts index df7d163..e714ab5 100644 --- a/app/api/schedule/[planPersonId]/status/route.ts +++ b/app/api/schedule/[planPersonId]/status/route.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { after } from "next/server"; import { ApiError } from "@/lib/http/api-error"; import { handlePlanningCenterRoute } from "@/lib/http/planning-center-route"; import { logger } from "@/lib/logger"; @@ -7,6 +8,7 @@ import { getActivityRequestContext, recordActivityEvent, } from "@/lib/db/activity-events"; +import { invalidateCandidateHistoryForPerson } from "@/lib/use-cases/planning-center/get-people-for-position"; export const dynamic = "force-dynamic"; @@ -16,6 +18,9 @@ const paramsSchema = z.object({ const bodySchema = z.object({ status: z.enum(["C", "U", "D"]), + serviceTypeId: z.string().min(1).optional(), + personId: z.string().min(1).optional(), + planId: z.string().min(1).optional(), }); export async function PATCH( @@ -27,7 +32,7 @@ export async function PATCH( const log = logger.withRequest(request).child({ requestId }); return handlePlanningCenterRoute(request, async (authContext) => { - const recordStatusEventSafely = async (event: { + const recordStatusEventSafely = (event: { success: boolean; statusCode: number; errorCode: string | null; @@ -35,33 +40,36 @@ export async function PATCH( status: "C" | "U" | "D" | null; metadata?: Record; }) => { - try { - await recordActivityEvent({ - eventType: "schedule_status_change", - actorUserId: authContext.session.user.id, - actorAccountId: authContext.accountId, - requestId, - path: activityRequestContext.path, - method: activityRequestContext.method, - ipAddress: activityRequestContext.ipAddress, - userAgent: activityRequestContext.userAgent, - success: event.success, - statusCode: event.statusCode, - errorCode: event.errorCode, - metadata: { - planPersonId: event.planPersonId, - status: event.status, - ...event.metadata, - }, - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - log.warn({ err }, "Failed to record schedule status change activity event"); - } + after(async () => { + try { + await recordActivityEvent({ + eventType: "schedule_status_change", + actorUserId: authContext.session.user.id, + actorAccountId: authContext.accountId, + requestId, + path: activityRequestContext.path, + method: activityRequestContext.method, + ipAddress: activityRequestContext.ipAddress, + userAgent: activityRequestContext.userAgent, + success: event.success, + statusCode: event.statusCode, + errorCode: event.errorCode, + metadata: { + planPersonId: event.planPersonId, + status: event.status, + ...event.metadata, + }, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + log.warn({ err }, "Failed to record schedule status change activity event"); + } + }); }; let planPersonId: string | null = null; let nextStatus: "C" | "U" | "D" | null = null; + let requestBody: z.infer | null = null; try { const parsedParams = paramsSchema.safeParse(await params); @@ -92,30 +100,44 @@ export async function PATCH( parsedBody.error.issues ); } - nextStatus = parsedBody.data.status; + requestBody = parsedBody.data; + nextStatus = requestBody.status; await planningCenterPeopleService.updatePlanPersonStatus( planPersonId, - nextStatus + nextStatus, + { + personId: requestBody.personId, + serviceTypeId: requestBody.serviceTypeId, + planId: requestBody.planId, + } ); + if (requestBody.personId) { + invalidateCandidateHistoryForPerson(requestBody.personId); + } log.info( { planPersonId, status: nextStatus }, "PlanPerson status updated successfully" ); - await recordStatusEventSafely({ + recordStatusEventSafely({ success: true, statusCode: 200, errorCode: null, planPersonId, status: nextStatus, + metadata: { + personId: requestBody.personId ?? null, + serviceTypeId: requestBody.serviceTypeId ?? null, + planId: requestBody.planId ?? null, + }, }); return { success: true }; } catch (error) { if (error instanceof ApiError) { - await recordStatusEventSafely({ + recordStatusEventSafely({ success: false, statusCode: error.status, errorCode: error.code, @@ -123,7 +145,7 @@ export async function PATCH( status: nextStatus, }); } else { - await recordStatusEventSafely({ + recordStatusEventSafely({ success: false, statusCode: 500, errorCode: "INTERNAL_SERVER_ERROR", diff --git a/app/api/schedule/route.ts b/app/api/schedule/route.ts index 543c8e0..181d92f 100644 --- a/app/api/schedule/route.ts +++ b/app/api/schedule/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { after, NextResponse } from "next/server"; import { z } from "zod"; import { ApiError } from "@/lib/http/api-error"; import { handlePlanningCenterRoute } from "@/lib/http/planning-center-route"; @@ -21,6 +21,8 @@ const bodySchema = z.object({ planId: z.string().min(1), teamId: z.string().min(1), positionId: z.string().min(1), + teamName: z.string().trim().min(1).optional(), + positionName: z.string().trim().min(1).optional(), oneOff: z.boolean().optional().default(false), }); @@ -30,37 +32,39 @@ export async function POST(request: Request) { const log = logger.withRequest(request).child({ requestId }); return handlePlanningCenterRoute(request, async (authContext) => { - const recordScheduleEventSafely = async (event: { + const recordScheduleEventSafely = (event: { success: boolean; statusCode: number; errorCode: string | null; input: z.infer | null; metadata?: Record; }) => { - try { - await recordActivityEvent({ - eventType: "schedule_attempt", - actorUserId: authContext.session.user.id, - actorAccountId: authContext.accountId, - requestId, - path: activityRequestContext.path, - method: activityRequestContext.method, - ipAddress: activityRequestContext.ipAddress, - userAgent: activityRequestContext.userAgent, - success: event.success, - statusCode: event.statusCode, - errorCode: event.errorCode, - serviceTypeId: event.input?.serviceTypeId ?? null, - personId: event.input?.personId ?? null, - planId: event.input?.planId ?? null, - teamId: event.input?.teamId ?? null, - positionId: event.input?.positionId ?? null, - metadata: event.metadata ?? null, - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - log.warn({ err }, "Failed to record schedule activity event"); - } + after(async () => { + try { + await recordActivityEvent({ + eventType: "schedule_attempt", + actorUserId: authContext.session.user.id, + actorAccountId: authContext.accountId, + requestId, + path: activityRequestContext.path, + method: activityRequestContext.method, + ipAddress: activityRequestContext.ipAddress, + userAgent: activityRequestContext.userAgent, + success: event.success, + statusCode: event.statusCode, + errorCode: event.errorCode, + serviceTypeId: event.input?.serviceTypeId ?? null, + personId: event.input?.personId ?? null, + planId: event.input?.planId ?? null, + teamId: event.input?.teamId ?? null, + positionId: event.input?.positionId ?? null, + metadata: event.metadata ?? null, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + log.warn({ err }, "Failed to record schedule activity event"); + } + }); }; let requestBody: z.infer | null = null; @@ -72,10 +76,25 @@ export async function POST(request: Request) { throw new ApiError(400, "INVALID_REQUEST", "Invalid request", parsed.error.issues); } requestBody = parsed.data; - const { serviceTypeId, personId, planId, teamId, positionId, oneOff } = requestBody; + const { + serviceTypeId, + personId, + planId, + teamId, + positionId, + oneOff, + } = requestBody; + + const personAssignmentsPromise = oneOff + ? Promise.resolve(null) + : planningCenterPeopleService.getPersonTeamPositionAssignments(personId); + const teamPositionsPromise = + planningCenterCatalogService.getServiceTypeTeamPositionsWithTeams(serviceTypeId); + const [{ data: teamPositions, included }, personAssignments] = await Promise.all([ + teamPositionsPromise, + personAssignmentsPromise, + ]); - const { data: teamPositions, included } = - await planningCenterCatalogService.getServiceTypeTeamPositionsWithTeams(serviceTypeId); const selectedPosition = teamPositions.find((p) => p.id === positionId) as | (RawTeamPosition & { relationships?: { team?: { data?: { id: string } } } }) | undefined; @@ -92,13 +111,11 @@ export async function POST(request: Request) { const selectedPositionName = selectedPosition.attributes?.name || ""; if (!oneOff) { - const personAssignments = - await planningCenterPeopleService.getPersonTeamPositionAssignments(personId); - const hasPositionAssignment = personAssignments.data.some((assignment) => { + const hasPositionAssignment = personAssignments?.data.some((assignment) => { const rel = assignment.relationships?.team_position?.data; const assignmentPositionId = Array.isArray(rel) ? rel[0]?.id : rel?.id; return assignmentPositionId === positionId; - }); + }) ?? false; if (!hasPositionAssignment) { throw new ApiError(400, "INVALID_REQUEST", "Person is not assigned to the selected team position"); } @@ -129,7 +146,7 @@ export async function POST(request: Request) { createdTeamPositionName.startsWith(`${selectedTeamName} - `); if (!teamMatches || !positionMatches) { - await recordScheduleEventSafely({ + recordScheduleEventSafely({ success: false, statusCode: 409, errorCode: "POSITION_MISMATCH", @@ -170,7 +187,7 @@ export async function POST(request: Request) { "Person scheduled successfully" ); - await recordScheduleEventSafely({ + recordScheduleEventSafely({ success: true, statusCode: 200, errorCode: null, @@ -187,7 +204,16 @@ export async function POST(request: Request) { const errorMessage = err.message || ""; if (errorMessage.includes("has already been scheduled for this position")) { - await recordScheduleEventSafely({ + if (requestBody) { + planningCenterPeopleService.invalidateScheduleReadCaches({ + personId: requestBody.personId, + serviceTypeId: requestBody.serviceTypeId, + planId: requestBody.planId, + }); + invalidateCandidateHistoryForPerson(requestBody.personId); + } + + recordScheduleEventSafely({ success: false, statusCode: 409, errorCode: "ALREADY_SCHEDULED", @@ -206,14 +232,14 @@ export async function POST(request: Request) { } if (error instanceof ApiError) { - await recordScheduleEventSafely({ + recordScheduleEventSafely({ success: false, statusCode: error.status, errorCode: error.code, input: requestBody, }); } else { - await recordScheduleEventSafely({ + recordScheduleEventSafely({ success: false, statusCode: 500, errorCode: "INTERNAL_SERVER_ERROR", diff --git a/app/layout.tsx b/app/layout.tsx index 8506307..d209196 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Analytics } from "@vercel/analytics/next"; import { Providers } from "@/components/providers"; -import { peoplePageFlag } from "@/flags"; import "./globals.css"; const geistSans = Geist({ @@ -35,7 +34,8 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const peoplePageEnabled = await peoplePageFlag(); + const peoplePageEnabled = + process.env.NODE_ENV !== "production" && !process.env.VERCEL; return ( diff --git a/app/schedule/page.tsx b/app/schedule/page.tsx index 514d0dd..aec8742 100644 --- a/app/schedule/page.tsx +++ b/app/schedule/page.tsx @@ -1,9 +1,10 @@ import { Suspense } from "react"; +import { SchedulePlansFallback } from "@/components/schedule/schedule-page-fallbacks"; import { SchedulePlansPage } from "@/components/schedule/schedule-plans-page"; export default function SchedulePage() { return ( - + }> ); diff --git a/app/schedule/plan/page.tsx b/app/schedule/plan/page.tsx index 1ddf749..7b88a95 100644 --- a/app/schedule/plan/page.tsx +++ b/app/schedule/plan/page.tsx @@ -1,9 +1,10 @@ import { Suspense } from "react"; import { DashboardPage } from "@/components/dashboard-page"; +import { SchedulePlanWorkspaceFallback } from "@/components/schedule/schedule-page-fallbacks"; export default function SchedulePlanPage() { return ( - + }> ); diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 91f21a7..02cc080 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -28,7 +28,29 @@ import { import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTheme } from "next-themes"; import { authClient } from "@/lib/auth-client"; +import { + ACCOUNT_PANEL_CACHE_KEY, + parseCachedAccountPanel, + serializeAccountPanel, + summarizeAccountPanel, + type AccountPanelSummary, +} from "@/lib/account-panel-cache"; import { getJson, postJson } from "@/lib/http/client"; +import { + PEOPLE_PAGE_NAV_CACHE_KEY, + parsePeoplePageNavState, + serializePeoplePageNavState, +} from "@/lib/people-page-nav-cache"; +import { clearCachedPeople } from "@/lib/people-cache"; +import { clearCachedPeopleDashboards } from "@/lib/people-dashboard-cache"; +import { clearCachedPeopleSearch } from "@/lib/people-search-cache"; +import { clearCachedMyScheduledPlans } from "@/lib/my-scheduled-plans-cache"; +import { clearCachedOrganizationTimeZone } from "@/lib/organization-time-zone-cache"; +import { clearCachedPlanItems } from "@/lib/plan-items-cache"; +import { clearCachedScheduleCatalog } from "@/lib/schedule-catalog-cache"; +import { clearCachedSongOptions } from "@/lib/song-options-cache"; +import { clearCachedSongSearch } from "@/lib/song-search-cache"; +import { clearCachedTeamPositions } from "@/lib/team-positions-cache"; import { APP_SHORTCUTS, SHORTCUTS_PALETTE_HOTKEY } from "@/lib/app-hotkeys"; import { cn } from "@/lib/utils"; import { HotkeyChord } from "@/components/hotkey-chord"; @@ -129,6 +151,37 @@ function readStoredSidebarOpen(): boolean { return true; } +function readCachedAccountPanelSummary(): AccountPanelSummary | null { + if (typeof window === "undefined") return null; + return parseCachedAccountPanel(window.localStorage.getItem(ACCOUNT_PANEL_CACHE_KEY)); +} + +function writeCachedAccountPanelSummary(summary: AccountPanelSummary) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(ACCOUNT_PANEL_CACHE_KEY, serializeAccountPanel(summary)); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +function readCachedPeoplePageEnabled(fallback: boolean): boolean { + if (typeof window === "undefined") return fallback; + return parsePeoplePageNavState(window.localStorage.getItem(PEOPLE_PAGE_NAV_CACHE_KEY))?.enabled ?? fallback; +} + +function writeCachedPeoplePageEnabled(enabled: boolean) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + PEOPLE_PAGE_NAV_CACHE_KEY, + serializePeoplePageNavState({ enabled }) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + function SidebarResizeRail({ width, onWidthChange, @@ -308,6 +361,51 @@ function AppTopBar() { ); } +function AppTopBarFallback({ pathname }: { pathname: string }) { + const hasPlan = pathname === "/schedule/plan"; + const isPeople = pathname.startsWith("/people"); + const isPersonDetail = /^\/people\/[^/]+/.test(pathname); + + return ( +
+ + + {isPersonDetail ? ( + <> + + People + + + + Person + + + ) : ( + + {isPeople ? "People" : "Schedule"} + + )} + + + {hasPlan ? ( + + + + Schedule + + + Lineup + + + Plan + + + + ) : null} +
+ ); +} + function SidebarAccountPanel({ onOpenShortcuts, onPeekLockChange, @@ -321,26 +419,23 @@ function SidebarAccountPanel({ const { setTheme, theme } = useTheme(); const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [data, setData] = useState(null); + const [cachedSummary, setCachedSummary] = useState(null); const [loading, setLoading] = useState(true); const [switchingAccountId, setSwitchingAccountId] = useState(null); const [isSigningOut, setIsSigningOut] = useState(false); const [error, setError] = useState(""); - const selectedAccount = useMemo(() => { - if (!data) return null; - if (!data.selectedAccountId) return data.accounts[0] ?? null; - return data.accounts.find((account) => account.id === data.selectedAccountId) ?? null; - }, [data]); - - const avatarName = - selectedAccount?.identity?.name || data?.session.name || data?.session.email || null; - const organizationName = selectedAccount?.identity?.organizationName ?? "worshipadmin.com"; + const liveSummary = useMemo(() => summarizeAccountPanel(data), [data]); + const triggerSummary = data ? liveSummary : cachedSummary ?? liveSummary; const loadAccounts = useCallback(async () => { setLoading(true); try { const response = await getJson("/api/planning-center/accounts"); setData(response); + const summary = summarizeAccountPanel(response); + setCachedSummary(summary); + writeCachedAccountPanelSummary(summary); setError(""); } catch (err) { const message = err instanceof Error ? err.message : "Failed to load account details"; @@ -350,6 +445,10 @@ function SidebarAccountPanel({ } }, []); + useEffect(() => { + setCachedSummary(readCachedAccountPanelSummary()); + }, []); + useEffect(() => { void loadAccounts(); }, [loadAccounts]); @@ -362,6 +461,16 @@ function SidebarAccountPanel({ "/api/planning-center/accounts", { accountId } ); + clearCachedPeople(); + clearCachedPeopleDashboards(); + clearCachedPeopleSearch(); + clearCachedMyScheduledPlans(); + clearCachedOrganizationTimeZone(); + clearCachedPlanItems(); + clearCachedScheduleCatalog(); + clearCachedSongOptions(); + clearCachedSongSearch(); + clearCachedTeamPositions(); await loadAccounts(); await queryClient.invalidateQueries(); router.refresh(); @@ -407,12 +516,12 @@ function SidebarAccountPanel({ - {data?.session.image ? : null} + {triggerSummary.image ? : null} - {initialsFromName(avatarName)} + {initialsFromName(triggerSummary.avatarName)} - {organizationName} + {triggerSummary.organizationName} setShortcutsOpen(true), { ignoreInputs: true }); + useEffect(() => { + let cancelled = false; + + setPeopleNavEnabled(readCachedPeoplePageEnabled(peoplePageEnabled)); + + void getJson<{ enabled: boolean }>("/api/people/feature") + .then(({ enabled }) => { + if (cancelled) return; + setPeopleNavEnabled(enabled); + writeCachedPeoplePageEnabled(enabled); + }) + .catch(() => { + // Keep the initial/cached value if the feature check fails. + }); + + return () => { + cancelled = true; + }; + }, [peoplePageEnabled]); + return ( <> - {peoplePageEnabled ? ( + {peopleNavEnabled ? (
- + }>
diff --git a/components/dashboard-page.tsx b/components/dashboard-page.tsx index 6d3f9fb..4f955c1 100644 --- a/components/dashboard-page.tsx +++ b/components/dashboard-page.tsx @@ -1,9 +1,8 @@ "use client"; -import { startTransition, useCallback, useEffect, useMemo, useState } from "react"; +import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { LoaderCircle } from "lucide-react"; import { PlanningCenterServicesIcon } from "@/components/planning-center-services-icon"; import { LineupTab } from "@/components/schedule/lineup-tab"; import { PlanTab } from "@/components/schedule/plan-tab"; @@ -12,11 +11,12 @@ import { Button } from "@/components/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import type { SlotRef } from "@/components/schedule/types"; import { Tabs, TabsContent } from "@/components/ui/tabs"; -import { usePeople } from "@/hooks/use-people"; +import { toast } from "@/components/ui/sonner"; +import { createPeopleQueryOptions, usePeople } from "@/hooks/use-people"; +import { createPlanItemsQueryOptions } from "@/hooks/use-plan-items"; import { usePlans } from "@/hooks/use-plans"; import { useServiceTypes } from "@/hooks/use-service-types"; import { useTeamPositions } from "@/hooks/use-team-positions"; -import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; interface RouteSelectionIds { @@ -30,6 +30,7 @@ interface RouteSelectionIds { type DashboardView = "schedule" | "lineup" | "plan"; const COLLAPSED_TEAMS_STORAGE_KEY_PREFIX = "schedule-collapsed-teams:"; const COLLAPSED_TEAMS_STORAGE_MAP_KEY = `${COLLAPSED_TEAMS_STORAGE_KEY_PREFIX}by-plan`; +const SLOT_PEOPLE_PREFETCH_DELAY_MS = 180; type SearchParamReader = Pick; function parseDashboardView(value: string | null): DashboardView { @@ -128,6 +129,7 @@ export function DashboardPage() { const pathname = usePathname(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); + const slotPrefetchTimeoutRef = useRef | null>(null); const [collapsedTeamsByPlan, setCollapsedTeamsByPlan] = useState< Record> @@ -177,15 +179,21 @@ export function DashboardPage() { const { data: serviceTypes, isLoading: serviceTypesLoading } = useServiceTypes(); const selectedServiceType = serviceTypes?.find((serviceType) => serviceType.id === routeIds.serviceTypeId) ?? null; + const routeServiceTypeId = routeIds.serviceTypeId ?? null; + const routePlanId = routeIds.planId ?? null; const { data: plans, isLoading: plansLoading, isFetching: plansFetching } = usePlans( - selectedServiceType?.id ?? null + routeServiceTypeId ); const selectedPlan = plans?.find((plan) => plan.id === routeIds.planId) ?? null; - const { data: teamPositionGroups, isLoading: teamPositionsLoading } = useTeamPositions( - selectedServiceType?.id ?? null, - selectedPlan?.id ?? null, + const { + data: teamPositionGroups, + isLoading: teamPositionsLoading, + isPlaceholderData: teamPositionsPlaceholder, + } = useTeamPositions( + routeServiceTypeId, + routePlanId, selectedPlan?.seriesId ?? null ); @@ -194,24 +202,80 @@ export function DashboardPage() { const selectedPositionObj = selectedTeamGroup?.positions.find((position) => position.id === routeIds.positionId) ?? null; - const selectedTeam = selectedTeamGroup?.teamId ?? null; - const selectedPosition = selectedPositionObj?.id ?? null; - const selectedPlanId = selectedPlan?.id ?? null; + const selectedTeam = routeIds.teamId ?? null; + const selectedPosition = routeIds.positionId ?? null; + const validatedTeam = selectedTeamGroup?.teamId ?? null; + const validatedPosition = selectedPositionObj?.id ?? null; + const canLoadSelectedSlotPeople = Boolean(selectedPlan?.sortDate && selectedPosition); + const selectedPlanId = routePlanId; const collapsedTeams = selectedPlanId ? (collapsedTeamsByPlan[selectedPlanId] ?? {}) : {}; - const hasSelectedPlan = Boolean(selectedServiceType && selectedPlan); const hasPlanUrlSelection = Boolean(routeIds.serviceTypeId && routeIds.planId); - const isPlanMetadataLoading = - hasPlanUrlSelection && (serviceTypesLoading || plansLoading || plansFetching); - const activeView: DashboardView = hasSelectedPlan ? routeIds.view : "schedule"; - - const { data: people, isLoading: peopleLoading } = usePeople( - selectedServiceType?.id ?? null, - selectedTeam, - selectedPosition, - selectedPlan?.id ?? null, + const hasSelectedPlanMetadata = Boolean(selectedServiceType && selectedPlan); + const activeView: DashboardView = hasPlanUrlSelection ? routeIds.view : "schedule"; + + const { + data: people, + isLoading: peopleLoading, + isPlaceholderData: peoplePlaceholder, + } = usePeople( + routeServiceTypeId, + canLoadSelectedSlotPeople ? selectedTeam : null, + canLoadSelectedSlotPeople ? selectedPosition : null, + routePlanId, selectedPlan?.sortDate ?? null ); + const prefetchPlanItems = useCallback(() => { + if (!routeServiceTypeId || !routePlanId) return; + void queryClient.prefetchQuery( + createPlanItemsQueryOptions(routeServiceTypeId, routePlanId) + ); + }, [queryClient, routePlanId, routeServiceTypeId]); + + useEffect(() => { + if (!hasPlanUrlSelection || activeView === "plan") return; + prefetchPlanItems(); + }, [activeView, hasPlanUrlSelection, prefetchPlanItems]); + + const prefetchSlotPeople = useCallback( + (slot: SlotRef) => { + if (!routeServiceTypeId || !selectedPlan?.id) return; + void queryClient.prefetchQuery( + createPeopleQueryOptions( + routeServiceTypeId, + slot.teamId, + slot.positionId, + selectedPlan.id, + selectedPlan.sortDate ?? null + ) + ); + }, + [queryClient, routeServiceTypeId, selectedPlan?.id, selectedPlan?.sortDate] + ); + + const handleSlotPreview = useCallback( + (slot: SlotRef) => { + if (slotPrefetchTimeoutRef.current) { + clearTimeout(slotPrefetchTimeoutRef.current); + } + + slotPrefetchTimeoutRef.current = setTimeout(() => { + slotPrefetchTimeoutRef.current = null; + prefetchSlotPeople(slot); + }, SLOT_PEOPLE_PREFETCH_DELAY_MS); + }, + [prefetchSlotPeople] + ); + + useEffect( + () => () => { + if (slotPrefetchTimeoutRef.current) { + clearTimeout(slotPrefetchTimeoutRef.current); + } + }, + [] + ); + useEffect(() => { const hasServiceTypeInUrl = !!routeIds.serviceTypeId; const hasPlanInUrl = !!routeIds.planId; @@ -224,8 +288,8 @@ export function DashboardPage() { const canonicalUrl = buildScheduleUrl({ serviceTypeId: selectedServiceType?.id ?? null, planId: selectedPlan?.id ?? null, - teamId: selectedTeam, - positionId: selectedPosition, + teamId: validatedTeam, + positionId: validatedPosition, view: activeView, }); @@ -244,9 +308,9 @@ export function DashboardPage() { router, activeView, selectedPlan?.id, - selectedPosition, + validatedPosition, selectedServiceType?.id, - selectedTeam, + validatedTeam, serviceTypesLoading, teamPositionsLoading, ]); @@ -263,30 +327,19 @@ export function DashboardPage() { } }, [collapsedTeamsByPlan]); - const handleScheduleSuccess = () => { - void queryClient.invalidateQueries({ - queryKey: queryKeys.peopleForSlot( - selectedServiceType?.id ?? null, - selectedTeam, - selectedPosition, - selectedPlan?.id ?? null - ), - }); - - void queryClient.invalidateQueries({ - queryKey: queryKeys.teamPositions( - selectedServiceType?.id ?? null, - selectedPlan?.id ?? null, - selectedPlan?.seriesId ?? null - ), - }); - }; + const handleScheduleSuccess = () => {}; const handleScheduleError = (message: string) => { - console.error("Schedule error:", message); + toast.error(message); }; const handleSlotSelect = (slot: SlotRef) => { + if (slotPrefetchTimeoutRef.current) { + clearTimeout(slotPrefetchTimeoutRef.current); + slotPrefetchTimeoutRef.current = null; + } + prefetchSlotPeople(slot); + if (selectedPlanId) { setCollapsedTeamsByPlan((prev) => { const currentForPlan = prev[selectedPlanId] ?? {}; @@ -303,8 +356,8 @@ export function DashboardPage() { } navigateTo({ - serviceTypeId: selectedServiceType?.id ?? null, - planId: selectedPlan?.id ?? null, + serviceTypeId: routeServiceTypeId, + planId: routePlanId, teamId: slot.teamId, positionId: slot.positionId, view: "schedule", @@ -335,10 +388,10 @@ export function DashboardPage() {
- {hasSelectedPlan && selectedServiceType && selectedPlan ? ( + {hasSelectedPlanMetadata && selectedServiceType && selectedPlan ? (

@@ -381,16 +434,9 @@ export function DashboardPage() {

) : null} - {!hasSelectedPlan ? ( + {!hasPlanUrlSelection ? (
- {isPlanMetadataLoading ? ( - <> - - Loading plan details… - - ) : ( - No plan selected · use the Schedule breadcrumb to choose one. - )} + No plan selected · use the Schedule breadcrumb to choose one.
) : ( @@ -400,16 +446,19 @@ export function DashboardPage() { > @@ -419,14 +468,16 @@ export function DashboardPage() { diff --git a/components/people/people-page.tsx b/components/people/people-page.tsx index 04c1d64..21f9e44 100644 --- a/components/people/people-page.tsx +++ b/components/people/people-page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useMemo, useState } from "react"; +import { useCallback, useDeferredValue, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; import { CalendarDays, ChevronRight, @@ -13,6 +14,7 @@ import { ShieldAlert, } from "lucide-react"; import { usePeopleDashboard } from "@/hooks/use-people-dashboard"; +import { createPeopleDashboardPersonQueryOptions } from "@/hooks/use-people-dashboard-person"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { @@ -124,11 +126,18 @@ export function PersonAvatar({ person }: { person: PeopleDashboardPerson }) { export function PeoplePage() { const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); const router = useRouter(); + const queryClient = useQueryClient(); const [activeView, setActiveView] = useState<"health" | "month">("health"); const [range, setRange] = useState("month"); const [selectedTeam, setSelectedTeam] = useState("all"); - const { data: dashboard, isLoading, isError } = usePeopleDashboard(range); + const { + data: dashboard, + isLoading, + isError, + isPlaceholderData, + } = usePeopleDashboard(range); const people = dashboard?.people ?? EMPTY_PEOPLE; const rhythmCalendarCells = dashboard ? buildCalendarCells(dashboard.month.startsOnWeekday, dashboard.month.daysInMonth) @@ -139,18 +148,33 @@ export function PeoplePage() { ); const visiblePeople = useMemo(() => { - const normalized = query.trim().toLowerCase(); + const normalized = deferredQuery.trim().toLowerCase(); return people.filter((person) => (selectedTeam === "all" || person.teams.includes(selectedTeam)) && (!normalized || [person.name, person.roles, ...person.teams].join(" ").toLowerCase().includes(normalized)) ); - }, [people, query, selectedTeam]); + }, [deferredQuery, people, selectedTeam]); const mvp = people[0] ?? null; const needsRest = people.filter((person) => person.load === "rest" || person.load === "high"); const underused = people.filter((person) => person.load === "low"); - const openPerson = (person: PeopleDashboardPerson) => router.push(`/people/${person.id}`); + const prefetchPersonDetail = useCallback( + (person: PeopleDashboardPerson) => { + router.prefetch(`/people/${person.id}`); + void queryClient.prefetchQuery( + createPeopleDashboardPersonQueryOptions(person.id, null) + ); + }, + [queryClient, router] + ); + const openPerson = useCallback( + (person: PeopleDashboardPerson) => { + prefetchPersonDetail(person); + router.push(`/people/${person.id}`); + }, + [prefetchPersonDetail, router] + ); return (
@@ -211,12 +235,18 @@ export function PeoplePage() {
+ {isPlaceholderData && !isError ? ( +
+ Loading selected range... +
+ ) : null} + {isError ? (
People dashboard failed to load. Refresh and try again.
) : activeView === "health" ? ( -
+
@@ -290,6 +320,7 @@ export function PeoplePage() { prefetchPersonDetail(person)} onClick={() => openPerson(person)} > @@ -349,6 +380,9 @@ export function PeoplePage() { key={`mobile-${person.id}`} type="button" className="flex w-full flex-col gap-2 border-b border-border/35 px-4 py-3 text-left last:border-b-0 hover:bg-muted/50" + onFocus={() => prefetchPersonDetail(person)} + onPointerEnter={() => prefetchPersonDetail(person)} + onTouchStart={() => prefetchPersonDetail(person)} onClick={() => openPerson(person)} >
@@ -391,6 +425,9 @@ export function PeoplePage() { key={`queue-${person.id}`} type="button" className="flex items-center gap-3 rounded-md px-2 py-1.5 text-left hover:bg-muted/50" + onFocus={() => prefetchPersonDetail(person)} + onPointerEnter={() => prefetchPersonDetail(person)} + onTouchStart={() => prefetchPersonDetail(person)} onClick={() => openPerson(person)} > @@ -419,6 +456,9 @@ export function PeoplePage() { key={`cadence-${person.id}`} type="button" className="flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left hover:bg-muted/50" + onFocus={() => prefetchPersonDetail(person)} + onPointerEnter={() => prefetchPersonDetail(person)} + onTouchStart={() => prefetchPersonDetail(person)} onClick={() => openPerson(person)} > @@ -449,6 +489,9 @@ export function PeoplePage() {
) : ( -
+
+ {isPlaceholderData ? ( +
+ Refreshing detail... +
+ ) : null}
diff --git a/components/providers.tsx b/components/providers.tsx index 263ec0e..cc84c9b 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -9,6 +9,8 @@ import { Toaster } from "@/components/ui/sonner"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useState } from "react"; +const QUERY_GC_TIME_MS = 30 * 60 * 1000; + export function Providers({ children, peoplePageEnabled, @@ -22,6 +24,7 @@ export function Providers({ new QueryClient({ defaultOptions: { queries: { + gcTime: QUERY_GC_TIME_MS, refetchOnWindowFocus: false, retry: 1, }, diff --git a/components/schedule/lineup-tab.tsx b/components/schedule/lineup-tab.tsx index 5d29702..2aafdf7 100644 --- a/components/schedule/lineup-tab.tsx +++ b/components/schedule/lineup-tab.tsx @@ -21,13 +21,17 @@ import { cn } from "@/lib/utils"; interface LineupTabProps { groups: TeamPositionGroup[]; isLoading: boolean; + isPlaceholderData: boolean; onSelectPosition: (slot: SlotRef) => void; + onPreviewPosition?: (slot: SlotRef) => void; } export function LineupTab({ groups, isLoading, + isPlaceholderData, onSelectPosition, + onPreviewPosition, }: LineupTabProps) { if (isLoading) { return ( @@ -51,10 +55,27 @@ export function LineupTab({ return ( -
- {groups.map((group) => ( - - ))} +
+ {isPlaceholderData ? ( +
+ Loading selected plan... +
+ ) : null} +
+ {groups.map((group) => ( + + ))} +
); @@ -63,9 +84,11 @@ export function LineupTab({ function TeamColumn({ group, onSelectPosition, + onPreviewPosition, }: { group: TeamPositionGroup; onSelectPosition: (slot: SlotRef) => void; + onPreviewPosition?: (slot: SlotRef) => void; }) { const totalScheduled = group.positions.reduce( (sum, position) => sum + (position.filledConfirmedCount ?? 0) + (position.filledPendingCount ?? 0), @@ -95,6 +118,7 @@ function TeamColumn({ teamName={group.teamName} position={position} onSelectPosition={onSelectPosition} + onPreviewPosition={onPreviewPosition} /> {index < group.positions.length - 1 ? : null}
@@ -110,11 +134,13 @@ function PositionAccordionItem({ teamName, position, onSelectPosition, + onPreviewPosition, }: { teamId: string; teamName: string; position: TeamPosition; onSelectPosition: (slot: SlotRef) => void; + onPreviewPosition?: (slot: SlotRef) => void; }) { const confirmed = position.filledConfirmedCount ?? 0; const pending = position.filledPendingCount ?? 0; @@ -122,6 +148,12 @@ function PositionAccordionItem({ const needed = position.neededCount ?? 0; const total = scheduledCount + needed; const people = position.filledPeople ?? []; + const slot = { + teamId, + teamName, + positionId: position.id, + positionName: position.name, + }; return ( @@ -147,15 +179,13 @@ function PositionAccordionItem({ )} title={`Open ${position.name} in scheduler`} aria-label={`Open ${position.name} in scheduler`} + onPointerEnter={() => onPreviewPosition?.(slot)} + onFocus={() => onPreviewPosition?.(slot)} + onTouchStart={() => onPreviewPosition?.(slot)} onClick={(event) => { event.preventDefault(); event.stopPropagation(); - onSelectPosition({ - teamId, - teamName, - positionId: position.id, - positionName: position.name, - }); + onSelectPosition(slot); }} > diff --git a/components/schedule/plan-item-edit-dialog.tsx b/components/schedule/plan-item-edit-dialog.tsx index d2e45e6..d6de715 100644 --- a/components/schedule/plan-item-edit-dialog.tsx +++ b/components/schedule/plan-item-edit-dialog.tsx @@ -25,14 +25,20 @@ import { Input } from "@/components/ui/input"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; -import type { PlanItem } from "@/lib/types"; +import type { PlanItem, PlanItemArrangement, PlanItemKey } from "@/lib/types"; interface PlanItemEditDialogProps { item: PlanItem | null; open: boolean; serviceTypeId: string | null; onOpenChange: (open: boolean) => void; - onSave: (input: { item: PlanItem; draft: DraftState; length: number | null }) => Promise; + onSave: (input: { + item: PlanItem; + draft: DraftState; + length: number | null; + optimisticArrangement: PlanItemArrangement | null; + optimisticKey: PlanItemKey | null; + }) => Promise; } export function PlanItemEditDialog({ @@ -94,6 +100,16 @@ export function PlanItemEditDialog({ item, draft, length: parsed.length, + optimisticArrangement: selectedArrangement + ? { + id: selectedArrangement.id, + name: selectedArrangement.name, + sequence: selectedArrangement.sequence, + length: selectedArrangement.length, + archivedAt: null, + } + : null, + optimisticKey: keyOptions.find((key) => key.id === draft.keyId) ?? null, }); onOpenChange(false); } catch (error) { diff --git a/components/schedule/plan-item-list.tsx b/components/schedule/plan-item-list.tsx index cb0e1b5..58f8517 100644 --- a/components/schedule/plan-item-list.tsx +++ b/components/schedule/plan-item-list.tsx @@ -17,7 +17,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { FileMusic, Music4, Trash2 } from "lucide-react"; +import { FileMusic, GripVertical, Music4, Trash2 } from "lucide-react"; import { useState } from "react"; import { formatLength, getItemTone } from "@/components/schedule/plan-tab-helpers"; import { Badge } from "@/components/ui/badge"; @@ -33,11 +33,13 @@ import { cn } from "@/lib/utils"; interface PlanItemListProps { items: PlanItem[]; isLoading: boolean; + isPlaceholderData: boolean; pendingItemId: string | null; onAddSong: () => void; onAddHeader: () => void; onAddItem: () => void; onEditItem: (itemId: string) => void; + onPreviewItem?: (itemId: string) => void; onDeleteItem: (itemId: string) => Promise | void; onReorderItems: (items: PlanItem[]) => Promise | void; } @@ -45,11 +47,13 @@ interface PlanItemListProps { export function PlanItemList({ items, isLoading, + isPlaceholderData, pendingItemId, onAddSong, onAddHeader, onAddItem, onEditItem, + onPreviewItem, onDeleteItem, onReorderItems, }: PlanItemListProps) { @@ -87,12 +91,13 @@ export function PlanItemList({ const handleConfirmDelete = async () => { if (!itemIdPendingDelete) return; + const itemId = itemIdPendingDelete; + setItemIdPendingDelete(null); try { - await onDeleteItem(itemIdPendingDelete); - setItemIdPendingDelete(null); + await onDeleteItem(itemId); } catch { - // Errors are handled by the mutation toast; keep the dialog open. + // Errors are handled by the mutation toast; the optimistic cache restores the row. } }; @@ -133,61 +138,84 @@ export function PlanItemList({

- - -
) : ( - setActiveItemId(null)} - onDragEnd={(event) => void handleDragEnd(event)} - > - item.id)} - strategy={verticalListSortingStrategy} - > -
-
- {items.map((item) => ( - onEditItem(item.id)} - onDelete={() => setItemIdPendingDelete(item.id)} - /> - ))} -
+
+ {isPlaceholderData ? ( +
+ Loading selected plan...
- - - {activeItem ? ( -
- onEditItem(activeItem.id)} - onDelete={() => setItemIdPendingDelete(activeItem.id)} - /> -
- ) : null} -
- + ) : null} +
+ setActiveItemId(null)} + onDragEnd={(event) => void handleDragEnd(event)} + > + item.id)} + strategy={verticalListSortingStrategy} + > +
+
+ {items.map((item) => ( + onEditItem(item.id)} + onPreview={() => onPreviewItem?.(item.id)} + onDelete={() => setItemIdPendingDelete(item.id)} + /> + ))} +
+
+
+ + {activeItem ? ( +
+ onEditItem(activeItem.id)} + onPreview={() => onPreviewItem?.(activeItem.id)} + onDelete={() => setItemIdPendingDelete(activeItem.id)} + /> +
+ ) : null} +
+
+
+
)} @@ -200,6 +228,7 @@ interface SortablePlanItemProps { isDragging: boolean; reorderDisabled: boolean; onEdit: () => void; + onPreview: () => void; onDelete: () => Promise | void; } @@ -209,6 +238,7 @@ function SortablePlanItem({ isDragging, reorderDisabled, onEdit, + onPreview, onDelete, }: SortablePlanItemProps) { const { @@ -252,6 +282,7 @@ function SortablePlanItem({ dragAttributes={attributes} dragListeners={listeners} onEdit={onEdit} + onPreview={onPreview} onDelete={onDelete} />
@@ -265,6 +296,7 @@ interface PlanItemCardProps { dragAttributes?: ReturnType["attributes"]; dragListeners?: ReturnType["listeners"]; onEdit: () => void; + onPreview: () => void; onDelete: () => Promise | void; } @@ -275,37 +307,46 @@ function PlanItemCard({ dragAttributes, dragListeners, onEdit, + onPreview, onDelete, }: PlanItemCardProps) { const tone = getItemTone(item); const lengthLabel = formatLength(item.length); - const handleCardKeyDown = (event: React.KeyboardEvent) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onEdit(); - }; + const rowHoverClassName = item.itemType === "header" + ? "hover:ring-border/80 hover:ring-1 hover:ring-inset" + : "hover:bg-accent/45"; + const dragHandleClassName = + "flex w-9 shrink-0 touch-manipulation items-center justify-center self-stretch border-0 bg-transparent text-muted-foreground/55 outline-none transition-colors hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-[3px] active:cursor-grabbing disabled:pointer-events-none disabled:opacity-50"; + const editButtonClassName = + "min-w-0 flex-1 border-0 bg-transparent text-left font-inherit outline-none transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px]"; return (
- + -
+ +
+
+
-
+
+ + +
- +
); } diff --git a/components/schedule/plan-person-status-menu.tsx b/components/schedule/plan-person-status-menu.tsx index 590a9fe..96112de 100644 --- a/components/schedule/plan-person-status-menu.tsx +++ b/components/schedule/plan-person-status-menu.tsx @@ -36,6 +36,8 @@ export interface PlanPersonStatusMenuProps { serviceTypeId?: string | null; personId?: string | null; planId?: string | null; + teamId?: string | null; + positionId?: string | null; onSuccess?: () => void; onError?: (message: string) => void; } @@ -46,6 +48,8 @@ export function PlanPersonStatusMenu({ serviceTypeId, personId, planId, + teamId, + positionId, onSuccess, onError, }: PlanPersonStatusMenuProps) { @@ -75,7 +79,16 @@ export function PlanPersonStatusMenu({ {ITEMS.map(({ value, label, dotClassName }) => ( void handleUpdate(planPersonId, STATUS_TO_CODE[value])} + disabled={currentStatus === value} + onSelect={() => + handleUpdate(planPersonId, STATUS_TO_CODE[value], { + serviceTypeId, + personId, + planId, + teamId, + positionId, + }) + } > {label} @@ -87,7 +100,13 @@ export function PlanPersonStatusMenu({ - void handleUnschedule(planPersonId, { serviceTypeId, personId, planId }) + handleUnschedule(planPersonId, { + serviceTypeId, + personId, + planId, + teamId, + positionId, + }) } > diff --git a/components/schedule/plan-tab-toolbar.tsx b/components/schedule/plan-tab-toolbar.tsx index b72c744..80c12d9 100644 --- a/components/schedule/plan-tab-toolbar.tsx +++ b/components/schedule/plan-tab-toolbar.tsx @@ -5,6 +5,8 @@ import { Button } from "@/components/ui/button"; interface PlanTabToolbarProps { pendingItemId: string | null; + isCreatingBasicItem?: boolean; + disabled?: boolean; onAddSong: () => void; onAddHeader: () => void; onAddItem: () => void; @@ -12,6 +14,8 @@ interface PlanTabToolbarProps { export function PlanTabToolbar({ pendingItemId, + isCreatingBasicItem = false, + disabled = false, onAddSong, onAddHeader, onAddItem, @@ -20,7 +24,14 @@ export function PlanTabToolbar({ return (
- @@ -30,7 +41,7 @@ export function PlanTabToolbar({ size="sm" className="h-8 gap-1.5" onClick={onAddHeader} - disabled={pendingItemId === "create-header"} + disabled={disabled || isCreatingBasicItem} > Header @@ -41,7 +52,7 @@ export function PlanTabToolbar({ size="sm" className="h-8 gap-1.5" onClick={onAddItem} - disabled={pendingItemId === "create-item"} + disabled={disabled || isCreatingBasicItem} > Item diff --git a/components/schedule/plan-tab.tsx b/components/schedule/plan-tab.tsx index 78ec3b2..85be1f6 100644 --- a/components/schedule/plan-tab.tsx +++ b/components/schedule/plan-tab.tsx @@ -15,17 +15,20 @@ export function PlanTab({ serviceTypeId, planId }: PlanTabProps) { const { items, isLoading, + isPlaceholderData, editingItemId, editingItem, songPickerOpen, pendingItemId, pendingSongId, + isCreatingBasicItem, setEditingItemId, setSongPickerOpen, createBasicItem, addSongToPlan, deleteItem, reorderItems, + prefetchItemSongOptions, saveItem, } = usePlanTabController({ serviceTypeId, @@ -61,6 +64,8 @@ export function PlanTab({ serviceTypeId, planId }: PlanTabProps) {
setSongPickerOpen(true)} onAddHeader={() => void createBasicItem("header")} onAddItem={() => void createBasicItem("item")} @@ -69,11 +74,13 @@ export function PlanTab({ serviceTypeId, planId }: PlanTabProps) { setSongPickerOpen(true)} onAddHeader={() => void createBasicItem("header")} onAddItem={() => void createBasicItem("item")} onEditItem={setEditingItemId} + onPreviewItem={prefetchItemSongOptions} onDeleteItem={deleteItem} onReorderItems={reorderItems} /> diff --git a/components/schedule/position-picker-list.tsx b/components/schedule/position-picker-list.tsx index 9bc1fb8..deec81e 100644 --- a/components/schedule/position-picker-list.tsx +++ b/components/schedule/position-picker-list.tsx @@ -13,30 +13,37 @@ import { SidebarMenuSkeleton } from "@/components/ui/sidebar"; import { TeamSlotsCollapsible } from "@/components/schedule/team-slots-collapsible"; import type { SlotRef } from "@/components/schedule/types"; import type { TeamPositionGroup } from "@/lib/types"; +import { cn } from "@/lib/utils"; export function PositionPickerList({ teamPositionsLoading, + teamPositionsPlaceholder, teamPositionGroups, collapsedTeams, selectedTeam, selectedPosition, onToggleTeam, onSelect, + onPreviewSlot, }: { teamPositionsLoading: boolean; + teamPositionsPlaceholder: boolean; teamPositionGroups: TeamPositionGroup[] | undefined; collapsedTeams: Record; selectedTeam: string | null; selectedPosition: string | null; onToggleTeam: (teamId: string) => void; onSelect: (slot: SlotRef) => void; + onPreviewSlot?: (slot: SlotRef) => void; }) { + const skeletonWidths = ["78%", "66%", "84%", "58%", "72%", "62%", "88%", "70%"]; + return (
{teamPositionsLoading ? ( Array.from({ length: 8 }).map((_, index) => ( - + )) ) : !teamPositionGroups || teamPositionGroups.length === 0 ? ( @@ -49,17 +56,27 @@ export function PositionPickerList({ ) : ( - teamPositionGroups.map((group) => ( - - )) +
+ {teamPositionsPlaceholder ? ( +
+ Loading selected plan... +
+ ) : null} +
+ {teamPositionGroups.map((group) => ( + + ))} +
+
)}
diff --git a/components/schedule/schedule-candidate-tile.tsx b/components/schedule/schedule-candidate-tile.tsx index db894f0..d624de0 100644 --- a/components/schedule/schedule-candidate-tile.tsx +++ b/components/schedule/schedule-candidate-tile.tsx @@ -42,6 +42,8 @@ export interface ScheduleCandidateTileProps { planId?: string | null; teamId?: string | null; positionId?: string | null; + teamName?: string | null; + positionName?: string | null; onScheduleSuccess?: () => void; onScheduleError?: (message: string) => void; } @@ -52,6 +54,8 @@ export function ScheduleCandidateTile({ planId, teamId, positionId, + teamName, + positionName, onScheduleSuccess, onScheduleError, }: ScheduleCandidateTileProps) { @@ -69,6 +73,8 @@ export function ScheduleCandidateTile({ planId, teamId, positionId, + teamName, + positionName, canSchedule: canScheduleForHook, onScheduleSuccess, onScheduleError, @@ -294,7 +300,7 @@ export function ScheduleCandidateTile({ size="sm" className="h-8 w-8 gap-1.5 px-0 sm:w-full sm:px-3" disabled={!canSchedule || isScheduling} - onClick={() => void handleSchedule(person.id)} + onClick={() => handleSchedule(person)} title={disableReason} > {isScheduling ? ( @@ -315,6 +321,8 @@ export function ScheduleCandidateTile({ serviceTypeId={serviceTypeId} personId={person.id} planId={planId} + teamId={teamId} + positionId={positionId} currentStatus={ (isConfirmed ? "confirmed" diff --git a/components/schedule/schedule-page-fallbacks.tsx b/components/schedule/schedule-page-fallbacks.tsx new file mode 100644 index 0000000..c7b7cb1 --- /dev/null +++ b/components/schedule/schedule-page-fallbacks.tsx @@ -0,0 +1,86 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function SchedulePlansFallback() { + return ( +
+
+
+ + +
+
+ + + +
+
+
+ + + + +
+
+ {Array.from({ length: 10 }).map((_, index) => ( +
+ + + + +
+ ))} +
+
+
+
+ ); +} + +export function SchedulePlanWorkspaceFallback() { + return ( +
+
+
+
+
+ + +
+ +
+
+ +
+ +
+
+ + +
+
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+
+
+ ); +} diff --git a/components/schedule/schedule-view-tab.tsx b/components/schedule/schedule-view-tab.tsx index 57d25c9..8a454ed 100644 --- a/components/schedule/schedule-view-tab.tsx +++ b/components/schedule/schedule-view-tab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useDeferredValue, useEffect, useMemo, useState } from "react"; import { CalendarDays, X } from "lucide-react"; import { ScheduleCandidateTile } from "@/components/schedule/schedule-candidate-tile"; import { SomeoneElseRow } from "@/components/schedule/someone-else-row"; @@ -30,37 +30,44 @@ import type { PersonWithAvailability, TeamPositionGroup } from "@/lib/types"; interface ScheduleViewTabProps { teamPositionsLoading: boolean; + teamPositionsPlaceholder: boolean; teamPositionGroups: TeamPositionGroup[] | undefined; collapsedTeams: Record; selectedTeam: string | null; selectedPosition: string | null; people: PersonWithAvailability[] | undefined; peopleLoading: boolean; + peoplePlaceholder: boolean; selectedServiceTypeId: string | null; selectedPlanId: string | null; onToggleTeam: (teamId: string) => void; onSelectSlot: (slot: SlotRef) => void; + onPreviewSlot?: (slot: SlotRef) => void; onScheduleSuccess: () => void; onScheduleError: (message: string) => void; } export function ScheduleViewTab({ teamPositionsLoading, + teamPositionsPlaceholder, teamPositionGroups, collapsedTeams, selectedTeam, selectedPosition, people, peopleLoading, + peoplePlaceholder, selectedServiceTypeId, selectedPlanId, onToggleTeam, onSelectSlot, + onPreviewSlot, onScheduleSuccess, onScheduleError, }: ScheduleViewTabProps) { const [pickerOpen, setPickerOpen] = useState(false); const [filter, setFilter] = useState(""); + const deferredFilter = useDeferredValue(filter); /** Tailwind `lg` — sidebar visible; sheet only below this width. */ const [isWidePickerLayout, setIsWidePickerLayout] = useState(false); @@ -106,7 +113,7 @@ export function ScheduleViewTab({ [people] ); - const normalizedFilter = filter.trim().toLowerCase(); + const normalizedFilter = deferredFilter.trim().toLowerCase(); const filteredActionable = useMemo( () => normalizedFilter @@ -134,12 +141,14 @@ export function ScheduleViewTab({ const positionPickerList = ( ); @@ -189,9 +198,22 @@ export function ScheduleViewTab({ ) : ( -
+
+ {peoplePlaceholder ? ( +
+ Loading selected slot... +
+ ) : null}
-
+
{filteredActionable.map((person) => ( @@ -209,6 +233,8 @@ export function ScheduleViewTab({ planId={selectedPlanId} teamId={selectedTeam} positionId={selectedPosition} + teamName={selectedSlotInfo?.teamName} + positionName={selectedSlotInfo?.positionName} onScheduleSuccess={onScheduleSuccess} onScheduleError={onScheduleError} /> @@ -218,7 +244,12 @@ export function ScheduleViewTab({ {filteredExceptions.length > 0 ? (
-
+
{filteredExceptions.map((person) => ( diff --git a/components/schedule/selected-position-header.tsx b/components/schedule/selected-position-header.tsx index 33cb946..cd94e4a 100644 --- a/components/schedule/selected-position-header.tsx +++ b/components/schedule/selected-position-header.tsx @@ -24,7 +24,7 @@ export function SelectedPositionHeader({

- {info?.positionName} + {info?.positionName ?? "Position"}

- ) : isLoading || isFetching ? ( + ) : showInitialLoading ? (
{Array.from({ length: 6 }).map((_, index) => ( @@ -78,6 +90,11 @@ export function SongPickerDialog({ ) : ( <> No songs matched that search. + {showRefreshing ? ( +
+ Searching… +
+ ) : null}
{songs.map((song) => { const lastScheduledLabel = formatLastScheduled(song.lastScheduledAt); @@ -88,6 +105,9 @@ export function SongPickerDialog({ value={[song.title, song.author, song.themes].filter(Boolean).join(" ")} disabled={pendingSongId === song.id} className="items-start" + onMouseEnter={() => prefetchSongOptions(song.id)} + onFocus={() => prefetchSongOptions(song.id)} + onTouchStart={() => prefetchSongOptions(song.id)} onSelect={async () => { await onSelectSong(song); }} diff --git a/components/schedule/team-slots-collapsible.tsx b/components/schedule/team-slots-collapsible.tsx index 72329c1..32b8f09 100644 --- a/components/schedule/team-slots-collapsible.tsx +++ b/components/schedule/team-slots-collapsible.tsx @@ -22,6 +22,7 @@ export function TeamSlotsCollapsible({ selectedPosition, onToggle, onSelect, + onPreview, }: { group: TeamPositionGroup; isCollapsed: boolean; @@ -29,6 +30,7 @@ export function TeamSlotsCollapsible({ selectedPosition: string | null; onToggle: (teamId: string) => void; onSelect: (slot: SlotRef) => void; + onPreview?: (slot: SlotRef) => void; }) { const openNeededCount = group.positions.reduce( (sum, position) => sum + (position.neededCount ?? 1), @@ -42,18 +44,19 @@ export function TeamSlotsCollapsible({ const renderPositionRow = (position: TeamPosition) => { const active = group.teamId === selectedTeam && position.id === selectedPosition; + const slot = { + teamId: group.teamId, + teamName: group.teamName, + positionId: position.id, + positionName: position.name, + }; return ( - onSelect({ - teamId: group.teamId, - teamName: group.teamName, - positionId: position.id, - positionName: position.name, - }) - } + onClick={() => onSelect(slot)} + onMouseEnter={() => onPreview?.(slot)} + onFocus={() => onPreview?.(slot)} className="h-8 rounded-none pl-5 pr-2 transition-none" > {position.name} diff --git a/components/service-plan-table-selector.tsx b/components/service-plan-table-selector.tsx index 69dcef6..d3a1e44 100644 --- a/components/service-plan-table-selector.tsx +++ b/components/service-plan-table-selector.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useQueries, useQueryClient } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { ServiceTypeMultiSelect } from "@/components/service-type-multi-select"; @@ -25,10 +25,12 @@ import { } from "@/components/ui/table"; import { useMyScheduledPlans } from "@/hooks/use-my-scheduled-plans"; import { useOrganizationTimeZone } from "@/hooks/use-organization-timezone"; +import { createPlanItemsQueryOptions } from "@/hooks/use-plan-items"; import { useServiceTypes } from "@/hooks/use-service-types"; import { createTeamPositionsQueryOptions } from "@/hooks/use-team-positions"; import { getJson } from "@/lib/http/client"; import { queryKeys } from "@/lib/query-keys"; +import { readCachedPlansEntry, writeCachedPlans } from "@/lib/schedule-catalog-cache"; import { addCalendarDaysToDayKey, formatCalendarDayInTimeZone, @@ -117,25 +119,23 @@ export function ServicePlanTableSelector({ }: ServicePlanTableSelectorProps) { const queryClient = useQueryClient(); const prefetchTimeoutRef = useRef | null>(null); + const cachedPlanWritesRef = useRef>(new Map()); const orgTimeZone = useOrganizationTimeZone(); const { data: serviceTypes, isLoading: serviceTypesLoading } = useServiceTypes(); - - const planQueries = useQueries({ - queries: (serviceTypes ?? []).map((serviceType) => ({ - queryKey: queryKeys.plans(serviceType.id), - queryFn: () => getJson(`/api/plans?service_type_id=${serviceType.id}`), - staleTime: 5 * 60 * 1000, - enabled: !!serviceTypes, - })), - }); - const [searchValue, setSearchValue] = useState(""); + const deferredSearchValue = useDeferredValue(searchValue); const [selectedServiceTypeIds, setSelectedServiceTypeIds] = useState( - () => (selectedServiceTypeId ? [selectedServiceTypeId] : readStoredServiceTypeIds()) + () => (selectedServiceTypeId ? [selectedServiceTypeId] : null) ); const [dateRangeFilter, setDateRangeFilter] = useState("60"); const [showMineOnly, setShowMineOnly] = useState(false); + useEffect(() => { + setSelectedServiceTypeIds( + selectedServiceTypeId ? [selectedServiceTypeId] : readStoredServiceTypeIds() + ); + }, [selectedServiceTypeId]); + const allServiceTypeIds = useMemo( () => (serviceTypes ?? []).map((serviceType) => serviceType.id), [serviceTypes] @@ -182,12 +182,47 @@ export function ServicePlanTableSelector({ [effectiveSelectedServiceTypeIds] ); + const planQueryOptions = useMemo( + () => (serviceTypes ?? []).map((serviceType) => ({ + queryKey: queryKeys.plans(serviceType.id), + queryFn: () => getJson(`/api/plans?service_type_id=${serviceType.id}`), + staleTime: 5 * 60 * 1000, + enabled: !!serviceTypes && selectedServiceTypeIdSet.has(serviceType.id), + })), + [selectedServiceTypeIdSet, serviceTypes] + ); + + const planQueries = useQueries({ + queries: planQueryOptions, + }); + + useEffect(() => { + if (!serviceTypes) return; + + for (const serviceType of serviceTypes) { + if (!selectedServiceTypeIdSet.has(serviceType.id)) continue; + + const cachedPlans = readCachedPlansEntry(serviceType.id); + if (!cachedPlans) continue; + + const queryKey = queryKeys.plans(serviceType.id); + const state = queryClient.getQueryState(queryKey); + if (state?.data !== undefined && state.dataUpdatedAt >= cachedPlans.savedAt) continue; + + queryClient.setQueryData(queryKey, cachedPlans.data, { + updatedAt: cachedPlans.savedAt, + }); + } + }, [queryClient, selectedServiceTypeIdSet, serviceTypes]); + const rows = useMemo(() => { if (!serviceTypes) return []; const flattened: ServicePlanRow[] = []; for (const [index, serviceType] of serviceTypes.entries()) { + if (!selectedServiceTypeIdSet.has(serviceType.id)) continue; + const plans = planQueries[index]?.data ?? []; for (const plan of plans) { const sortDate = parsePlanDate(plan.sortDate); @@ -218,7 +253,7 @@ export function ServicePlanTableSelector({ return a.planTitle.localeCompare(b.planTitle); }); - }, [planQueries, serviceTypes]); + }, [planQueries, selectedServiceTypeIdSet, serviceTypes]); const planIdsForLookup = useMemo( () => [...new Set(rows.map((row) => row.planId))], @@ -235,7 +270,7 @@ export function ServicePlanTableSelector({ ); const visibleRows = useMemo(() => { - const normalizedSearch = searchValue.trim().toLowerCase(); + const normalizedSearch = deferredSearchValue.trim().toLowerCase(); return rows.filter((row) => { if (selectedServiceTypeIdSet.size === 0) return false; @@ -268,21 +303,32 @@ export function ServicePlanTableSelector({ }); }, [ dateRangeFilter, + deferredSearchValue, myScheduledPlanIdSet, orgTimeZone, rows, - searchValue, selectedServiceTypeIdSet, showMineOnly, ]); const plansLoading = planQueries.some((query) => query.isLoading); const errorMessage = planQueries.find((query) => query.isError)?.error; - // Hold the table until we know which rows belong to the current user — avoids the - // "scheduled" markers popping in after rows render. - const awaitingMyScheduled = - planIdsForLookup.length > 0 && (myScheduledPlansLoading || (!myScheduledPlans && myScheduledPlansFetching)); - const isLoading = serviceTypesLoading || plansLoading || awaitingMyScheduled; + const isInitialLoading = serviceTypesLoading || (plansLoading && rows.length === 0); + useEffect(() => { + if (!serviceTypes) return; + for (const [index, serviceType] of serviceTypes.entries()) { + const query = planQueries[index]; + const plans = query?.data; + if (!plans) continue; + const dataUpdatedAt = query.dataUpdatedAt; + if (cachedPlanWritesRef.current.get(serviceType.id) === dataUpdatedAt) { + continue; + } + writeCachedPlans(serviceType.id, plans); + cachedPlanWritesRef.current.set(serviceType.id, dataUpdatedAt); + } + }, [planQueries, serviceTypes]); + const prefetchTeamPositions = useCallback( (row: ServicePlanRow) => { void queryClient.prefetchQuery( @@ -291,6 +337,14 @@ export function ServicePlanTableSelector({ }, [queryClient] ); + const prefetchPlanItems = useCallback( + (row: ServicePlanRow) => { + void queryClient.prefetchQuery( + createPlanItemsQueryOptions(row.serviceTypeId, row.planId) + ); + }, + [queryClient] + ); const warmPeopleHistory = useCallback( (row: ServicePlanRow) => { const dateKey = row.sortDate.toISOString(); @@ -310,9 +364,10 @@ export function ServicePlanTableSelector({ const prefetchPlanData = useCallback( (row: ServicePlanRow) => { prefetchTeamPositions(row); + prefetchPlanItems(row); warmPeopleHistory(row); }, - [prefetchTeamPositions, warmPeopleHistory] + [prefetchPlanItems, prefetchTeamPositions, warmPeopleHistory] ); const cancelDelayedPrefetch = useCallback(() => { if (!prefetchTimeoutRef.current) return; @@ -330,19 +385,35 @@ export function ServicePlanTableSelector({ [cancelDelayedPrefetch, prefetchPlanData] ); + const handleSelectRow = useCallback( + (row: ServicePlanRow) => { + cancelDelayedPrefetch(); + prefetchPlanData(row); + onSelect({ + serviceTypeId: row.serviceTypeId, + planId: row.planId, + }); + }, + [cancelDelayedPrefetch, onSelect, prefetchPlanData] + ); + useEffect(() => cancelDelayedPrefetch, [cancelDelayedPrefetch]); const firstVisibleRow = visibleRows[0] ?? null; useEffect(() => { - if (isLoading || !firstVisibleRow) return; + if (!firstVisibleRow) return; warmPeopleHistory(firstVisibleRow); - }, [firstVisibleRow, isLoading, warmPeopleHistory]); + }, [firstVisibleRow, warmPeopleHistory]); const myScheduledCount = useMemo( () => rows.filter((row) => myScheduledPlanIdSet.has(row.planId)).length, [rows, myScheduledPlanIdSet] ); - const mineTabDisabled = !isLoading && myScheduledCount === 0; + const mineTabDisabled = + isInitialLoading || + myScheduledPlansLoading || + (!myScheduledPlans && myScheduledPlansFetching) || + myScheduledCount === 0; // If "mine only" was selected and no rows match, fall back to "all". useEffect(() => { @@ -423,7 +494,7 @@ export function ServicePlanTableSelector({ - {isLoading ? ( + {isInitialLoading ? ( Array.from({ length: 8 }).map((_, index) => ( @@ -440,7 +511,7 @@ export function ServicePlanTableSelector({ )) - ) : errorMessage ? ( + ) : errorMessage && visibleRows.length === 0 ? ( @@ -489,21 +560,13 @@ export function ServicePlanTableSelector({ ? `${row.serviceTypeName} — you are scheduled` : undefined } - onClick={() => - onSelect({ - serviceTypeId: row.serviceTypeId, - planId: row.planId, - }) - } + onClick={() => handleSelectRow(row)} onMouseEnter={() => scheduleDelayedPrefetch(row)} onMouseLeave={cancelDelayedPrefetch} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - onSelect({ - serviceTypeId: row.serviceTypeId, - planId: row.planId, - }); + handleSelectRow(row); }} >
- {isLoading ? ( + {isInitialLoading ? ( Array.from({ length: 8 }).map((_, index) => (
@@ -547,7 +610,7 @@ export function ServicePlanTableSelector({
)) - ) : errorMessage ? ( + ) : errorMessage && visibleRows.length === 0 ? (
@@ -592,12 +655,7 @@ export function ServicePlanTableSelector({ ? `${row.serviceTypeName} — you are scheduled` : undefined } - onClick={() => - onSelect({ - serviceTypeId: row.serviceTypeId, - planId: row.planId, - }) - } + onClick={() => handleSelectRow(row)} onTouchStart={() => prefetchPlanData(row)} >
diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx index edeada6..1d8eff3 100644 --- a/components/ui/sidebar.tsx +++ b/components/ui/sidebar.tsx @@ -654,15 +654,12 @@ function SidebarMenuBadge({ function SidebarMenuSkeleton({ className, showIcon = false, + width = "70%", ...props }: React.ComponentProps<"div"> & { showIcon?: boolean + width?: string }) { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%` - }, []) - return (
readCachedMyScheduledPlans(planIdsKey), + [planIdsKey] + ); + useHydrateQueryFromCache(queryKey, readCachedPlans); - return useQuery({ - queryKey: queryKeys.myScheduledPlans(planIdsKey), + return useQuery({ + queryKey, queryFn: async () => { if (normalizedPlanIds.length === 0) { return { planIds: [] }; } - return postJson("/api/my-scheduled-plans", { + const scheduledPlans = await postJson("/api/my-scheduled-plans", { planIds: normalizedPlanIds, }); + writeCachedMyScheduledPlans(planIdsKey, scheduledPlans); + return scheduledPlans; }, enabled: normalizedPlanIds.length > 0, + placeholderData: (previousPlans) => previousPlans, staleTime: 60 * 1000, }); } diff --git a/hooks/use-organization-timezone.ts b/hooks/use-organization-timezone.ts index 3a8caf0..874f8bc 100644 --- a/hooks/use-organization-timezone.ts +++ b/hooks/use-organization-timezone.ts @@ -1,14 +1,36 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { useCallback } from "react"; import { getJson } from "@/lib/http/client"; +import { + readCachedOrganizationTimeZone, + writeCachedOrganizationTimeZone, +} from "@/lib/organization-time-zone-cache"; import { queryKeys } from "@/lib/query-keys"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; /** Client hook for the Services org `time_zone` (via `/api/planning-center/organization`). */ export function useOrganizationTimeZone(): string { + const queryKey = queryKeys.organizationTimeZone(); + const readCachedTimeZone = useCallback(() => { + const cachedTimeZone = readCachedOrganizationTimeZone(); + return cachedTimeZone + ? { + data: { timeZone: cachedTimeZone.timeZone }, + savedAt: cachedTimeZone.savedAt, + } + : undefined; + }, []); + useHydrateQueryFromCache(queryKey, readCachedTimeZone); + const { data } = useQuery({ - queryKey: queryKeys.organizationTimeZone(), - queryFn: () => getJson<{ timeZone: string }>("/api/planning-center/organization"), + queryKey, + queryFn: async () => { + const response = await getJson<{ timeZone: string }>("/api/planning-center/organization"); + writeCachedOrganizationTimeZone(response.timeZone); + return response; + }, staleTime: 60 * 60 * 1000, gcTime: 2 * 60 * 60 * 1000, }); diff --git a/hooks/use-people-dashboard-person.ts b/hooks/use-people-dashboard-person.ts index 2e373e1..d469271 100644 --- a/hooks/use-people-dashboard-person.ts +++ b/hooks/use-people-dashboard-person.ts @@ -1,15 +1,55 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; import { getJson } from "@/lib/http/client"; +import { + readCachedPeopleDashboardPerson, + writeCachedPeopleDashboardPerson, +} from "@/lib/people-dashboard-cache"; +import { getCachedPeopleDashboardPersonDetail } from "@/lib/people-dashboard-person-placeholder"; import { queryKeys } from "@/lib/query-keys"; -import type { PeopleDashboardPersonDetail } from "@/lib/use-cases/planning-center/people-dashboard-types"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; +import type { + PeopleDashboardData, + PeopleDashboardPersonDetail, +} from "@/lib/use-cases/planning-center/people-dashboard-types"; -export function usePeopleDashboardPerson(personId: string, month: string | null) { - return useQuery({ +export function createPeopleDashboardPersonQueryOptions( + personId: string, + month: string | null +) { + return { queryKey: queryKeys.peopleDashboardPerson(personId, month), - queryFn: () => { + queryFn: async () => { const params = month ? `?month=${encodeURIComponent(month)}` : ""; - return getJson(`/api/people/dashboard/${personId}${params}`); + const detail = await getJson( + `/api/people/dashboard/${personId}${params}` + ); + writeCachedPeopleDashboardPerson(personId, month, detail); + return detail; }, staleTime: 2 * 60 * 1000, + }; +} + +export function usePeopleDashboardPerson(personId: string, month: string | null) { + const queryClient = useQueryClient(); + const queryKey = queryKeys.peopleDashboardPerson(personId, month); + const readCachedPerson = useCallback( + () => readCachedPeopleDashboardPerson(personId, month), + [month, personId] + ); + useHydrateQueryFromCache(queryKey, readCachedPerson); + + return useQuery({ + ...createPeopleDashboardPersonQueryOptions(personId, month), + queryKey, + placeholderData: () => + getCachedPeopleDashboardPersonDetail( + queryClient.getQueriesData({ + queryKey: ["people-dashboard"], + }).map(([, dashboard]) => dashboard), + personId, + month + ), }); } diff --git a/hooks/use-people-dashboard.ts b/hooks/use-people-dashboard.ts index 626ac86..40dde05 100644 --- a/hooks/use-people-dashboard.ts +++ b/hooks/use-people-dashboard.ts @@ -1,15 +1,36 @@ import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect } from "react"; import { getJson } from "@/lib/http/client"; +import { + readCachedPeopleDashboard, + writeCachedPeopleDashboard, +} from "@/lib/people-dashboard-cache"; import { queryKeys } from "@/lib/query-keys"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; import type { PeopleDashboardData, PeopleDashboardRange, } from "@/lib/use-cases/planning-center/people-dashboard-types"; export function usePeopleDashboard(range: PeopleDashboardRange) { - return useQuery({ - queryKey: queryKeys.peopleDashboard(range), + const queryKey = queryKeys.peopleDashboard(range); + const readCachedDashboard = useCallback( + () => readCachedPeopleDashboard(range), + [range] + ); + useHydrateQueryFromCache(queryKey, readCachedDashboard); + + const query = useQuery({ + queryKey, queryFn: () => getJson(`/api/people/dashboard?range=${range}`), staleTime: 2 * 60 * 1000, + placeholderData: (previousDashboard) => previousDashboard, }); + + useEffect(() => { + if (!query.data) return; + writeCachedPeopleDashboard(query.data); + }, [query.data]); + + return query; } diff --git a/hooks/use-people-search.ts b/hooks/use-people-search.ts index 7e9beb8..c5a7b23 100644 --- a/hooks/use-people-search.ts +++ b/hooks/use-people-search.ts @@ -1,8 +1,15 @@ "use client"; +import { useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { getJson } from "@/lib/http/client"; +import { + normalizePeopleSearchQuery, + readCachedPeopleSearch, + writeCachedPeopleSearch, +} from "@/lib/people-search-cache"; import { queryKeys } from "@/lib/query-keys"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; export interface PeopleSearchResult { id: string; @@ -13,15 +20,25 @@ export interface PeopleSearchResult { } export function usePeopleSearch(query: string) { - const normalizedQuery = query.trim(); + const normalizedQuery = normalizePeopleSearchQuery(query); + const queryKey = queryKeys.peopleSearch(normalizedQuery); + const readCachedResults = useCallback( + () => readCachedPeopleSearch(normalizedQuery), + [normalizedQuery] + ); + useHydrateQueryFromCache(queryKey, readCachedResults); return useQuery({ - queryKey: queryKeys.peopleSearch(normalizedQuery), - queryFn: () => - getJson( + queryKey, + queryFn: async () => { + const results = await getJson( `/api/people/search?q=${encodeURIComponent(normalizedQuery)}` - ), + ); + writeCachedPeopleSearch(normalizedQuery, results); + return results; + }, enabled: normalizedQuery.length >= 2, + placeholderData: (previousPeople) => previousPeople, staleTime: 30_000, }); } diff --git a/hooks/use-people.ts b/hooks/use-people.ts index a052b8c..1b1455c 100644 --- a/hooks/use-people.ts +++ b/hooks/use-people.ts @@ -1,26 +1,32 @@ +import { useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { getJson } from "@/lib/http/client"; +import { readCachedPeople, writeCachedPeople } from "@/lib/people-cache"; import type { PersonWithAvailability } from "@/lib/types"; import { queryKeys } from "@/lib/query-keys"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; -export function usePeople( +function normalizePeopleDateKey(date: Date | string | null): string | null { + if (!date) return null; + return typeof date === "string" ? date : date.toISOString(); +} + +function normalizePeopleDate(date: Date | string | null): Date | null { + if (!date) return null; + return typeof date === "string" ? new Date(date) : date; +} + +export function createPeopleQueryOptions( serviceTypeId: string | null, teamId: string | null, positionId: string | null, planId: string | null = null, date: Date | string | null = null ) { - // Convert date to string for query key (handle both Date and string) - const dateKey = date - ? (typeof date === "string" ? date : date.toISOString()) - : null; + const dateKey = normalizePeopleDateKey(date); + const dateObj = normalizePeopleDate(date); - // Convert date to Date object if it's a string - const dateObj = date - ? (typeof date === "string" ? new Date(date) : date) - : null; - - return useQuery({ + return { queryKey: queryKeys.people( serviceTypeId, teamId, @@ -50,9 +56,33 @@ export function usePeople( params.append("date", dateObj.toISOString()); } - return getJson(`/api/people?${params.toString()}`); + const people = await getJson(`/api/people?${params.toString()}`); + writeCachedPeople(serviceTypeId, teamId, positionId, planId, dateKey, people); + return people; }, - enabled: !!positionId && !!serviceTypeId, staleTime: 5 * 60 * 1000, // 5 minutes + }; +} + +export function usePeople( + serviceTypeId: string | null, + teamId: string | null, + positionId: string | null, + planId: string | null = null, + date: Date | string | null = null +) { + const dateKey = normalizePeopleDateKey(date); + const queryKey = queryKeys.people(serviceTypeId, teamId, positionId, planId, dateKey); + const readCachedPeopleForQuery = useCallback( + () => readCachedPeople(serviceTypeId, teamId, positionId, planId, dateKey), + [dateKey, planId, positionId, serviceTypeId, teamId] + ); + useHydrateQueryFromCache(queryKey, readCachedPeopleForQuery); + + return useQuery({ + ...createPeopleQueryOptions(serviceTypeId, teamId, positionId, planId, date), + queryKey, + enabled: !!positionId && !!serviceTypeId, + placeholderData: (previousPeople) => previousPeople, }); } diff --git a/hooks/use-plan-items.ts b/hooks/use-plan-items.ts index 6ff3153..f52eab3 100644 --- a/hooks/use-plan-items.ts +++ b/hooks/use-plan-items.ts @@ -1,29 +1,55 @@ +import { useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { getJson } from "@/lib/http/client"; import { hydratePlanItems, type SerializedPlanItem } from "@/lib/plan-item-client"; +import { readCachedPlanItems, writeCachedPlanItems } from "@/lib/plan-items-cache"; import { queryKeys } from "@/lib/query-keys"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; import type { PlanItem } from "@/lib/types"; const PLAN_ITEMS_STALE_TIME_MS = 60 * 1000; -export function usePlanItems( +function buildPlanItemsUrl(serviceTypeId: string, planId: string): string { + const params = new URLSearchParams({ + service_type_id: serviceTypeId, + plan_id: planId, + }); + return `/api/plan-items?${params.toString()}`; +} + +export function createPlanItemsQueryOptions( serviceTypeId: string | null, planId: string | null ) { - return useQuery({ + return { queryKey: queryKeys.planItems(serviceTypeId, planId), queryFn: async () => { if (!serviceTypeId || !planId) return []; - const params = new URLSearchParams({ - service_type_id: serviceTypeId, - plan_id: planId, - }); - - const items = await getJson(`/api/plan-items?${params.toString()}`); - return hydratePlanItems(items); + const items = await getJson(buildPlanItemsUrl(serviceTypeId, planId)); + const hydratedItems = hydratePlanItems(items); + writeCachedPlanItems(serviceTypeId, planId, hydratedItems); + return hydratedItems; }, - enabled: !!serviceTypeId && !!planId, staleTime: PLAN_ITEMS_STALE_TIME_MS, + }; +} + +export function usePlanItems( + serviceTypeId: string | null, + planId: string | null +) { + const queryKey = queryKeys.planItems(serviceTypeId, planId); + const readCachedItems = useCallback( + () => readCachedPlanItems(serviceTypeId, planId), + [planId, serviceTypeId] + ); + useHydrateQueryFromCache(queryKey, readCachedItems); + + return useQuery({ + ...createPlanItemsQueryOptions(serviceTypeId, planId), + queryKey, + enabled: !!serviceTypeId && !!planId, + placeholderData: (previousItems) => previousItems, }); } diff --git a/hooks/use-plan-tab-controller.ts b/hooks/use-plan-tab-controller.ts index af685d1..907b52d 100644 --- a/hooks/use-plan-tab-controller.ts +++ b/hooks/use-plan-tab-controller.ts @@ -1,20 +1,36 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { deleteJson, patchJson, postJson } from "@/lib/http/client"; import { hydratePlanItem, type SerializedPlanItem } from "@/lib/plan-item-client"; import { appendPlanItem, + applyPlanItemDraft, applyPlanItemsOptimisticUpdate, + collectPlanSongOptionPrefetchIds, + createOptimisticBasicPlanItem, + createOptimisticSongPlanItem, + nextPlanItemSequence, + planItemDraftChangesItem, + planItemsHaveSameOrder, removePlanItem, replacePlanItem, + replacePlanItemById, restorePlanItemsSnapshot, + settlePlanItemsQuery, type PlanItemsOptimisticSnapshot, } from "@/lib/plan-items-query-state"; import { queryKeys } from "@/lib/query-keys"; -import type { PlanItem, SongCatalogEntry } from "@/lib/types"; +import type { + PlanItem, + PlanItemArrangement, + PlanItemKey, + SongCatalogEntry, + SongOptionSet, +} from "@/lib/types"; import { usePlanItems } from "@/hooks/use-plan-items"; +import { createSongOptionsQueryOptions } from "@/hooks/use-song-options"; import { getItemTypeLabel, type DraftState } from "@/components/schedule/plan-tab-helpers"; import { toast } from "@/components/ui/sonner"; @@ -48,7 +64,11 @@ export function usePlanTabController({ }: UsePlanTabControllerArgs) { const queryClient = useQueryClient(); const queryKey = queryKeys.planItems(serviceTypeId, planId); - const { data: itemsData, isLoading } = usePlanItems(serviceTypeId, planId); + const { + data: itemsData, + isLoading, + isPlaceholderData, + } = usePlanItems(serviceTypeId, planId); const items = itemsData ?? EMPTY_PLAN_ITEMS; const [editingItemId, setEditingItemId] = useState(null); @@ -56,12 +76,56 @@ export function usePlanTabController({ const [pendingItemId, setPendingItemId] = useState(null); const [pendingSongId, setPendingSongId] = useState(null); - const invalidatePlanItems = () => - queryClient.invalidateQueries({ - queryKey, - }); + useEffect(() => { + setEditingItemId(null); + setSongPickerOpen(false); + }, [planId, serviceTypeId]); + + useEffect(() => { + if (!serviceTypeId || isPlaceholderData) return; + + const songIds = collectPlanSongOptionPrefetchIds(items); + if (songIds.length === 0) return; + + const timers = songIds.map((songId, index) => + window.setTimeout(() => { + void queryClient.prefetchQuery( + createSongOptionsQueryOptions(songId, serviceTypeId) + ); + }, 450 + index * 150) + ); + + return () => { + for (const timer of timers) { + window.clearTimeout(timer); + } + }; + }, [isPlaceholderData, items, queryClient, serviceTypeId]); + + const settlePlanItems = () => settlePlanItemsQuery(queryClient, queryKey); + + const prefetchItemSongOptions = useCallback( + (itemId: string) => { + if (!serviceTypeId) return; + const item = items.find((candidate) => candidate.id === itemId); + if (!item?.song) return; - const createItemMutation = useMutation({ + void queryClient.prefetchQuery( + createSongOptionsQueryOptions(item.song.id, serviceTypeId) + ); + }, + [items, queryClient, serviceTypeId] + ); + + const createItemMutation = useMutation< + PlanItem, + unknown, + "header" | "item", + { + snapshot: PlanItemsOptimisticSnapshot | undefined; + optimisticItemId: string; + } + >({ mutationFn: async (kind: "header" | "item") => { if (!serviceTypeId || !planId) { throw new Error("A service type and plan must be selected."); @@ -76,56 +140,110 @@ export function usePlanTabController({ return hydratePlanItem(item); }, - onMutate: (kind) => { - setPendingItemId(`create-${kind}`); + onMutate: async (kind) => { + await queryClient.cancelQueries({ queryKey }); + + const optimisticItemId = `optimistic-${kind}-${crypto.randomUUID()}`; + setPendingItemId(optimisticItemId); + + return { + optimisticItemId, + snapshot: applyPlanItemsOptimisticUpdate(queryClient, queryKey, (current) => + appendPlanItem( + current, + createOptimisticBasicPlanItem( + optimisticItemId, + kind, + nextPlanItemSequence(current) + ) + ) + ), + }; }, - onSuccess: (item) => { + onSuccess: (item, _kind, context) => { queryClient.setQueryData(queryKey, (current = EMPTY_PLAN_ITEMS) => - appendPlanItem(current, item) + replacePlanItemById(current, context.optimisticItemId, item) ); setEditingItemId(item.id); toast.success(`${getItemTypeLabel(item)} added.`); }, - onError: (error) => { + onError: (error, _kind, context) => { + restorePlanItemsSnapshot(queryClient, queryKey, context?.snapshot); toast.error(toErrorMessage(error, "Something went wrong.")); }, onSettled: () => { setPendingItemId(null); - void invalidatePlanItems(); + settlePlanItems(); }, }); - const addSongMutation = useMutation({ + const addSongMutation = useMutation< + PlanItem, + unknown, + SongCatalogEntry, + { + snapshot: PlanItemsOptimisticSnapshot | undefined; + optimisticItemId: string; + } + >({ mutationFn: async (song: SongCatalogEntry) => { if (!serviceTypeId || !planId) { throw new Error("A service type and plan must be selected."); } + const songOptionsQuery = createSongOptionsQueryOptions(song.id, serviceTypeId); + const songOptions = + queryClient.getQueryData(songOptionsQuery.queryKey) ?? null; + const item = await postJson("/api/plan-items", { service_type_id: serviceTypeId, plan_id: planId, + title: songOptions?.song.title ?? song.title, song_id: song.id, + arrangement_id: songOptions?.suggestedArrangementId ?? undefined, + key_id: songOptions?.suggestedKeyId ?? undefined, + selected_layout_id: songOptions?.suggestedLayoutId ?? undefined, }); return hydratePlanItem(item); }, - onMutate: (song) => { + onMutate: async (song) => { + await queryClient.cancelQueries({ queryKey }); + + const optimisticItemId = `optimistic-song-${song.id}-${crypto.randomUUID()}`; setPendingSongId(song.id); + setPendingItemId(optimisticItemId); + setSongPickerOpen(false); + + return { + optimisticItemId, + snapshot: applyPlanItemsOptimisticUpdate(queryClient, queryKey, (current) => + appendPlanItem( + current, + createOptimisticSongPlanItem( + optimisticItemId, + song, + nextPlanItemSequence(current) + ) + ) + ), + }; }, - onSuccess: (item) => { + onSuccess: (item, _song, context) => { queryClient.setQueryData(queryKey, (current = EMPTY_PLAN_ITEMS) => - appendPlanItem(current, item) + replacePlanItemById(current, context.optimisticItemId, item) ); setEditingItemId(item.id); - setSongPickerOpen(false); toast.success("Song added to plan."); }, - onError: (error) => { + onError: (error, _song, context) => { + restorePlanItemsSnapshot(queryClient, queryKey, context?.snapshot); toast.error(toErrorMessage(error, "Something went wrong.")); }, onSettled: () => { setPendingSongId(null); - void invalidatePlanItems(); + setPendingItemId(null); + settlePlanItems(); }, }); @@ -167,7 +285,7 @@ export function usePlanTabController({ }, onSettled: () => { setPendingItemId(null); - void invalidatePlanItems(); + settlePlanItems(); }, }); @@ -205,19 +323,34 @@ export function usePlanTabController({ }, onSettled: () => { setPendingItemId(null); - void invalidatePlanItems(); + settlePlanItems(); }, }); - const updateItemMutation = useMutation({ + const updateItemMutation = useMutation< + PlanItem, + unknown, + { + item: PlanItem; + draft: DraftState; + length: number | null; + optimisticArrangement: PlanItemArrangement | null; + optimisticKey: PlanItemKey | null; + }, + { snapshot: PlanItemsOptimisticSnapshot | undefined } + >({ mutationFn: async ({ item, draft, length, + optimisticArrangement: _optimisticArrangement, + optimisticKey: _optimisticKey, }: { item: PlanItem; draft: DraftState; length: number | null; + optimisticArrangement: PlanItemArrangement | null; + optimisticKey: PlanItemKey | null; }) => { if (!serviceTypeId || !planId) { throw new Error("A service type and plan must be selected."); @@ -237,39 +370,70 @@ export function usePlanTabController({ return hydratePlanItem(itemResponse); }, + onMutate: async ({ item, draft, length, optimisticArrangement, optimisticKey }) => { + setPendingItemId(item.id); + await queryClient.cancelQueries({ queryKey }); + + return { + snapshot: applyPlanItemsOptimisticUpdate(queryClient, queryKey, (current) => + replacePlanItem( + current, + applyPlanItemDraft(item, draft, length, optimisticArrangement, optimisticKey) + ) + ), + }; + }, + onError: (error, _input, context) => { + restorePlanItemsSnapshot(queryClient, queryKey, context?.snapshot); + toast.error(toErrorMessage(error, "Something went wrong.")); + }, onSuccess: (updatedItem) => { queryClient.setQueryData(queryKey, (current = EMPTY_PLAN_ITEMS) => replacePlanItem(current, updatedItem) ); }, onSettled: () => { - void invalidatePlanItems(); + setPendingItemId(null); + settlePlanItems(); }, }); return { items, isLoading, + isPlaceholderData, editingItemId, editingItem: editingItemId ? items.find((item) => item.id === editingItemId) ?? null : null, songPickerOpen, pendingItemId, pendingSongId, + isCreatingBasicItem: createItemMutation.isPending, isSavingItem: updateItemMutation.isPending, setEditingItemId, setSongPickerOpen, createBasicItem: (kind: "header" | "item") => createItemMutation.mutateAsync(kind), addSongToPlan: (song: SongCatalogEntry) => addSongMutation.mutateAsync(song), deleteItem: (itemId: string) => deleteItemMutation.mutateAsync(itemId), - reorderItems: (nextItems: PlanItem[]) => reorderItemsMutation.mutateAsync(nextItems), - saveItem: ({ - item, - draft, - length, - }: { + reorderItems: (nextItems: PlanItem[]) => { + if (planItemsHaveSameOrder(items, nextItems)) { + return Promise.resolve(); + } + + return reorderItemsMutation.mutateAsync(nextItems); + }, + prefetchItemSongOptions, + saveItem: (input: { item: PlanItem; draft: DraftState; length: number | null; - }) => updateItemMutation.mutateAsync({ item, draft, length }), + optimisticArrangement: PlanItemArrangement | null; + optimisticKey: PlanItemKey | null; + }) => { + if (!planItemDraftChangesItem(input.item, input.draft, input.length)) { + return Promise.resolve(); + } + + return updateItemMutation.mutateAsync(input); + }, }; } diff --git a/hooks/use-plans.ts b/hooks/use-plans.ts index 33627e8..6b8bb3a 100644 --- a/hooks/use-plans.ts +++ b/hooks/use-plans.ts @@ -1,11 +1,21 @@ import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect } from "react"; import { getJson } from "@/lib/http/client"; +import { readCachedPlansEntry, writeCachedPlans } from "@/lib/schedule-catalog-cache"; import type { Plan } from "@/lib/types"; import { queryKeys } from "@/lib/query-keys"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; export function usePlans(serviceTypeId: string | null) { - return useQuery({ - queryKey: queryKeys.plans(serviceTypeId), + const queryKey = queryKeys.plans(serviceTypeId); + const readCachedPlans = useCallback( + () => readCachedPlansEntry(serviceTypeId), + [serviceTypeId] + ); + useHydrateQueryFromCache(queryKey, readCachedPlans); + + const query = useQuery({ + queryKey, queryFn: async () => { if (!serviceTypeId) { return []; @@ -15,4 +25,11 @@ export function usePlans(serviceTypeId: string | null) { enabled: !!serviceTypeId, staleTime: 5 * 60 * 1000, // 5 minutes }); + + useEffect(() => { + if (!query.data || !serviceTypeId) return; + writeCachedPlans(serviceTypeId, query.data); + }, [query.data, serviceTypeId]); + + return query; } diff --git a/hooks/use-schedule-cache-optimism.ts b/hooks/use-schedule-cache-optimism.ts new file mode 100644 index 0000000..c50ab9f --- /dev/null +++ b/hooks/use-schedule-cache-optimism.ts @@ -0,0 +1,437 @@ +"use client"; + +import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import { clearCachedMyScheduledPlans } from "@/lib/my-scheduled-plans-cache"; +import { clearCachedPeople } from "@/lib/people-cache"; +import { clearCachedPeopleDashboards } from "@/lib/people-dashboard-cache"; +import { clearCachedTeamPositions } from "@/lib/team-positions-cache"; +import { queryKeys } from "@/lib/query-keys"; +import type { + FilledPositionPerson, + PersonWithAvailability, + TeamPosition, + TeamPositionGroup, +} from "@/lib/types"; + +export type OptimisticPlanPersonStatusCode = "C" | "U" | "D"; + +export interface OptimisticSchedulePerson { + id: string; + firstName?: string | null; + lastName?: string | null; + fullName: string; + photoUrl?: string | null; + photoThumbnailUrl?: string | null; +} + +export interface OptimisticScheduleSlot { + serviceTypeId: string; + planId: string; + teamId: string; + positionId: string; +} + +export interface ScheduleMutationInvalidateContext { + serviceTypeId?: string | null; + personId?: string | null; + planId?: string | null; + teamId?: string | null; + positionId?: string | null; +} + +export const SCHEDULE_MUTATION_RECONCILE_DELAY_MS = 2500; + +const activeRefetchTimers = new WeakMap< + QueryClient, + Map> +>(); + +interface ScheduleMutationSnapshot { + people: [QueryKey, PersonWithAvailability[] | undefined][]; + teamPositions: [QueryKey, TeamPositionGroup[] | undefined][]; +} + +function snapshotScheduleCaches(queryClient: QueryClient): ScheduleMutationSnapshot { + return { + people: queryClient.getQueriesData({ queryKey: ["people"] }), + teamPositions: queryClient.getQueriesData({ queryKey: ["team-positions"] }), + }; +} + +export function restoreScheduleCaches( + queryClient: QueryClient, + snapshot: ScheduleMutationSnapshot | undefined +) { + if (!snapshot) return; + for (const [queryKey, data] of snapshot.people) { + queryClient.setQueryData(queryKey, data); + } + for (const [queryKey, data] of snapshot.teamPositions) { + queryClient.setQueryData(queryKey, data); + } +} + +export function invalidateScheduleMutationQueries( + queryClient: QueryClient, + context: ScheduleMutationInvalidateContext +) { + for (const filters of getScheduleMutationQueryFilters(context)) { + void queryClient.invalidateQueries(filters); + } +} + +export function cancelScheduleMutationQueries( + queryClient: QueryClient, + context: ScheduleMutationInvalidateContext +) { + return Promise.all( + getScheduleMutationQueryFilters(context).map((filters) => + queryClient.cancelQueries(filters) + ) + ); +} + +export function settleScheduleMutationQueries( + queryClient: QueryClient, + context: ScheduleMutationInvalidateContext +) { + clearCachedMyScheduledPlans(); + clearCachedPeople(); + clearCachedPeopleDashboards(); + clearCachedTeamPositions(); + const filtersList = getScheduleMutationQueryFilters(context); + + for (const filters of filtersList) { + void queryClient.invalidateQueries({ ...filters, refetchType: "inactive" }); + } + + for (const filters of filtersList) { + scheduleActiveRefetch(queryClient, filters); + } +} + +function getScheduleMutationQueryFilters(context: ScheduleMutationInvalidateContext) { + const serviceTypeId = context.serviceTypeId ?? null; + const planId = context.planId ?? null; + const teamId = context.teamId ?? null; + const positionId = context.positionId ?? null; + + return [ + { + queryKey: ["my-scheduled-plans"], + }, + { + queryKey: + serviceTypeId && planId + ? ["team-positions", serviceTypeId, planId] + : ["team-positions"], + }, + { + queryKey: + serviceTypeId && teamId && positionId && planId + ? queryKeys.peopleForSlot(serviceTypeId, teamId, positionId, planId) + : ["people"], + }, + ]; +} + +function scheduleActiveRefetch( + queryClient: QueryClient, + filters: { queryKey: QueryKey } +) { + let clientTimers = activeRefetchTimers.get(queryClient); + if (!clientTimers) { + clientTimers = new Map(); + activeRefetchTimers.set(queryClient, clientTimers); + } + + const timerKey = JSON.stringify(filters.queryKey); + const currentTimer = clientTimers.get(timerKey); + if (currentTimer) { + clearTimeout(currentTimer); + } + + const nextTimer = setTimeout(() => { + clientTimers.delete(timerKey); + void queryClient.refetchQueries({ ...filters, type: "active" }); + }, SCHEDULE_MUTATION_RECONCILE_DELAY_MS); + clientTimers.set(timerKey, nextTimer); +} + +function statusToFilledStatus( + status: OptimisticPlanPersonStatusCode +): FilledPositionPerson["status"] | null { + if (status === "D") return null; + return status === "C" ? "confirmed" : "pending"; +} + +function recalculateFilledCounts(position: TeamPosition): TeamPosition { + const people = position.filledPeople ?? []; + return { + ...position, + filledConfirmedCount: people.filter((person) => person.status === "confirmed").length, + filledPendingCount: people.filter((person) => person.status === "pending").length, + filledPeople: people.length > 0 ? people : undefined, + }; +} + +function upsertFilledPerson( + position: TeamPosition, + person: OptimisticSchedulePerson, + planPersonId: string, + statusCode: OptimisticPlanPersonStatusCode +): TeamPosition { + const status = statusToFilledStatus(statusCode); + const currentPeople = position.filledPeople ?? []; + const filteredPeople = currentPeople.filter( + (filledPerson) => + filledPerson.planPersonId !== planPersonId && filledPerson.id !== person.id + ); + + if (!status) { + return recalculateFilledCounts({ ...position, filledPeople: filteredPeople }); + } + + const nextPerson: FilledPositionPerson = { + id: person.id, + planPersonId, + name: person.fullName, + status, + rawStatus: statusCode, + photoThumbnailUrl: person.photoThumbnailUrl ?? null, + }; + + const filledPeople = [...filteredPeople, nextPerson].sort((a, b) => { + if (a.status !== b.status) return a.status === "confirmed" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return recalculateFilledCounts({ ...position, filledPeople }); +} + +function removeFilledPerson(position: TeamPosition, planPersonId: string): TeamPosition { + const filledPeople = (position.filledPeople ?? []).filter( + (person) => person.planPersonId !== planPersonId + ); + return recalculateFilledCounts({ ...position, filledPeople }); +} + +function updateFilledPersonStatus( + position: TeamPosition, + planPersonId: string, + statusCode: OptimisticPlanPersonStatusCode +): TeamPosition { + const status = statusToFilledStatus(statusCode); + const currentPeople = position.filledPeople ?? []; + const existingPerson = currentPeople.find((person) => person.planPersonId === planPersonId); + if (!existingPerson) return position; + + if (!status) { + return removeFilledPerson(position, planPersonId); + } + + const filledPeople = currentPeople + .map((person) => + person.planPersonId === planPersonId + ? { ...person, status, rawStatus: statusCode } + : person + ) + .sort((a, b) => { + if (a.status !== b.status) return a.status === "confirmed" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return recalculateFilledCounts({ ...position, filledPeople }); +} + +function applyStatusToPerson( + person: PersonWithAvailability, + statusCode: OptimisticPlanPersonStatusCode, + planPersonId: string +): PersonWithAvailability { + return { + ...person, + isScheduledForSelectedPlanPosition: true, + isConfirmedForSelectedPlanPosition: statusCode === "C", + isDeclinedForSelectedPlanPosition: statusCode === "D", + selectedPlanDeclineReason: statusCode === "D" ? null : undefined, + scheduledPlanPersonId: planPersonId, + }; +} + +function createOptimisticPerson( + person: OptimisticSchedulePerson, + planPersonId: string +): PersonWithAvailability { + const [firstFallback = "", ...lastParts] = person.fullName.trim().split(/\s+/); + return applyStatusToPerson( + { + id: person.id, + firstName: person.firstName ?? firstFallback, + lastName: person.lastName ?? lastParts.join(" "), + fullName: person.fullName, + photoUrl: person.photoUrl ?? null, + photoThumbnailUrl: person.photoThumbnailUrl ?? null, + archived: false, + positions: [], + }, + "U", + planPersonId + ); +} + +function clearStatusFromPerson(person: PersonWithAvailability): PersonWithAvailability { + return { + ...person, + isScheduledForSelectedPlanPosition: false, + isConfirmedForSelectedPlanPosition: false, + isDeclinedForSelectedPlanPosition: false, + selectedPlanDeclineReason: undefined, + scheduledPlanPersonId: undefined, + }; +} + +export function optimisticallySchedulePerson( + queryClient: QueryClient, + slot: OptimisticScheduleSlot, + person: OptimisticSchedulePerson, + planPersonId: string +): ScheduleMutationSnapshot { + const snapshot = snapshotScheduleCaches(queryClient); + + queryClient.setQueriesData( + { + queryKey: queryKeys.peopleForSlot( + slot.serviceTypeId, + slot.teamId, + slot.positionId, + slot.planId + ), + }, + (people) => { + if (!people) return people; + let found = false; + const updatedPeople = people.map((cachedPerson) => { + if (cachedPerson.id !== person.id) return cachedPerson; + found = true; + return applyStatusToPerson(cachedPerson, "U", planPersonId); + }); + return found ? updatedPeople : [createOptimisticPerson(person, planPersonId), ...updatedPeople]; + } + ); + + queryClient.setQueriesData( + { queryKey: ["team-positions", slot.serviceTypeId, slot.planId] }, + (groups) => + groups?.map((group) => + group.teamId === slot.teamId + ? { + ...group, + positions: group.positions.map((position) => + position.id === slot.positionId + ? upsertFilledPerson(position, person, planPersonId, "U") + : position + ), + } + : group + ) + ); + + return snapshot; +} + +export function reconcileOptimisticPlanPersonId( + queryClient: QueryClient, + optimisticPlanPersonId: string, + planPersonId: string +) { + if (optimisticPlanPersonId === planPersonId) return; + + queryClient.setQueriesData( + { queryKey: ["people"] }, + (people) => + people?.map((person) => + person.scheduledPlanPersonId === optimisticPlanPersonId + ? { ...person, scheduledPlanPersonId: planPersonId } + : person + ) + ); + + queryClient.setQueriesData( + { queryKey: ["team-positions"] }, + (groups) => + groups?.map((group) => ({ + ...group, + positions: group.positions.map((position) => ({ + ...position, + filledPeople: position.filledPeople?.map((person) => + person.planPersonId === optimisticPlanPersonId + ? { ...person, planPersonId } + : person + ), + })), + })) + ); +} + +export function optimisticallyUpdatePlanPersonStatus( + queryClient: QueryClient, + planPersonId: string, + statusCode: OptimisticPlanPersonStatusCode +): ScheduleMutationSnapshot { + const snapshot = snapshotScheduleCaches(queryClient); + + queryClient.setQueriesData( + { queryKey: ["people"] }, + (people) => + people?.map((person) => + person.scheduledPlanPersonId === planPersonId + ? applyStatusToPerson(person, statusCode, planPersonId) + : person + ) + ); + + queryClient.setQueriesData( + { queryKey: ["team-positions"] }, + (groups) => + groups?.map((group) => ({ + ...group, + positions: group.positions.map((position) => + updateFilledPersonStatus(position, planPersonId, statusCode) + ), + })) + ); + + return snapshot; +} + +export function optimisticallyUnschedulePlanPerson( + queryClient: QueryClient, + planPersonId: string, + personId?: string | null +): ScheduleMutationSnapshot { + const snapshot = snapshotScheduleCaches(queryClient); + + queryClient.setQueriesData( + { queryKey: ["people"] }, + (people) => + people?.map((person) => + person.scheduledPlanPersonId === planPersonId || (!!personId && person.id === personId) + ? clearStatusFromPerson(person) + : person + ) + ); + + queryClient.setQueriesData( + { queryKey: ["team-positions"] }, + (groups) => + groups?.map((group) => ({ + ...group, + positions: group.positions.map((position) => + removeFilledPerson(position, planPersonId) + ), + })) + ); + + return snapshot; +} diff --git a/hooks/use-schedule-plan-person.ts b/hooks/use-schedule-plan-person.ts index cbc670b..5150ce2 100644 --- a/hooks/use-schedule-plan-person.ts +++ b/hooks/use-schedule-plan-person.ts @@ -1,7 +1,16 @@ "use client"; import { useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { HttpClientError, postJson } from "@/lib/http/client"; +import { + cancelScheduleMutationQueries, + optimisticallySchedulePerson, + reconcileOptimisticPlanPersonId, + restoreScheduleCaches, + settleScheduleMutationQueries, + type OptimisticSchedulePerson, +} from "@/hooks/use-schedule-cache-optimism"; function formatSchedulePayloadError(json: unknown): string { if (!json || typeof json !== "object") return "Failed to schedule"; @@ -44,7 +53,10 @@ export function useSchedulePlanPerson({ planId, teamId, positionId, + teamName, + positionName, canSchedule, + onOptimisticSchedule, onScheduleSuccess, onScheduleError, oneOff = false, @@ -53,45 +65,89 @@ export function useSchedulePlanPerson({ planId: string | null | undefined; teamId: string | null | undefined; positionId: string | null | undefined; + teamName?: string | null | undefined; + positionName?: string | null | undefined; canSchedule: boolean; + onOptimisticSchedule?: () => void; onScheduleSuccess?: () => void; onScheduleError?: (message: string) => void; oneOff?: boolean; }) { - const [isScheduling, setIsScheduling] = useState(false); + const queryClient = useQueryClient(); const [scheduleSuccess, setScheduleSuccess] = useState(false); const [scheduleError, setScheduleError] = useState(null); useEffect(() => { - setIsScheduling(false); setScheduleSuccess(false); setScheduleError(null); }, [serviceTypeId, planId, teamId, positionId]); - const handleSchedule = async (personId: string) => { - if (!serviceTypeId || !planId || !teamId || !positionId || isScheduling || !canSchedule) return; - - setIsScheduling(true); - setScheduleError(null); - - try { - await postJson<{ success: boolean }>("/api/schedule", { + const scheduleMutation = useMutation({ + mutationFn: async ({ person }: { person: OptimisticSchedulePerson }) => + postJson<{ success: boolean; data?: { id?: string } }>("/api/schedule", { serviceTypeId, - personId, + personId: person.id, planId, teamId, positionId, + teamName: teamName || undefined, + positionName: positionName || undefined, oneOff, + }), + onMutate: async ({ person }) => { + if (!serviceTypeId || !planId || !teamId || !positionId) return {}; + + const optimisticPlanPersonId = `optimistic:${planId}:${teamId}:${positionId}:${person.id}`; + await cancelScheduleMutationQueries(queryClient, { + serviceTypeId, + planId, + teamId, + positionId, }); + setScheduleSuccess(true); + const snapshot = optimisticallySchedulePerson( + queryClient, + { serviceTypeId, planId, teamId, positionId }, + person, + optimisticPlanPersonId + ); + onOptimisticSchedule?.(); + + return { optimisticPlanPersonId, snapshot }; + }, + onSuccess: (result, _variables, context) => { + const planPersonId = result.data?.id; + if (planPersonId && context.optimisticPlanPersonId) { + reconcileOptimisticPlanPersonId( + queryClient, + context.optimisticPlanPersonId, + planPersonId + ); + } + settleScheduleMutationQueries(queryClient, { + serviceTypeId, + planId, + teamId, + positionId, + }); onScheduleSuccess?.(); - } catch (err) { + }, + onError: (err, _variables, context) => { if (err instanceof HttpClientError && err.code === "ALREADY_SCHEDULED") { setScheduleSuccess(true); + settleScheduleMutationQueries(queryClient, { + serviceTypeId, + planId, + teamId, + positionId, + }); onScheduleSuccess?.(); return; } + restoreScheduleCaches(queryClient, context?.snapshot); + setScheduleSuccess(false); const message = err instanceof HttpClientError ? formatScheduleClientError(err) @@ -100,10 +156,32 @@ export function useSchedulePlanPerson({ : "Failed to schedule"; setScheduleError(message); onScheduleError?.(message); - } finally { - setIsScheduling(false); - } + }, + }); + + const handleSchedule = (input: string | OptimisticSchedulePerson) => { + if (!serviceTypeId || !planId || !teamId || !positionId || scheduleMutation.isPending || !canSchedule) return; + + setScheduleError(null); + const person = + typeof input === "string" + ? { id: input, fullName: "Unknown person", photoThumbnailUrl: null } + : { + id: input.id, + firstName: "firstName" in input ? input.firstName : undefined, + lastName: "lastName" in input ? input.lastName : undefined, + fullName: input.fullName, + photoUrl: "photoUrl" in input ? input.photoUrl : undefined, + photoThumbnailUrl: input.photoThumbnailUrl, + }; + + scheduleMutation.mutate({ person }); }; - return { isScheduling, scheduleSuccess, scheduleError, handleSchedule }; + return { + isScheduling: scheduleMutation.isPending, + scheduleSuccess, + scheduleError, + handleSchedule, + }; } diff --git a/hooks/use-service-types.ts b/hooks/use-service-types.ts index 7c25ef0..b5b0309 100644 --- a/hooks/use-service-types.ts +++ b/hooks/use-service-types.ts @@ -1,12 +1,29 @@ import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect } from "react"; import { getJson } from "@/lib/http/client"; +import { + readCachedServiceTypesEntry, + writeCachedServiceTypes, +} from "@/lib/schedule-catalog-cache"; import type { ServiceType } from "@/lib/types"; import { queryKeys } from "@/lib/query-keys"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; export function useServiceTypes() { - return useQuery({ - queryKey: queryKeys.serviceTypes(), + const queryKey = queryKeys.serviceTypes(); + const readCachedServiceTypes = useCallback(() => readCachedServiceTypesEntry(), []); + useHydrateQueryFromCache(queryKey, readCachedServiceTypes); + + const query = useQuery({ + queryKey, queryFn: () => getJson("/api/service-types"), staleTime: 10 * 60 * 1000, // 10 minutes }); + + useEffect(() => { + if (!query.data) return; + writeCachedServiceTypes(query.data); + }, [query.data]); + + return query; } diff --git a/hooks/use-song-options.ts b/hooks/use-song-options.ts index 58eb8c0..aa8178a 100644 --- a/hooks/use-song-options.ts +++ b/hooks/use-song-options.ts @@ -1,14 +1,35 @@ import { useQuery } from "@tanstack/react-query"; +import { useCallback } from "react"; import { getJson } from "@/lib/http/client"; import { queryKeys } from "@/lib/query-keys"; import { hydrateSongOptionSet, type SerializedSongOptionSet } from "@/lib/song-catalog-client"; +import { readCachedSongOptions, writeCachedSongOptions } from "@/lib/song-options-cache"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; import type { SongOptionSet } from "@/lib/types"; export function useSongOptions( songId: string | null, serviceTypeId: string | null ) { + const queryKey = queryKeys.songOptions(songId, serviceTypeId); + const readCachedOptions = useCallback( + () => readCachedSongOptions(songId, serviceTypeId), + [serviceTypeId, songId] + ); + useHydrateQueryFromCache(queryKey, readCachedOptions); + return useQuery({ + ...createSongOptionsQueryOptions(songId, serviceTypeId), + queryKey, + enabled: !!songId && !!serviceTypeId, + }); +} + +export function createSongOptionsQueryOptions( + songId: string | null, + serviceTypeId: string | null +) { + return { queryKey: queryKeys.songOptions(songId, serviceTypeId), queryFn: async () => { if (!songId || !serviceTypeId) return null; @@ -21,9 +42,11 @@ export function useSongOptions( `/api/songs/${songId}/options?${params.toString()}` ); - return hydrateSongOptionSet(optionSet); + const hydratedOptions = hydrateSongOptionSet(optionSet); + writeCachedSongOptions(songId, serviceTypeId, hydratedOptions); + return hydratedOptions; }, - enabled: !!songId && !!serviceTypeId, + placeholderData: (previousOptions: SongOptionSet | null | undefined) => previousOptions, staleTime: 5 * 60 * 1000, - }); + }; } diff --git a/hooks/use-song-search.ts b/hooks/use-song-search.ts index 0abb0e0..5c35628 100644 --- a/hooks/use-song-search.ts +++ b/hooks/use-song-search.ts @@ -1,7 +1,14 @@ +import { useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { getJson } from "@/lib/http/client"; import { queryKeys } from "@/lib/query-keys"; import { hydrateSongCatalogEntry, type SerializedSongCatalogEntry } from "@/lib/song-catalog-client"; +import { + normalizeSongSearchQuery, + readCachedSongSearch, + writeCachedSongSearch, +} from "@/lib/song-search-cache"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; import type { SongCatalogEntry } from "@/lib/types"; const SONG_SEARCH_STALE_TIME_MS = 5 * 60 * 1000; @@ -10,10 +17,16 @@ export function useSongSearch( serviceTypeId: string | null, query: string ) { - const trimmedQuery = query.trim(); + const trimmedQuery = normalizeSongSearchQuery(query); + const queryKey = queryKeys.songSearch(serviceTypeId, trimmedQuery); + const readCachedSongs = useCallback( + () => readCachedSongSearch(serviceTypeId, trimmedQuery), + [serviceTypeId, trimmedQuery] + ); + useHydrateQueryFromCache(queryKey, readCachedSongs); return useQuery({ - queryKey: queryKeys.songSearch(serviceTypeId, trimmedQuery), + queryKey, queryFn: async () => { if (!serviceTypeId || !trimmedQuery) return []; @@ -26,9 +39,12 @@ export function useSongSearch( `/api/songs/search?${params.toString()}` ); - return songs.map(hydrateSongCatalogEntry); + const hydratedSongs = songs.map(hydrateSongCatalogEntry); + writeCachedSongSearch(serviceTypeId, trimmedQuery, hydratedSongs); + return hydratedSongs; }, enabled: !!serviceTypeId && trimmedQuery.length > 0, + placeholderData: (previousSongs) => previousSongs, staleTime: SONG_SEARCH_STALE_TIME_MS, }); } diff --git a/hooks/use-team-positions.ts b/hooks/use-team-positions.ts index 7a9d7a9..cc6f084 100644 --- a/hooks/use-team-positions.ts +++ b/hooks/use-team-positions.ts @@ -1,7 +1,10 @@ +import { useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { getJson } from "@/lib/http/client"; import type { TeamPositionGroup } from "@/lib/types"; import { queryKeys } from "@/lib/query-keys"; +import { useHydrateQueryFromCache } from "@/lib/query-cache-hydration"; +import { readCachedTeamPositions, writeCachedTeamPositions } from "@/lib/team-positions-cache"; const TEAM_POSITIONS_STALE_TIME_MS = 10 * 60 * 1000; @@ -31,9 +34,11 @@ export function createTeamPositionsQueryOptions( if (!serviceTypeId || !planId) { return []; } - return getJson( + const groups = await getJson( buildTeamPositionsUrl(serviceTypeId, planId, seriesId) ); + writeCachedTeamPositions(serviceTypeId, planId, seriesId, groups); + return groups; }, staleTime: TEAM_POSITIONS_STALE_TIME_MS, }; @@ -44,8 +49,17 @@ export function useTeamPositions( planId: string | null, seriesId: string | null ) { + const queryKey = queryKeys.teamPositions(serviceTypeId, planId, seriesId); + const readCachedGroups = useCallback( + () => readCachedTeamPositions(serviceTypeId, planId, seriesId), + [planId, seriesId, serviceTypeId] + ); + useHydrateQueryFromCache(queryKey, readCachedGroups); + return useQuery({ ...createTeamPositionsQueryOptions(serviceTypeId, planId, seriesId), + queryKey, enabled: !!serviceTypeId && !!planId, + placeholderData: (previousGroups) => previousGroups, }); } diff --git a/hooks/use-unschedule-plan-person.ts b/hooks/use-unschedule-plan-person.ts index 1dd947c..40c3473 100644 --- a/hooks/use-unschedule-plan-person.ts +++ b/hooks/use-unschedule-plan-person.ts @@ -1,8 +1,14 @@ "use client"; -import { useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { deleteJson, HttpClientError } from "@/lib/http/client"; +import { + cancelScheduleMutationQueries, + optimisticallyUnschedulePlanPerson, + restoreScheduleCaches, + settleScheduleMutationQueries, + type ScheduleMutationInvalidateContext, +} from "@/hooks/use-schedule-cache-optimism"; function formatUnscheduleError(error: unknown): string { if (error instanceof HttpClientError) return error.message || "Failed to unschedule"; @@ -18,37 +24,50 @@ export function useUnschedulePlanPerson({ onError?: (message: string) => void; } = {}) { const queryClient = useQueryClient(); - const [isUnscheduling, setIsUnscheduling] = useState(false); - const handleUnschedule = async ( - planPersonId: string | null | undefined, - context?: { serviceTypeId?: string | null; personId?: string | null; planId?: string | null } - ) => { - if (!planPersonId || isUnscheduling) return; - - setIsUnscheduling(true); - try { - await deleteJson<{ success: boolean }>( + const unscheduleMutation = useMutation({ + mutationFn: ({ + planPersonId, + context, + }: { + planPersonId: string; + context?: ScheduleMutationInvalidateContext & { personId?: string | null }; + }) => + deleteJson<{ success: boolean }>( `/api/schedule/${encodeURIComponent(planPersonId)}`, { serviceTypeId: context?.serviceTypeId ?? undefined, personId: context?.personId ?? undefined, planId: context?.planId ?? undefined, } - ); - - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["team-positions"] }), - queryClient.invalidateQueries({ queryKey: ["people"] }), - ]); - + ), + onMutate: async ({ planPersonId, context }) => { + await cancelScheduleMutationQueries(queryClient, context ?? {}); + return { + snapshot: optimisticallyUnschedulePlanPerson( + queryClient, + planPersonId, + context?.personId + ), + }; + }, + onSuccess: (_result, variables) => { + settleScheduleMutationQueries(queryClient, variables.context ?? {}); onSuccess?.(); - } catch (error) { + }, + onError: (error, _variables, context) => { + restoreScheduleCaches(queryClient, context?.snapshot); onError?.(formatUnscheduleError(error)); - } finally { - setIsUnscheduling(false); - } + }, + }); + + const handleUnschedule = ( + planPersonId: string | null | undefined, + context?: ScheduleMutationInvalidateContext & { personId?: string | null } + ) => { + if (!planPersonId || unscheduleMutation.isPending) return; + unscheduleMutation.mutate({ planPersonId, context }); }; - return { isUnscheduling, handleUnschedule }; + return { isUnscheduling: unscheduleMutation.isPending, handleUnschedule }; } diff --git a/hooks/use-update-plan-person-status.ts b/hooks/use-update-plan-person-status.ts index 379bffe..1e2388e 100644 --- a/hooks/use-update-plan-person-status.ts +++ b/hooks/use-update-plan-person-status.ts @@ -1,8 +1,15 @@ "use client"; import { useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { HttpClientError, patchJson } from "@/lib/http/client"; +import { + cancelScheduleMutationQueries, + optimisticallyUpdatePlanPersonStatus, + restoreScheduleCaches, + settleScheduleMutationQueries, + type ScheduleMutationInvalidateContext, +} from "@/hooks/use-schedule-cache-optimism"; export type PlanPersonStatusCode = "C" | "U" | "D"; @@ -24,38 +31,55 @@ export function useUpdatePlanPersonStatus({ onError?: (message: string) => void; } = {}) { const queryClient = useQueryClient(); - const [isUpdating, setIsUpdating] = useState(false); const [updateError, setUpdateError] = useState(null); - const handleUpdate = async ( - planPersonId: string | null | undefined, - status: PlanPersonStatusCode - ) => { - if (!planPersonId || isUpdating) return; - - setIsUpdating(true); - setUpdateError(null); - - try { - await patchJson<{ success: boolean }>( + const updateMutation = useMutation({ + mutationFn: ({ + planPersonId, + status, + context, + }: { + planPersonId: string; + status: PlanPersonStatusCode; + context?: ScheduleMutationInvalidateContext; + }) => + patchJson<{ success: boolean }>( `/api/schedule/${encodeURIComponent(planPersonId)}/status`, - { status } - ); - - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["team-positions"] }), - queryClient.invalidateQueries({ queryKey: ["people"] }), - ]); - + { + status, + serviceTypeId: context?.serviceTypeId ?? undefined, + personId: context?.personId ?? undefined, + planId: context?.planId ?? undefined, + } + ), + onMutate: async ({ planPersonId, status, context }) => { + await cancelScheduleMutationQueries(queryClient, context ?? {}); + return { + snapshot: optimisticallyUpdatePlanPersonStatus(queryClient, planPersonId, status), + }; + }, + onSuccess: (_result, variables) => { + settleScheduleMutationQueries(queryClient, variables.context ?? {}); onSuccess?.(); - } catch (err) { + }, + onError: (err, _variables, context) => { + restoreScheduleCaches(queryClient, context?.snapshot); const message = formatUpdateStatusError(err); setUpdateError(message); onError?.(message); - } finally { - setIsUpdating(false); - } + }, + }); + + const handleUpdate = ( + planPersonId: string | null | undefined, + status: PlanPersonStatusCode, + context?: ScheduleMutationInvalidateContext + ) => { + if (!planPersonId || updateMutation.isPending) return; + + setUpdateError(null); + updateMutation.mutate({ planPersonId, status, context }); }; - return { isUpdating, updateError, handleUpdate }; + return { isUpdating: updateMutation.isPending, updateError, handleUpdate }; } diff --git a/lib/account-panel-cache.test.ts b/lib/account-panel-cache.test.ts new file mode 100644 index 0000000..aa76959 --- /dev/null +++ b/lib/account-panel-cache.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + parseCachedAccountPanel, + serializeAccountPanel, + summarizeAccountPanel, + type AccountPanelSource, +} from "@/lib/account-panel-cache"; + +function source(): AccountPanelSource { + return { + session: { + name: "Jake Bodea", + email: "jake@example.com", + image: "https://example.com/avatar.jpg", + }, + selectedAccountId: "account-2", + accounts: [ + { + id: "account-1", + identity: { + name: "Jake", + organizationName: "First Church", + }, + }, + { + id: "account-2", + identity: { + name: "Planning Center Jake", + organizationName: "Agape Christian Church", + }, + }, + ], + }; +} + +describe("account panel cache", () => { + it("summarizes only the sidebar identity needed for immediate shell paint", () => { + expect(summarizeAccountPanel(source())).toEqual({ + organizationName: "Agape Christian Church", + avatarName: "Planning Center Jake", + image: "https://example.com/avatar.jpg", + }); + }); + + it("round-trips valid cached summaries", () => { + const summary = { + organizationName: "Agape Christian Church", + avatarName: "Jake", + image: null, + }; + + expect(parseCachedAccountPanel(serializeAccountPanel(summary))).toEqual(summary); + }); + + it("ignores invalid cache payloads", () => { + expect(parseCachedAccountPanel(null)).toBeNull(); + expect(parseCachedAccountPanel("{}")).toBeNull(); + expect(parseCachedAccountPanel("{bad json")).toBeNull(); + }); +}); diff --git a/lib/account-panel-cache.ts b/lib/account-panel-cache.ts new file mode 100644 index 0000000..a3733d7 --- /dev/null +++ b/lib/account-panel-cache.ts @@ -0,0 +1,76 @@ +export const ACCOUNT_PANEL_CACHE_KEY = "worshipadmin:account-panel"; + +export interface AccountPanelSummary { + organizationName: string; + avatarName: string | null; + image: string | null; +} + +export interface AccountPanelSource { + session: { + name: string; + email: string; + image: string | null; + }; + selectedAccountId: string | null; + accounts: Array<{ + id: string; + identity: { + name: string | null; + organizationName: string | null; + } | null; + }>; +} + +const DEFAULT_SUMMARY: AccountPanelSummary = { + organizationName: "worshipadmin.com", + avatarName: null, + image: null, +}; + +export function summarizeAccountPanel(source: AccountPanelSource | null): AccountPanelSummary { + if (!source) return DEFAULT_SUMMARY; + + const selectedAccount = source.selectedAccountId + ? source.accounts.find((account) => account.id === source.selectedAccountId) ?? null + : source.accounts[0] ?? null; + + return { + organizationName: selectedAccount?.identity?.organizationName || DEFAULT_SUMMARY.organizationName, + avatarName: + selectedAccount?.identity?.name || + source.session.name || + source.session.email || + null, + image: source.session.image, + }; +} + +export function parseCachedAccountPanel(raw: string | null): AccountPanelSummary | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const value = parsed as Partial; + if (typeof value.organizationName !== "string" || !value.organizationName.trim()) { + return null; + } + + return { + organizationName: value.organizationName, + avatarName: typeof value.avatarName === "string" && value.avatarName.trim() + ? value.avatarName + : null, + image: typeof value.image === "string" && value.image.trim() + ? value.image + : null, + }; + } catch { + return null; + } +} + +export function serializeAccountPanel(summary: AccountPanelSummary): string { + return JSON.stringify(summary); +} diff --git a/lib/http/client.ts b/lib/http/client.ts index 01e5c35..781eb72 100644 --- a/lib/http/client.ts +++ b/lib/http/client.ts @@ -1,4 +1,5 @@ import { mergeHeaders } from "@/lib/http/merge-headers"; +import { elapsedMs, formatDurationMs, nowMs } from "@/lib/http/timing"; export class HttpClientError extends Error { readonly status: number; @@ -53,13 +54,56 @@ async function parseSuccess(response: Response): Promise { } async function requestJson(url: string, init: RequestInit): Promise { - const response = await fetch(url, init); + const startedAtMs = nowMs(); + let response: Response; + + try { + response = await fetch(url, init); + } catch (error) { + logHttpTiming(url, init, startedAtMs, undefined, error); + throw error; + } + + logHttpTiming(url, init, startedAtMs, response); + if (!response.ok) { throw await parseError(response); } return parseSuccess(response); } +function logHttpTiming( + url: string, + init: RequestInit, + startedAtMs: number, + response?: Response, + error?: unknown +) { + if (!shouldLogHttpTimings()) return; + + const method = (init.method ?? "GET").toUpperCase(); + const durationMs = elapsedMs(startedAtMs); + const routeMs = response?.headers.get("x-worshipadmin-route-ms"); + const status = response?.status ?? "ERR"; + const errorMessage = error instanceof Error ? error.message : undefined; + + console.debug("[http]", { + method, + url, + status, + durationMs: formatDurationMs(durationMs), + routeMs, + error: errorMessage, + }); +} + +function shouldLogHttpTimings(): boolean { + return ( + process.env.NODE_ENV !== "production" || + process.env.NEXT_PUBLIC_HTTP_TIMING_LOGS === "1" + ); +} + export async function getJson(url: string, init?: RequestInit): Promise { return requestJson(url, { method: "GET", diff --git a/lib/http/route-handler.ts b/lib/http/route-handler.ts index fc48838..bf0a7d7 100644 --- a/lib/http/route-handler.ts +++ b/lib/http/route-handler.ts @@ -1,22 +1,30 @@ import { NextResponse } from "next/server"; import { ZodError } from "zod"; import { ApiError } from "@/lib/http/api-error"; +import { + elapsedMs, + setRouteTimingHeaders, + nowMs, +} from "@/lib/http/timing"; import { logger } from "@/lib/logger"; import { PlanningCenterApiError } from "@/lib/planning-center/core-client"; const log = logger.for("http/route-handler"); export async function handleRoute(handler: () => Promise) { + const startedAtMs = nowMs(); + try { const data = await handler(); if (data instanceof Response) { - return data; + return withRouteTiming(data, startedAtMs); } - return NextResponse.json(data); + return withRouteTiming(NextResponse.json(data), startedAtMs); } catch (error) { if (error instanceof ApiError) { log.warn({ err: error, code: error.code, status: error.status }, "API route error"); - return NextResponse.json( + return jsonWithRouteTiming( + startedAtMs, { error: error.message, code: error.code, @@ -28,7 +36,8 @@ export async function handleRoute(handler: () => Promise) { if (error instanceof ZodError) { log.warn({ err: error }, "API route validation error"); - return NextResponse.json( + return jsonWithRouteTiming( + startedAtMs, { error: "Invalid request", code: "INVALID_REQUEST", @@ -62,7 +71,8 @@ export async function handleRoute(handler: () => Promise) { "Planning Center route error" ); - return NextResponse.json( + return jsonWithRouteTiming( + startedAtMs, { error: message, code, @@ -76,7 +86,8 @@ export async function handleRoute(handler: () => Promise) { const message = error instanceof Error ? error.message : "Unknown error"; log.error({ err: error instanceof Error ? error : new Error(String(error)) }, "Unhandled route error"); - return NextResponse.json( + return jsonWithRouteTiming( + startedAtMs, { error: "Internal server error", code: "INTERNAL_SERVER_ERROR", @@ -86,3 +97,28 @@ export async function handleRoute(handler: () => Promise) { ); } } + +function jsonWithRouteTiming( + startedAtMs: number, + body: unknown, + init?: ResponseInit +) { + return withRouteTiming(NextResponse.json(body, init), startedAtMs); +} + +function withRouteTiming(response: Response, startedAtMs: number): Response { + const durationMs = elapsedMs(startedAtMs); + + try { + setRouteTimingHeaders(response.headers, durationMs); + return response; + } catch { + const headers = new Headers(response.headers); + setRouteTimingHeaders(headers, durationMs); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } +} diff --git a/lib/http/timing.test.ts b/lib/http/timing.test.ts new file mode 100644 index 0000000..890395d --- /dev/null +++ b/lib/http/timing.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + elapsedMs, + formatDurationMs, + setRouteTimingHeaders, +} from "@/lib/http/timing"; + +describe("http timing helpers", () => { + it("formats durations for response headers and logs", () => { + expect(formatDurationMs(12.345)).toBe("12.3"); + expect(formatDurationMs(-4)).toBe("0.0"); + expect(formatDurationMs(Number.NaN)).toBe("0.0"); + }); + + it("never reports negative elapsed time", () => { + expect(elapsedMs(10, 4)).toBe(0); + expect(elapsedMs(10, 13.25)).toBe(3.25); + }); + + it("sets route timing headers", () => { + const headers = new Headers(); + + setRouteTimingHeaders(headers, 42.24); + + expect(headers.get("Server-Timing")).toBe("app;dur=42.2"); + expect(headers.get("x-worshipadmin-route-ms")).toBe("42.2"); + }); +}); diff --git a/lib/http/timing.ts b/lib/http/timing.ts new file mode 100644 index 0000000..bac7bb1 --- /dev/null +++ b/lib/http/timing.ts @@ -0,0 +1,18 @@ +export function nowMs(): number { + return typeof performance === "undefined" ? Date.now() : performance.now(); +} + +export function elapsedMs(startedAtMs: number, endedAtMs: number = nowMs()): number { + return Math.max(0, endedAtMs - startedAtMs); +} + +export function formatDurationMs(durationMs: number): string { + if (!Number.isFinite(durationMs)) return "0.0"; + return Math.max(0, durationMs).toFixed(1); +} + +export function setRouteTimingHeaders(headers: Headers, durationMs: number): void { + const formatted = formatDurationMs(durationMs); + headers.set("Server-Timing", `app;dur=${formatted}`); + headers.set("x-worshipadmin-route-ms", formatted); +} diff --git a/lib/my-scheduled-plans-cache.test.ts b/lib/my-scheduled-plans-cache.test.ts new file mode 100644 index 0000000..93e29fd --- /dev/null +++ b/lib/my-scheduled-plans-cache.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedMyScheduledPlans, + readCachedMyScheduledPlans, + writeCachedMyScheduledPlans, +} from "@/lib/my-scheduled-plans-cache"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +describe("my scheduled plans cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("round-trips scheduled plan ids with the saved timestamp", () => { + const savedAt = new Date("2026-05-23T18:00:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedMyScheduledPlans("plan-1,plan-2", { planIds: ["plan-2"] }); + + expect(readCachedMyScheduledPlans("plan-1,plan-2")).toEqual({ + savedAt, + data: { planIds: ["plan-2"] }, + }); + }); + + it("does not read a different plan lookup snapshot", () => { + writeCachedMyScheduledPlans("plan-1,plan-2", { planIds: ["plan-2"] }); + + expect(readCachedMyScheduledPlans("plan-1,plan-3")).toBeUndefined(); + }); + + it("ignores invalid cache payloads", () => { + window.localStorage.setItem( + "worshipadmin:my-scheduled-plans:v1:plan-1%2Cplan-2", + JSON.stringify({ savedAt: Date.now(), data: { planIds: [2] } }) + ); + + expect(readCachedMyScheduledPlans("plan-1,plan-2")).toBeUndefined(); + }); + + it("clears scheduled-plan snapshots without touching unrelated storage", () => { + writeCachedMyScheduledPlans("plan-1,plan-2", { planIds: ["plan-2"] }); + writeCachedMyScheduledPlans("plan-3,plan-4", { planIds: ["plan-3"] }); + window.localStorage.setItem("unrelated", "keep"); + + clearCachedMyScheduledPlans(); + + expect(readCachedMyScheduledPlans("plan-1,plan-2")).toBeUndefined(); + expect(readCachedMyScheduledPlans("plan-3,plan-4")).toBeUndefined(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); +}); diff --git a/lib/my-scheduled-plans-cache.ts b/lib/my-scheduled-plans-cache.ts new file mode 100644 index 0000000..8af3359 --- /dev/null +++ b/lib/my-scheduled-plans-cache.ts @@ -0,0 +1,82 @@ +export interface MyScheduledPlansData { + planIds: string[]; +} + +const CACHE_VERSION = "v1"; +const CACHE_KEY_PREFIX = `worshipadmin:my-scheduled-plans:${CACHE_VERSION}:`; + +interface CachedPayload { + savedAt: number; + data: MyScheduledPlansData; +} + +export interface MyScheduledPlansCacheEntry { + savedAt: number; + data: MyScheduledPlansData; +} + +export function readCachedMyScheduledPlans( + planIdsKey: string +): MyScheduledPlansCacheEntry | undefined { + if (!planIdsKey || typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(buildCacheKey(planIdsKey)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isMyScheduledPlansData(parsed.data)) return undefined; + + return { + savedAt: parsed.savedAt, + data: parsed.data, + }; + } catch { + return undefined; + } +} + +export function writeCachedMyScheduledPlans( + planIdsKey: string, + data: MyScheduledPlansData +) { + if (!planIdsKey || typeof window === "undefined") return; + + try { + window.localStorage.setItem( + buildCacheKey(planIdsKey), + JSON.stringify({ + savedAt: Date.now(), + data, + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function clearCachedMyScheduledPlans() { + if (typeof window === "undefined") return; + + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(CACHE_KEY_PREFIX)) { + window.localStorage.removeItem(key); + } + } + } catch { + // Ignore storage failures; live queries will still fetch Planning Center. + } +} + +function buildCacheKey(planIdsKey: string) { + return `${CACHE_KEY_PREFIX}${encodeURIComponent(planIdsKey)}`; +} + +function isMyScheduledPlansData(value: unknown): value is MyScheduledPlansData { + if (!value || typeof value !== "object") return false; + const data = value as Partial; + return Array.isArray(data.planIds) && data.planIds.every((id) => typeof id === "string"); +} diff --git a/lib/organization-time-zone-cache.test.ts b/lib/organization-time-zone-cache.test.ts new file mode 100644 index 0000000..7504c47 --- /dev/null +++ b/lib/organization-time-zone-cache.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedOrganizationTimeZone, + readCachedOrganizationTimeZone, + writeCachedOrganizationTimeZone, +} from "@/lib/organization-time-zone-cache"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +describe("organization time zone cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("round-trips a valid IANA time zone with the saved timestamp", () => { + const savedAt = new Date("2026-05-23T20:00:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedOrganizationTimeZone("America/Los_Angeles"); + + expect(readCachedOrganizationTimeZone()).toEqual({ + savedAt, + timeZone: "America/Los_Angeles", + }); + }); + + it("does not cache invalid time zones", () => { + writeCachedOrganizationTimeZone("not-a-zone"); + + expect(readCachedOrganizationTimeZone()).toBeUndefined(); + }); + + it("ignores invalid cache payloads", () => { + window.localStorage.setItem( + "worshipadmin:organization-time-zone:v1", + JSON.stringify({ savedAt: Date.now(), timeZone: "not-a-zone" }) + ); + + expect(readCachedOrganizationTimeZone()).toBeUndefined(); + }); + + it("clears the cached time zone", () => { + writeCachedOrganizationTimeZone("America/Los_Angeles"); + + clearCachedOrganizationTimeZone(); + + expect(readCachedOrganizationTimeZone()).toBeUndefined(); + }); +}); diff --git a/lib/organization-time-zone-cache.ts b/lib/organization-time-zone-cache.ts new file mode 100644 index 0000000..e0a0e14 --- /dev/null +++ b/lib/organization-time-zone-cache.ts @@ -0,0 +1,72 @@ +const CACHE_VERSION = "v1"; +const CACHE_KEY = `worshipadmin:organization-time-zone:${CACHE_VERSION}`; + +interface CachedPayload { + savedAt: number; + timeZone: string; +} + +export interface OrganizationTimeZoneCacheEntry { + savedAt: number; + timeZone: string; +} + +export function readCachedOrganizationTimeZone(): OrganizationTimeZoneCacheEntry | undefined { + if (typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(CACHE_KEY); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isUsableTimeZone(parsed.timeZone)) return undefined; + + return { + savedAt: parsed.savedAt, + timeZone: parsed.timeZone, + }; + } catch { + return undefined; + } +} + +export function writeCachedOrganizationTimeZone(timeZone: string) { + if (typeof window === "undefined") return; + if (!isUsableTimeZone(timeZone)) return; + + try { + window.localStorage.setItem( + CACHE_KEY, + JSON.stringify({ + savedAt: Date.now(), + timeZone, + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function clearCachedOrganizationTimeZone() { + if (typeof window === "undefined") return; + + try { + window.localStorage.removeItem(CACHE_KEY); + } catch { + // Ignore storage failures; live queries still fetch Planning Center. + } +} + +function isUsableTimeZone(value: unknown): value is string { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + if (!trimmed) return false; + + try { + new Intl.DateTimeFormat("en-US", { timeZone: trimmed }); + return true; + } catch { + return false; + } +} diff --git a/lib/people-cache.test.ts b/lib/people-cache.test.ts new file mode 100644 index 0000000..ae87a5b --- /dev/null +++ b/lib/people-cache.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedPeople, + readCachedPeople, + writeCachedPeople, +} from "@/lib/people-cache"; +import type { PersonWithAvailability } from "@/lib/types"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +function people(): PersonWithAvailability[] { + return [ + { + id: "person-1", + firstName: "Andrew", + lastName: "Hinea", + fullName: "Andrew Hinea", + photoUrl: null, + photoThumbnailUrl: "https://example.com/andrew.jpg", + archived: false, + positions: [ + { + id: "position-1", + name: "Vocal", + teamId: "team-1", + teamName: "Vocals", + }, + ], + availability: "available", + frequency: { + recentServedDays: 1, + last60Days: 2, + last90Days: 3, + totalServed: 7, + recentRehearsalOnlyDays: 1, + rehearsalLast60Days: 2, + rehearsalLast90Days: 3, + totalRehearsals: 4, + upcomingServices: 1, + upcomingRehearsals: 1, + lastServedDate: new Date("2026-05-17T16:00:00.000Z"), + nextUpcomingDate: new Date("2026-05-31T16:00:00.000Z"), + lastRehearsalDate: new Date("2026-05-16T16:00:00.000Z"), + nextRehearsalDate: new Date("2026-05-30T16:00:00.000Z"), + }, + serviceHistory: [ + { + id: "history-1", + sourceScheduleId: "schedule-1", + date: new Date("2026-05-17T16:00:00.000Z"), + teamPositionName: "Vocal", + teamName: "Vocals", + serviceTypeName: "Agape Worship Services", + planTitle: "May 17", + status: "C", + timeType: "service", + }, + ], + isBlockedForDate: false, + isScheduledForSelectedPlanPosition: false, + isConfirmedForSelectedPlanPosition: false, + isDeclinedForSelectedPlanPosition: false, + selectedPlanDeclineReason: null, + selectedPlanAssignmentLabels: ["Acoustic Guitar"], + scheduledPlanPersonId: "plan-person-1", + recommendationScore: 91, + recommendationReasoning: ["Light recent schedule"], + }, + ]; +} + +describe("people cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("round-trips people snapshots with the saved timestamp", () => { + const savedAt = new Date("2026-05-23T15:00:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedPeople("st-1", "team-1", "position-1", "plan-1", "2026-05-31", people()); + + const cached = readCachedPeople("st-1", "team-1", "position-1", "plan-1", "2026-05-31"); + + expect(cached?.savedAt).toBe(savedAt); + expect(cached?.data[0].fullName).toBe("Andrew Hinea"); + expect(cached?.data[0].serviceHistory?.[0]?.date).toBe("2026-05-17T16:00:00.000Z"); + expect(cached?.data[0].frequency?.nextUpcomingDate).toBe("2026-05-31T16:00:00.000Z"); + }); + + it("does not read a different slot or date snapshot", () => { + writeCachedPeople("st-1", "team-1", "position-1", "plan-1", "2026-05-31", people()); + + expect(readCachedPeople("st-1", "team-2", "position-1", "plan-1", "2026-05-31")).toBeUndefined(); + expect(readCachedPeople("st-1", "team-1", "position-2", "plan-1", "2026-05-31")).toBeUndefined(); + expect(readCachedPeople("st-1", "team-1", "position-1", "plan-1", "2026-06-07")).toBeUndefined(); + }); + + it("ignores invalid cache payloads", () => { + window.localStorage.setItem( + "worshipadmin:people:v1:st-1:team-1:position-1:plan-1:2026-05-31", + JSON.stringify({ savedAt: Date.now(), data: [{ id: "person-1" }] }) + ); + + expect(readCachedPeople("st-1", "team-1", "position-1", "plan-1", "2026-05-31")).toBeUndefined(); + }); + + it("clears people snapshots without touching unrelated storage", () => { + writeCachedPeople("st-1", "team-1", "position-1", "plan-1", "2026-05-31", people()); + writeCachedPeople("st-1", "team-1", "position-2", "plan-1", "2026-05-31", people()); + window.localStorage.setItem("unrelated", "keep"); + + clearCachedPeople(); + + expect(readCachedPeople("st-1", "team-1", "position-1", "plan-1", "2026-05-31")).toBeUndefined(); + expect(readCachedPeople("st-1", "team-1", "position-2", "plan-1", "2026-05-31")).toBeUndefined(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); +}); diff --git a/lib/people-cache.ts b/lib/people-cache.ts new file mode 100644 index 0000000..28406ac --- /dev/null +++ b/lib/people-cache.ts @@ -0,0 +1,233 @@ +import type { + PersonWithAvailability, + ScheduleFrequency, + ServiceHistoryItem, + TeamPosition, +} from "@/lib/types"; + +const CACHE_VERSION = "v1"; +const CACHE_KEY_PREFIX = `worshipadmin:people:${CACHE_VERSION}:`; + +interface CachedPayload { + savedAt: number; + data: PersonWithAvailability[]; +} + +export interface PeopleCacheEntry { + savedAt: number; + data: PersonWithAvailability[]; +} + +export function readCachedPeople( + serviceTypeId: string | null, + teamId: string | null, + positionId: string | null, + planId: string | null, + dateKey: string | null +): PeopleCacheEntry | undefined { + if (!serviceTypeId || !positionId || typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem( + buildCacheKey(serviceTypeId, teamId, positionId, planId, dateKey) + ); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isPeopleArray(parsed.data)) return undefined; + + return { + savedAt: parsed.savedAt, + data: parsed.data, + }; + } catch { + return undefined; + } +} + +export function writeCachedPeople( + serviceTypeId: string | null, + teamId: string | null, + positionId: string | null, + planId: string | null, + dateKey: string | null, + people: PersonWithAvailability[] +) { + if (!serviceTypeId || !positionId || typeof window === "undefined") return; + + try { + window.localStorage.setItem( + buildCacheKey(serviceTypeId, teamId, positionId, planId, dateKey), + JSON.stringify({ + savedAt: Date.now(), + data: people, + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function clearCachedPeople() { + if (typeof window === "undefined") return; + + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(CACHE_KEY_PREFIX)) { + window.localStorage.removeItem(key); + } + } + } catch { + // Ignore storage failures; live queries will still fetch from Planning Center. + } +} + +function buildCacheKey( + serviceTypeId: string, + teamId: string | null, + positionId: string, + planId: string | null, + dateKey: string | null +) { + return [ + CACHE_KEY_PREFIX, + encodeURIComponent(serviceTypeId), + ":", + encodeURIComponent(teamId ?? "none"), + ":", + encodeURIComponent(positionId), + ":", + encodeURIComponent(planId ?? "none"), + ":", + encodeURIComponent(dateKey ?? "none"), + ].join(""); +} + +function isPeopleArray(value: unknown): value is PersonWithAvailability[] { + return Array.isArray(value) && value.every(isPersonWithAvailability); +} + +function isPersonWithAvailability(value: unknown): value is PersonWithAvailability { + if (!value || typeof value !== "object") return false; + const person = value as Partial; + + return typeof person.id === "string" && + typeof person.firstName === "string" && + typeof person.lastName === "string" && + typeof person.fullName === "string" && + isOptionalString(person.photoUrl) && + isOptionalString(person.photoThumbnailUrl) && + typeof person.archived === "boolean" && + Array.isArray(person.positions) && + person.positions.every(isTeamPosition) && + isAvailability(person.availability) && + (person.frequency === undefined || isScheduleFrequency(person.frequency)) && + ( + person.serviceHistory === undefined || + (Array.isArray(person.serviceHistory) && person.serviceHistory.every(isServiceHistoryItem)) + ) && + isOptionalBoolean(person.isBlockedForDate) && + isOptionalBoolean(person.isScheduledForSelectedPlanPosition) && + isOptionalBoolean(person.isConfirmedForSelectedPlanPosition) && + isOptionalBoolean(person.isDeclinedForSelectedPlanPosition) && + isOptionalString(person.selectedPlanDeclineReason) && + ( + person.selectedPlanAssignmentLabels === undefined || + ( + Array.isArray(person.selectedPlanAssignmentLabels) && + person.selectedPlanAssignmentLabels.every((label) => typeof label === "string") + ) + ) && + isOptionalString(person.scheduledPlanPersonId) && + isOptionalNumber(person.recommendationScore) && + ( + person.recommendationReasoning === undefined || + ( + Array.isArray(person.recommendationReasoning) && + person.recommendationReasoning.every((reason) => typeof reason === "string") + ) + ); +} + +function isTeamPosition(value: unknown): value is TeamPosition { + if (!value || typeof value !== "object") return false; + const position = value as Partial; + return typeof position.id === "string" && + typeof position.name === "string" && + typeof position.teamId === "string" && + isOptionalString(position.teamName) && + isOptionalNumber(position.neededCount) && + isOptionalNumber(position.filledPendingCount) && + isOptionalNumber(position.filledConfirmedCount); +} + +function isAvailability(value: unknown) { + return value === undefined || value === "available" || value === "blocked" || value === "unknown"; +} + +function isScheduleFrequency(value: unknown): value is ScheduleFrequency { + if (!value || typeof value !== "object") return false; + const frequency = value as Partial; + + return isRequiredNumber(frequency.recentServedDays) && + isRequiredNumber(frequency.last60Days) && + isRequiredNumber(frequency.last90Days) && + isRequiredNumber(frequency.totalServed) && + isRequiredNumber(frequency.recentRehearsalOnlyDays) && + isRequiredNumber(frequency.rehearsalLast60Days) && + isRequiredNumber(frequency.rehearsalLast90Days) && + isRequiredNumber(frequency.totalRehearsals) && + isRequiredNumber(frequency.upcomingServices) && + isRequiredNumber(frequency.upcomingRehearsals) && + isOptionalDateLike(frequency.lastServedDate) && + isOptionalDateLike(frequency.lastRehearsalDate) && + isOptionalDateLike(frequency.nextUpcomingDate) && + isOptionalDateLike(frequency.nextRehearsalDate); +} + +function isServiceHistoryItem(value: unknown): value is ServiceHistoryItem { + if (!value || typeof value !== "object") return false; + const item = value as Partial; + + return typeof item.id === "string" && + typeof item.sourceScheduleId === "string" && + isDateLike(item.date) && + typeof item.teamPositionName === "string" && + typeof item.status === "string" && + isOptionalString(item.teamName) && + isOptionalString(item.serviceTypeName) && + isOptionalString(item.planTitle) && + ( + item.timeType === undefined || + item.timeType === "service" || + item.timeType === "rehearsal" || + item.timeType === "other" + ); +} + +function isOptionalString(value: unknown) { + return value === undefined || value === null || typeof value === "string"; +} + +function isOptionalNumber(value: unknown) { + return value === undefined || typeof value === "number"; +} + +function isRequiredNumber(value: unknown) { + return typeof value === "number"; +} + +function isOptionalBoolean(value: unknown) { + return value === undefined || typeof value === "boolean"; +} + +function isOptionalDateLike(value: unknown) { + return value === undefined || isDateLike(value); +} + +function isDateLike(value: unknown) { + if (typeof value !== "string" && !(value instanceof Date)) return false; + return !Number.isNaN(new Date(value).getTime()); +} diff --git a/lib/people-dashboard-cache.test.ts b/lib/people-dashboard-cache.test.ts new file mode 100644 index 0000000..2f3ce67 --- /dev/null +++ b/lib/people-dashboard-cache.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedPeopleDashboards, + readCachedPeopleDashboard, + readCachedPeopleDashboardPerson, + writeCachedPeopleDashboard, + writeCachedPeopleDashboardPerson, +} from "@/lib/people-dashboard-cache"; +import type { + PeopleDashboardData, + PeopleDashboardPersonDetail, +} from "@/lib/use-cases/planning-center/people-dashboard-types"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +function dashboard(overrides: Partial = {}): PeopleDashboardData { + return { + range: "month", + generatedAt: "2026-05-23T12:00:00.000Z", + month: { + year: 2026, + monthIndex: 4, + label: "May 2026", + daysInMonth: 31, + startsOnWeekday: 5, + }, + people: [ + { + id: "person-1", + name: "Andrew Hinea", + initials: "AH", + photoThumbnailUrl: null, + teams: ["Band"], + roles: "Acoustic Guitar", + status: "Available soon", + load: "normal", + lastServed: "May 17", + nextScheduled: "May 31", + monthCount: 2, + thirtyDayCount: 2, + ninetyDayCount: 5, + upcomingCount: 1, + streak: "2 this month", + highlight: "Available soon", + monthDays: [ + { + day: 31, + kind: "service", + positionName: "Acoustic Guitar", + serviceTypeName: "Agape Worship Services", + status: "U", + planUrl: "/schedule/plan?planId=plan-1", + }, + ], + }, + ], + stats: { + scheduledPeople: 1, + highLoadPeople: 0, + availableSoonPeople: 1, + }, + monthDays: [ + { + day: 31, + serviceCount: 1, + confirmedServiceCount: 0, + potentialServiceCount: 1, + rehearsalCount: 0, + blockoutCount: 0, + }, + ], + matrixDays: [31], + requestBudget: { + teamRequests: 1, + scheduleRequests: 1, + blockoutRequests: 1, + rosterPeopleCount: 1, + hydratedPeopleCount: 1, + sampled: false, + }, + ...overrides, + }; +} + +function personDetail(): PeopleDashboardPersonDetail { + return { + generatedAt: "2026-05-23T12:10:00.000Z", + month: dashboard().month, + previousMonth: "2026-04", + nextMonth: "2026-06", + person: dashboard().people[0], + trend: [ + { + month: "2026-05", + label: "May", + services: 2, + rehearsals: 1, + }, + ], + requestBudget: { + scheduleRequests: 1, + blockoutRequests: 1, + }, + }; +} + +describe("people dashboard cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("round-trips dashboard data with the original saved timestamp", () => { + const savedAt = new Date("2026-05-23T12:05:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedPeopleDashboard(dashboard()); + + expect(readCachedPeopleDashboard("month")).toEqual({ + savedAt, + data: dashboard(), + }); + }); + + it("ignores cache entries for a different range", () => { + writeCachedPeopleDashboard(dashboard({ range: "30" })); + + expect(readCachedPeopleDashboard("month")).toBeUndefined(); + expect(readCachedPeopleDashboard("30")?.data.range).toBe("30"); + }); + + it("clears all people dashboard snapshots without touching unrelated storage", () => { + writeCachedPeopleDashboard(dashboard()); + writeCachedPeopleDashboard(dashboard({ range: "90" })); + writeCachedPeopleDashboardPerson("person-1", "2026-05", personDetail()); + window.localStorage.setItem("unrelated", "keep"); + + clearCachedPeopleDashboards(); + + expect(readCachedPeopleDashboard("month")).toBeUndefined(); + expect(readCachedPeopleDashboard("90")).toBeUndefined(); + expect(readCachedPeopleDashboardPerson("person-1", "2026-05")).toBeUndefined(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); + + it("round-trips person detail snapshots with the saved timestamp", () => { + const savedAt = new Date("2026-05-23T12:15:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedPeopleDashboardPerson("person-1", "2026-05", personDetail()); + + expect(readCachedPeopleDashboardPerson("person-1", "2026-05")).toEqual({ + savedAt, + data: personDetail(), + }); + }); + + it("does not read a different person or month detail snapshot", () => { + writeCachedPeopleDashboardPerson("person-1", "2026-05", personDetail()); + + expect(readCachedPeopleDashboardPerson("person-2", "2026-05")).toBeUndefined(); + expect(readCachedPeopleDashboardPerson("person-1", "2026-06")).toBeUndefined(); + }); + + it("ignores invalid person detail snapshots", () => { + window.localStorage.setItem( + "worshipadmin:people-dashboard:v1:person:person-1:2026-05", + JSON.stringify({ savedAt: Date.now(), data: { person: { id: "person-1" } } }) + ); + + expect(readCachedPeopleDashboardPerson("person-1", "2026-05")).toBeUndefined(); + }); +}); diff --git a/lib/people-dashboard-cache.ts b/lib/people-dashboard-cache.ts new file mode 100644 index 0000000..d20cba4 --- /dev/null +++ b/lib/people-dashboard-cache.ts @@ -0,0 +1,274 @@ +import type { + PeopleDashboardData, + PeopleDashboardDay, + PeopleDashboardPerson, + PeopleDashboardPersonDetail, + PeopleDashboardRange, +} from "@/lib/use-cases/planning-center/people-dashboard-types"; + +const CACHE_VERSION = "v1"; +const KEY_PREFIX = `worshipadmin:people-dashboard:${CACHE_VERSION}:`; +const PERSON_DETAIL_KEY_PREFIX = `${KEY_PREFIX}person:`; + +interface CachedPayload { + savedAt: number; + data: T; +} + +export interface PeopleDashboardCacheEntry { + savedAt: number; + data: PeopleDashboardData; +} + +export interface PeopleDashboardPersonCacheEntry { + savedAt: number; + data: PeopleDashboardPersonDetail; +} + +export function readCachedPeopleDashboard( + range: PeopleDashboardRange +): PeopleDashboardCacheEntry | undefined { + if (typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(buildCacheKey(range)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial>; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isPeopleDashboardData(parsed.data, range)) return undefined; + return { + savedAt: parsed.savedAt, + data: parsed.data, + }; + } catch { + return undefined; + } +} + +export function writeCachedPeopleDashboard(data: PeopleDashboardData) { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem( + buildCacheKey(data.range), + JSON.stringify({ + savedAt: Date.now(), + data, + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function readCachedPeopleDashboardPerson( + personId: string, + month: string | null +): PeopleDashboardPersonCacheEntry | undefined { + if (typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(buildPersonDetailCacheKey(personId, month)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial>; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isPeopleDashboardPersonDetail(parsed.data)) return undefined; + + return { + savedAt: parsed.savedAt, + data: parsed.data, + }; + } catch { + return undefined; + } +} + +export function writeCachedPeopleDashboardPerson( + personId: string, + month: string | null, + data: PeopleDashboardPersonDetail +) { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem( + buildPersonDetailCacheKey(personId, month), + JSON.stringify({ + savedAt: Date.now(), + data, + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function clearCachedPeopleDashboards() { + if (typeof window === "undefined") return; + + try { + const prefix = KEY_PREFIX; + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(prefix)) { + window.localStorage.removeItem(key); + } + } + } catch { + // Ignore storage read/write failures. + } +} + +function buildCacheKey(range: PeopleDashboardRange) { + return `${KEY_PREFIX}${range}`; +} + +function buildPersonDetailCacheKey(personId: string, month: string | null) { + return `${PERSON_DETAIL_KEY_PREFIX}${encodeURIComponent(personId)}:${encodeURIComponent(month ?? "current")}`; +} + +function isPeopleDashboardData( + value: unknown, + range: PeopleDashboardRange +): value is PeopleDashboardData { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return candidate.range === range && + typeof candidate.generatedAt === "string" && + isDashboardMonth(candidate.month) && + Array.isArray(candidate.people) && + candidate.people.every(isDashboardPerson) && + isDashboardStats(candidate.stats) && + Array.isArray(candidate.monthDays) && + candidate.monthDays.every(isDashboardDay) && + Array.isArray(candidate.matrixDays) && + candidate.matrixDays.every((day) => typeof day === "number") && + isRequestBudget(candidate.requestBudget); +} + +function isPeopleDashboardPersonDetail( + value: unknown +): value is PeopleDashboardPersonDetail { + if (!value || typeof value !== "object") return false; + const detail = value as Partial; + + return typeof detail.generatedAt === "string" && + isDashboardMonth(detail.month) && + typeof detail.previousMonth === "string" && + typeof detail.nextMonth === "string" && + isDashboardPerson(detail.person) && + Array.isArray(detail.trend) && + detail.trend.every(isPersonDetailTrend) && + isPersonDetailRequestBudget(detail.requestBudget); +} + +function isDashboardMonth(value: unknown): value is PeopleDashboardData["month"] { + if (!value || typeof value !== "object") return false; + const month = value as Partial; + return typeof month.year === "number" && + typeof month.monthIndex === "number" && + typeof month.label === "string" && + typeof month.daysInMonth === "number" && + typeof month.startsOnWeekday === "number"; +} + +function isDashboardPerson(value: unknown): value is PeopleDashboardPerson { + if (!value || typeof value !== "object") return false; + const person = value as Partial; + return typeof person.id === "string" && + typeof person.name === "string" && + typeof person.initials === "string" && + (person.photoThumbnailUrl === null || typeof person.photoThumbnailUrl === "string") && + Array.isArray(person.teams) && + person.teams.every((team) => typeof team === "string") && + typeof person.roles === "string" && + typeof person.status === "string" && + isLoad(person.load) && + typeof person.lastServed === "string" && + typeof person.nextScheduled === "string" && + typeof person.monthCount === "number" && + typeof person.thirtyDayCount === "number" && + typeof person.ninetyDayCount === "number" && + typeof person.upcomingCount === "number" && + typeof person.streak === "string" && + typeof person.highlight === "string" && + Array.isArray(person.monthDays) && + person.monthDays.every(isPersonMonthDay); +} + +function isPersonMonthDay( + value: unknown +): value is PeopleDashboardPerson["monthDays"][number] { + if (!value || typeof value !== "object") return false; + const day = value as Partial; + return typeof day.day === "number" && + isDayKind(day.kind) && + isOptionalString(day.positionName) && + isOptionalString(day.serviceTypeName) && + isOptionalString(day.status) && + isOptionalString(day.planUrl); +} + +function isPersonDetailTrend( + value: unknown +): value is PeopleDashboardPersonDetail["trend"][number] { + if (!value || typeof value !== "object") return false; + const trend = value as Partial; + return typeof trend.month === "string" && + typeof trend.label === "string" && + typeof trend.services === "number" && + typeof trend.rehearsals === "number"; +} + +function isPersonDetailRequestBudget( + value: unknown +): value is PeopleDashboardPersonDetail["requestBudget"] { + if (!value || typeof value !== "object") return false; + const budget = value as Partial; + return typeof budget.scheduleRequests === "number" && + typeof budget.blockoutRequests === "number"; +} + +function isDashboardStats(value: unknown): value is PeopleDashboardData["stats"] { + if (!value || typeof value !== "object") return false; + const stats = value as Partial; + return typeof stats.scheduledPeople === "number" && + typeof stats.highLoadPeople === "number" && + typeof stats.availableSoonPeople === "number"; +} + +function isDashboardDay(value: unknown): value is PeopleDashboardDay { + if (!value || typeof value !== "object") return false; + const day = value as Partial; + return typeof day.day === "number" && + typeof day.serviceCount === "number" && + typeof day.confirmedServiceCount === "number" && + typeof day.potentialServiceCount === "number" && + typeof day.rehearsalCount === "number" && + typeof day.blockoutCount === "number"; +} + +function isRequestBudget(value: unknown): value is PeopleDashboardData["requestBudget"] { + if (!value || typeof value !== "object") return false; + const budget = value as Partial; + return typeof budget.teamRequests === "number" && + typeof budget.scheduleRequests === "number" && + typeof budget.blockoutRequests === "number" && + typeof budget.rosterPeopleCount === "number" && + typeof budget.hydratedPeopleCount === "number" && + typeof budget.sampled === "boolean"; +} + +function isLoad(value: unknown) { + return value === "low" || value === "normal" || value === "high" || value === "rest"; +} + +function isDayKind(value: unknown) { + return value === "service" || value === "rehearsal" || value === "blockout" || value === "rest"; +} + +function isOptionalString(value: unknown) { + return value === undefined || typeof value === "string"; +} diff --git a/lib/people-dashboard-person-placeholder.test.ts b/lib/people-dashboard-person-placeholder.test.ts new file mode 100644 index 0000000..4f89847 --- /dev/null +++ b/lib/people-dashboard-person-placeholder.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { getCachedPeopleDashboardPersonDetail } from "@/lib/people-dashboard-person-placeholder"; +import type { + PeopleDashboardData, + PeopleDashboardPerson, +} from "@/lib/use-cases/planning-center/people-dashboard-types"; + +function dashboardPerson(id: string): PeopleDashboardPerson { + return { + id, + name: "Alex Adams", + initials: "AA", + photoThumbnailUrl: null, + teams: ["Band"], + roles: "Vocals", + status: "Available soon", + load: "normal", + lastServed: "May 12", + nextScheduled: "May 31", + nextRehearsal: "Not scheduled", + monthCount: 2, + thirtyDayCount: 2, + ninetyDayCount: 4, + upcomingCount: 1, + streak: "2 in 30 days", + highlight: "Healthy cadence.", + monthDays: [], + }; +} + +function dashboard(): PeopleDashboardData { + return { + range: "month", + generatedAt: "2026-05-23T12:00:00.000Z", + month: { + year: 2026, + monthIndex: 4, + label: "May 2026", + daysInMonth: 31, + startsOnWeekday: 5, + }, + people: [dashboardPerson("person-1")], + stats: { + scheduledPeople: 1, + highLoadPeople: 0, + availableSoonPeople: 1, + }, + monthDays: [], + matrixDays: [], + requestBudget: { + teamRequests: 1, + scheduleRequests: 1, + blockoutRequests: 0, + rosterPeopleCount: 1, + hydratedPeopleCount: 1, + sampled: false, + }, + }; +} + +describe("getCachedPeopleDashboardPersonDetail", () => { + it("builds a person detail placeholder from cached dashboard data", () => { + const placeholder = getCachedPeopleDashboardPersonDetail( + [dashboard()], + "person-1", + null + ); + + expect(placeholder?.person.name).toBe("Alex Adams"); + expect(placeholder?.month.label).toBe("May 2026"); + expect(placeholder?.previousMonth).toBe("2026-04"); + expect(placeholder?.nextMonth).toBe("2026-06"); + expect(placeholder?.requestBudget.scheduleRequests).toBe(0); + }); + + it("does not reuse cached dashboard data for a different requested month", () => { + const placeholder = getCachedPeopleDashboardPersonDetail( + [dashboard()], + "person-1", + "2026-06" + ); + + expect(placeholder).toBeUndefined(); + }); +}); diff --git a/lib/people-dashboard-person-placeholder.ts b/lib/people-dashboard-person-placeholder.ts new file mode 100644 index 0000000..9f3d17a --- /dev/null +++ b/lib/people-dashboard-person-placeholder.ts @@ -0,0 +1,51 @@ +import type { + PeopleDashboardData, + PeopleDashboardPersonDetail, +} from "@/lib/use-cases/planning-center/people-dashboard-types"; + +export function getCachedPeopleDashboardPersonDetail( + dashboards: Array, + personId: string, + month: string | null +): PeopleDashboardPersonDetail | undefined { + for (const dashboard of dashboards) { + if (!dashboard) continue; + const dashboardMonth = formatDashboardMonthKey(dashboard.month); + if (month && month !== dashboardMonth) continue; + + const person = dashboard.people.find((candidate) => candidate.id === personId); + if (!person) continue; + + return { + generatedAt: dashboard.generatedAt, + month: dashboard.month, + previousMonth: shiftMonthKey( + dashboard.month.year, + dashboard.month.monthIndex, + -1 + ), + nextMonth: shiftMonthKey( + dashboard.month.year, + dashboard.month.monthIndex, + 1 + ), + person, + trend: [], + requestBudget: { + scheduleRequests: 0, + blockoutRequests: 0, + }, + }; + } + + return undefined; +} + +function formatDashboardMonthKey(month: PeopleDashboardData["month"]) { + return `${month.year}-${String(month.monthIndex + 1).padStart(2, "0")}`; +} + +function shiftMonthKey(year: number, monthIndex: number, delta: number) { + const date = new Date(Date.UTC(year, monthIndex + delta, 1, 12)); + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; +} diff --git a/lib/people-page-nav-cache.test.ts b/lib/people-page-nav-cache.test.ts new file mode 100644 index 0000000..c46a4e3 --- /dev/null +++ b/lib/people-page-nav-cache.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + parsePeoplePageNavState, + serializePeoplePageNavState, +} from "@/lib/people-page-nav-cache"; + +describe("people page nav cache", () => { + it("round-trips enabled state", () => { + expect(parsePeoplePageNavState(serializePeoplePageNavState({ enabled: true }))).toEqual({ + enabled: true, + }); + expect(parsePeoplePageNavState(serializePeoplePageNavState({ enabled: false }))).toEqual({ + enabled: false, + }); + }); + + it("ignores invalid payloads", () => { + expect(parsePeoplePageNavState(null)).toBeNull(); + expect(parsePeoplePageNavState("{}")).toBeNull(); + expect(parsePeoplePageNavState("{bad json")).toBeNull(); + }); +}); diff --git a/lib/people-page-nav-cache.ts b/lib/people-page-nav-cache.ts new file mode 100644 index 0000000..c79c7d3 --- /dev/null +++ b/lib/people-page-nav-cache.ts @@ -0,0 +1,22 @@ +export const PEOPLE_PAGE_NAV_CACHE_KEY = "worshipadmin:people-page-nav"; + +export interface PeoplePageNavState { + enabled: boolean; +} + +export function parsePeoplePageNavState(raw: string | null): PeoplePageNavState | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + if (typeof parsed.enabled !== "boolean") return null; + return { enabled: parsed.enabled }; + } catch { + return null; + } +} + +export function serializePeoplePageNavState(state: PeoplePageNavState): string { + return JSON.stringify(state); +} diff --git a/lib/people-search-cache.test.ts b/lib/people-search-cache.test.ts new file mode 100644 index 0000000..52b73c9 --- /dev/null +++ b/lib/people-search-cache.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedPeopleSearch, + normalizePeopleSearchQuery, + readCachedPeopleSearch, + writeCachedPeopleSearch, +} from "@/lib/people-search-cache"; +import type { PeopleSearchResult } from "@/hooks/use-people-search"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +function people(): PeopleSearchResult[] { + return [ + { + id: "person-1", + firstName: "Andrew", + lastName: "Hinea", + fullName: "Andrew Hinea", + photoThumbnailUrl: "https://example.com/andrew.jpg", + }, + { + id: "person-2", + firstName: "Mina", + lastName: "Lee", + fullName: "Mina Lee", + photoThumbnailUrl: null, + }, + ]; +} + +describe("people search cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("normalizes query casing and whitespace", () => { + expect(normalizePeopleSearchQuery(" Andrew H ")).toBe("andrew h"); + }); + + it("round-trips people search results with the saved timestamp", () => { + const savedAt = new Date("2026-05-23T17:00:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedPeopleSearch("Andrew H", people()); + + expect(readCachedPeopleSearch(" andrew h ")).toEqual({ + savedAt, + data: people(), + }); + }); + + it("does not read a different query snapshot", () => { + writeCachedPeopleSearch("andrew", people()); + + expect(readCachedPeopleSearch("mina")).toBeUndefined(); + }); + + it("does not read or write too-short queries", () => { + writeCachedPeopleSearch("a", people()); + + expect(readCachedPeopleSearch("a")).toBeUndefined(); + }); + + it("ignores invalid cache payloads", () => { + window.localStorage.setItem( + "worshipadmin:people-search:v1:andrew", + JSON.stringify({ savedAt: Date.now(), data: [{ id: "person-1" }] }) + ); + + expect(readCachedPeopleSearch("andrew")).toBeUndefined(); + }); + + it("clears people search snapshots without touching unrelated storage", () => { + writeCachedPeopleSearch("andrew", people()); + writeCachedPeopleSearch("mina", people()); + window.localStorage.setItem("unrelated", "keep"); + + clearCachedPeopleSearch(); + + expect(readCachedPeopleSearch("andrew")).toBeUndefined(); + expect(readCachedPeopleSearch("mina")).toBeUndefined(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); +}); diff --git a/lib/people-search-cache.ts b/lib/people-search-cache.ts new file mode 100644 index 0000000..3af0529 --- /dev/null +++ b/lib/people-search-cache.ts @@ -0,0 +1,90 @@ +import type { PeopleSearchResult } from "@/hooks/use-people-search"; + +const CACHE_VERSION = "v1"; +const CACHE_KEY_PREFIX = `worshipadmin:people-search:${CACHE_VERSION}:`; + +interface CachedPayload { + savedAt: number; + data: PeopleSearchResult[]; +} + +export interface PeopleSearchCacheEntry { + savedAt: number; + data: PeopleSearchResult[]; +} + +export function readCachedPeopleSearch(query: string): PeopleSearchCacheEntry | undefined { + const normalizedQuery = normalizePeopleSearchQuery(query); + if (normalizedQuery.length < 2 || typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(buildCacheKey(normalizedQuery)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isPeopleSearchResultArray(parsed.data)) return undefined; + + return { + savedAt: parsed.savedAt, + data: parsed.data, + }; + } catch { + return undefined; + } +} + +export function writeCachedPeopleSearch(query: string, results: PeopleSearchResult[]) { + const normalizedQuery = normalizePeopleSearchQuery(query); + if (normalizedQuery.length < 2 || typeof window === "undefined") return; + + try { + window.localStorage.setItem( + buildCacheKey(normalizedQuery), + JSON.stringify({ + savedAt: Date.now(), + data: results, + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function clearCachedPeopleSearch() { + if (typeof window === "undefined") return; + + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(CACHE_KEY_PREFIX)) { + window.localStorage.removeItem(key); + } + } + } catch { + // Ignore storage failures; live search will still query Planning Center. + } +} + +export function normalizePeopleSearchQuery(query: string) { + return query.trim().toLowerCase(); +} + +function buildCacheKey(query: string) { + return `${CACHE_KEY_PREFIX}${encodeURIComponent(query)}`; +} + +function isPeopleSearchResultArray(value: unknown): value is PeopleSearchResult[] { + return Array.isArray(value) && value.every(isPeopleSearchResult); +} + +function isPeopleSearchResult(value: unknown): value is PeopleSearchResult { + if (!value || typeof value !== "object") return false; + const result = value as Partial; + + return typeof result.id === "string" && + typeof result.firstName === "string" && + typeof result.lastName === "string" && + typeof result.fullName === "string" && + (result.photoThumbnailUrl === null || typeof result.photoThumbnailUrl === "string"); +} diff --git a/lib/plan-items-cache.test.ts b/lib/plan-items-cache.test.ts new file mode 100644 index 0000000..c3f8d3e --- /dev/null +++ b/lib/plan-items-cache.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedPlanItems, + readCachedPlanItems, + writeCachedPlanItems, +} from "@/lib/plan-items-cache"; +import type { PlanItem } from "@/lib/types"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +function planItems(): PlanItem[] { + return [ + { + id: "item-1", + title: "Opening Song", + itemType: "song", + sequence: 1, + servicePosition: "during", + length: 300, + description: "Full band", + htmlDetails: "

Full band

", + customArrangementSequence: ["Verse 1", "Chorus"], + song: { + id: "song-1", + title: "Opening Song", + author: "Author", + themes: "Praise", + lastScheduledAt: new Date("2026-05-17T16:00:00.000Z"), + }, + arrangement: { + id: "arrangement-1", + name: "Default", + sequence: ["Verse 1", "Chorus"], + length: 300, + archivedAt: null, + }, + key: { + id: "key-1", + name: "G", + startingKey: "G", + endingKey: null, + }, + layout: { + id: "layout-1", + name: "Lyrics & Chords", + }, + }, + { + id: "item-2", + title: "Welcome", + itemType: "header", + sequence: 2, + servicePosition: "during", + length: null, + description: "", + htmlDetails: "", + customArrangementSequence: [], + song: null, + arrangement: null, + key: null, + layout: null, + }, + ]; +} + +describe("plan items cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("round-trips plan items and restores date fields", () => { + const savedAt = new Date("2026-05-23T13:00:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedPlanItems("st-1", "plan-1", planItems()); + + const cached = readCachedPlanItems("st-1", "plan-1"); + + expect(cached?.savedAt).toBe(savedAt); + expect(cached?.data).toHaveLength(2); + expect(cached?.data[0].song?.lastScheduledAt).toBeInstanceOf(Date); + expect(cached?.data[0].song?.lastScheduledAt?.toISOString()).toBe("2026-05-17T16:00:00.000Z"); + }); + + it("does not read a different plan snapshot", () => { + writeCachedPlanItems("st-1", "plan-1", planItems()); + + expect(readCachedPlanItems("st-1", "plan-2")).toBeUndefined(); + expect(readCachedPlanItems("st-2", "plan-1")).toBeUndefined(); + }); + + it("ignores invalid cache payloads", () => { + window.localStorage.setItem( + "worshipadmin:plan-items:v1:st-1:plan-1", + JSON.stringify({ savedAt: Date.now(), data: [{ id: "broken" }] }) + ); + + expect(readCachedPlanItems("st-1", "plan-1")).toBeUndefined(); + }); + + it("clears plan item snapshots without touching unrelated storage", () => { + writeCachedPlanItems("st-1", "plan-1", planItems()); + writeCachedPlanItems("st-1", "plan-2", planItems()); + window.localStorage.setItem("unrelated", "keep"); + + clearCachedPlanItems(); + + expect(readCachedPlanItems("st-1", "plan-1")).toBeUndefined(); + expect(readCachedPlanItems("st-1", "plan-2")).toBeUndefined(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); +}); diff --git a/lib/plan-items-cache.ts b/lib/plan-items-cache.ts new file mode 100644 index 0000000..4568114 --- /dev/null +++ b/lib/plan-items-cache.ts @@ -0,0 +1,164 @@ +import { + hydratePlanItems, + serializePlanItems, + type SerializedPlanItem, +} from "@/lib/plan-item-client"; +import type { PlanItem, PlanItemServicePosition, PlanItemType } from "@/lib/types"; + +const CACHE_VERSION = "v1"; +const CACHE_KEY_PREFIX = `worshipadmin:plan-items:${CACHE_VERSION}:`; + +interface CachedPayload { + savedAt: number; + data: SerializedPlanItem[]; +} + +export interface PlanItemsCacheEntry { + savedAt: number; + data: PlanItem[]; +} + +export function readCachedPlanItems( + serviceTypeId: string | null, + planId: string | null +): PlanItemsCacheEntry | undefined { + if (!serviceTypeId || !planId || typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(buildCacheKey(serviceTypeId, planId)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isSerializedPlanItemArray(parsed.data)) return undefined; + + return { + savedAt: parsed.savedAt, + data: hydratePlanItems(parsed.data), + }; + } catch { + return undefined; + } +} + +export function writeCachedPlanItems( + serviceTypeId: string | null, + planId: string | null, + items: PlanItem[] +) { + if (!serviceTypeId || !planId || typeof window === "undefined") return; + + try { + window.localStorage.setItem( + buildCacheKey(serviceTypeId, planId), + JSON.stringify({ + savedAt: Date.now(), + data: serializePlanItems(items), + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function clearCachedPlanItems() { + if (typeof window === "undefined") return; + + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(CACHE_KEY_PREFIX)) { + window.localStorage.removeItem(key); + } + } + } catch { + // Ignore storage failures; live queries will still fetch from Planning Center. + } +} + +function buildCacheKey(serviceTypeId: string, planId: string) { + return `${CACHE_KEY_PREFIX}${encodeURIComponent(serviceTypeId)}:${encodeURIComponent(planId)}`; +} + +function isSerializedPlanItemArray(value: unknown): value is SerializedPlanItem[] { + return Array.isArray(value) && value.every(isSerializedPlanItem); +} + +function isSerializedPlanItem(value: unknown): value is SerializedPlanItem { + if (!value || typeof value !== "object") return false; + const item = value as Partial; + + return typeof item.id === "string" && + typeof item.title === "string" && + isPlanItemType(item.itemType) && + typeof item.sequence === "number" && + isPlanItemServicePosition(item.servicePosition) && + (item.length === null || typeof item.length === "number") && + typeof item.description === "string" && + typeof item.htmlDetails === "string" && + Array.isArray(item.customArrangementSequence) && + item.customArrangementSequence.every((entry) => typeof entry === "string") && + isSerializedSong(item.song) && + isSerializedArrangement(item.arrangement) && + isSerializedKey(item.key) && + isSerializedLayout(item.layout); +} + +function isPlanItemType(value: unknown): value is PlanItemType { + return value === "song" || value === "header" || value === "item" || value === "media"; +} + +function isPlanItemServicePosition(value: unknown): value is PlanItemServicePosition { + return value === "pre" || value === "during" || value === "post"; +} + +function isSerializedSong(value: unknown) { + if (value === null) return true; + if (!value || typeof value !== "object") return false; + const song = value as SerializedPlanItem["song"]; + return !!song && + typeof song.id === "string" && + typeof song.title === "string" && + typeof song.author === "string" && + typeof song.themes === "string" && + isNullableDateLike(song.lastScheduledAt); +} + +function isSerializedArrangement(value: unknown) { + if (value === null) return true; + if (!value || typeof value !== "object") return false; + const arrangement = value as SerializedPlanItem["arrangement"]; + return !!arrangement && + typeof arrangement.id === "string" && + typeof arrangement.name === "string" && + Array.isArray(arrangement.sequence) && + arrangement.sequence.every((entry) => typeof entry === "string") && + (arrangement.length === null || typeof arrangement.length === "number") && + isNullableDateLike(arrangement.archivedAt); +} + +function isSerializedKey(value: unknown) { + if (value === null) return true; + if (!value || typeof value !== "object") return false; + const key = value as SerializedPlanItem["key"]; + return !!key && + typeof key.id === "string" && + typeof key.name === "string" && + (key.startingKey === null || typeof key.startingKey === "string") && + (key.endingKey === null || typeof key.endingKey === "string"); +} + +function isSerializedLayout(value: unknown) { + if (value === null) return true; + if (!value || typeof value !== "object") return false; + const layout = value as SerializedPlanItem["layout"]; + return !!layout && + typeof layout.id === "string" && + typeof layout.name === "string"; +} + +function isNullableDateLike(value: unknown) { + if (value === null) return true; + if (typeof value !== "string" && !(value instanceof Date)) return false; + return !Number.isNaN(new Date(value).getTime()); +} diff --git a/lib/plan-items-query-state.test.ts b/lib/plan-items-query-state.test.ts index 707f28b..96c617f 100644 --- a/lib/plan-items-query-state.test.ts +++ b/lib/plan-items-query-state.test.ts @@ -1,12 +1,25 @@ import { QueryClient } from "@tanstack/react-query"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { + applyPlanItemDraft, applyPlanItemsOptimisticUpdate, + collectPlanSongOptionPrefetchIds, + createOptimisticBasicPlanItem, + createOptimisticSongPlanItem, + nextPlanItemSequence, + PLAN_ITEMS_MUTATION_RECONCILE_DELAY_MS, + planItemDraftChangesItem, + planItemsHaveSameOrder, removePlanItem, + replacePlanItemById, reorderPlanItems, restorePlanItemsSnapshot, + settlePlanItemsQuery, } from "@/lib/plan-items-query-state"; -import type { PlanItem } from "@/lib/types"; +import { readCachedPlanItems, writeCachedPlanItems } from "@/lib/plan-items-cache"; +import { readCachedSongOptions, writeCachedSongOptions } from "@/lib/song-options-cache"; +import { readCachedSongSearch, writeCachedSongSearch } from "@/lib/song-search-cache"; +import type { PlanItem, SongOptionSet } from "@/lib/types"; function createItem(id: string, sequence: number): PlanItem { return { @@ -26,7 +39,66 @@ function createItem(id: string, sequence: number): PlanItem { }; } +function createSongItem(id: string, sequence: number, songId: string): PlanItem { + return { + ...createItem(id, sequence), + itemType: "song", + song: { + id: songId, + title: `Song ${songId}`, + author: "", + themes: "", + lastScheduledAt: null, + }, + }; +} + +function songOptions(): SongOptionSet { + return { + song: { + id: "song-1", + title: "Build My Life", + author: "Pat Barrett", + themes: "Worship", + hidden: false, + lastScheduledAt: new Date("2026-05-17T16:00:00.000Z"), + }, + arrangements: [], + layouts: [], + currentLayout: null, + suggestedArrangementId: null, + suggestedKeyId: null, + suggestedLayoutId: null, + layoutMode: "unavailable", + }; +} + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + describe("plan item query state", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + it("restores the previous cache snapshot after an optimistic reorder rollback", () => { const queryClient = new QueryClient(); const queryKey = ["plan-items", "service-1", "plan-1"] as const; @@ -59,4 +131,221 @@ describe("plan item query state", () => { { id: "item-3", sequence: 2 }, ]); }); + + it("detects when a proposed reorder keeps the same item order", () => { + const current = [createItem("item-1", 1), createItem("item-2", 2)]; + + expect( + planItemsHaveSameOrder(current, [ + { ...createItem("item-1", 1), sequence: 10 }, + { ...createItem("item-2", 2), sequence: 11 }, + ]) + ).toBe(true); + + expect(planItemsHaveSameOrder(current, [createItem("item-2", 1), createItem("item-1", 2)])).toBe( + false + ); + expect(planItemsHaveSameOrder(current, [createItem("item-1", 1)])).toBe(false); + }); + + it("collects a bounded unique list of song option prefetch ids", () => { + expect( + collectPlanSongOptionPrefetchIds( + [ + createItem("item-1", 1), + createSongItem("song-item-1", 2, "song-1"), + createSongItem("song-item-2", 3, "song-1"), + createSongItem("song-item-3", 4, "song-2"), + createSongItem("song-item-4", 5, "song-3"), + ], + 2 + ) + ).toEqual(["song-1", "song-2"]); + + expect(collectPlanSongOptionPrefetchIds([createSongItem("song-item-1", 1, "song-1")], 0)).toEqual( + [] + ); + }); + + it("builds optimistic basic and song items at the next sequence", () => { + const current = [createItem("item-1", 1), createItem("item-2", 2)]; + const nextSequence = nextPlanItemSequence(current); + + expect(createOptimisticBasicPlanItem("temp-header", "header", nextSequence)).toMatchObject({ + id: "temp-header", + title: "New Header", + itemType: "header", + sequence: 3, + }); + + expect( + createOptimisticSongPlanItem( + "temp-song", + { + id: "song-1", + title: "Grace Alone", + author: "The Modern Post", + themes: "Grace", + hidden: false, + lastScheduledAt: null, + }, + nextSequence + ) + ).toMatchObject({ + id: "temp-song", + title: "Grace Alone", + itemType: "song", + sequence: 3, + song: { + id: "song-1", + title: "Grace Alone", + }, + }); + }); + + it("replaces temporary items with the server item", () => { + const current = [ + createItem("item-1", 1), + createOptimisticBasicPlanItem("temp-item", "item", 2), + ]; + const serverItem = createItem("server-item", 2); + + expect(replacePlanItemById(current, "temp-item", serverItem).map((item) => item.id)).toEqual([ + "item-1", + "server-item", + ]); + }); + + it("applies draft fields for immediate edit feedback", () => { + expect( + applyPlanItemDraft( + createItem("item-1", 1), + { + title: "Welcome", + servicePosition: "pre", + description: "Before service", + }, + 0, + null, + null + ) + ).toMatchObject({ + title: "Welcome", + servicePosition: "pre", + length: null, + description: "Before service", + }); + }); + + it("detects unchanged plan item drafts so saves can skip no-op PATCH requests", () => { + const item = { + ...createItem("song-item-1", 1), + itemType: "song" as const, + title: "Build My Life", + song: { + id: "song-1", + title: "Build My Life", + author: "Pat Barrett", + themes: "", + lastScheduledAt: null, + }, + arrangement: { + id: "arr-1", + name: "Default", + sequence: [], + length: null, + archivedAt: null, + }, + key: { + id: "key-1", + name: "A", + startingKey: null, + endingKey: null, + }, + servicePosition: "during" as const, + length: 300, + description: "Intro", + }; + + expect( + planItemDraftChangesItem( + item, + { + title: "Ignored for songs", + servicePosition: "during", + description: "Intro", + arrangementId: "arr-1", + keyId: "key-1", + }, + 300 + ) + ).toBe(false); + + expect( + planItemDraftChangesItem( + item, + { + title: "Ignored for songs", + servicePosition: "during", + description: "Intro", + arrangementId: "arr-1", + keyId: "key-2", + }, + 300 + ) + ).toBe(true); + }); + + it("settles optimistic plan item mutations without immediately refetching the active list", () => { + vi.useFakeTimers(); + const queryClient = new QueryClient(); + const queryKey = ["plan-items", "service-1", "plan-1"] as const; + const invalidateQueries = vi + .spyOn(queryClient, "invalidateQueries") + .mockResolvedValue(undefined); + const refetchQueries = vi + .spyOn(queryClient, "refetchQueries") + .mockResolvedValue(undefined); + + settlePlanItemsQuery(queryClient, queryKey); + settlePlanItemsQuery(queryClient, queryKey); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey, + refetchType: "inactive", + }); + expect(refetchQueries).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(PLAN_ITEMS_MUTATION_RECONCILE_DELAY_MS); + + expect(refetchQueries).toHaveBeenCalledWith({ + queryKey, + type: "active", + }); + expect(refetchQueries).toHaveBeenCalledTimes(1); + }); + + it("clears persisted plan-item and song metadata snapshots when optimistic mutations settle", () => { + installLocalStorageMock(); + const queryClient = new QueryClient(); + const queryKey = ["plan-items", "service-1", "plan-1"] as const; + writeCachedPlanItems("service-1", "plan-1", [createItem("item-1", 1)]); + writeCachedSongSearch("service-1", "build", [ + { + id: "song-1", + title: "Build My Life", + author: "Pat Barrett", + themes: "Worship", + hidden: false, + lastScheduledAt: new Date("2026-05-17T16:00:00.000Z"), + }, + ]); + writeCachedSongOptions("song-1", "service-1", songOptions()); + + settlePlanItemsQuery(queryClient, queryKey); + + expect(readCachedPlanItems("service-1", "plan-1")).toBeUndefined(); + expect(readCachedSongSearch("service-1", "build")).toBeUndefined(); + expect(readCachedSongOptions("song-1", "service-1")).toBeUndefined(); + }); }); diff --git a/lib/plan-items-query-state.ts b/lib/plan-items-query-state.ts index e626c22..9fbafe5 100644 --- a/lib/plan-items-query-state.ts +++ b/lib/plan-items-query-state.ts @@ -1,8 +1,25 @@ import type { QueryClient } from "@tanstack/react-query"; -import type { PlanItem } from "@/lib/types"; +import { clearCachedPlanItems } from "@/lib/plan-items-cache"; +import { clearCachedSongOptions } from "@/lib/song-options-cache"; +import { clearCachedSongSearch } from "@/lib/song-search-cache"; +import type { + PlanItem, + PlanItemArrangement, + PlanItemKey, + PlanItemServicePosition, + SongCatalogEntry, +} from "@/lib/types"; export type PlanItemsQueryKey = readonly unknown[]; +export const PLAN_ITEMS_MUTATION_RECONCILE_DELAY_MS = 2500; +export const PLAN_SONG_OPTIONS_PREFETCH_LIMIT = 6; + +const activeRefetchTimers = new WeakMap< + QueryClient, + Map> +>(); + export interface PlanItemsOptimisticSnapshot { previousItems: PlanItem[]; nextItems: PlanItem[]; @@ -12,10 +29,121 @@ export function appendPlanItem(items: PlanItem[], item: PlanItem): PlanItem[] { return [...items, item].toSorted((a, b) => a.sequence - b.sequence); } +export function nextPlanItemSequence(items: PlanItem[]): number { + return items.reduce((max, item) => Math.max(max, item.sequence), 0) + 1; +} + +export function createOptimisticBasicPlanItem( + id: string, + kind: "header" | "item", + sequence: number +): PlanItem { + return { + id, + title: kind === "header" ? "New Header" : "New Item", + itemType: kind, + sequence, + servicePosition: "during", + length: null, + description: "", + htmlDetails: "", + customArrangementSequence: [], + song: null, + arrangement: null, + key: null, + layout: null, + }; +} + +export function createOptimisticSongPlanItem( + id: string, + song: SongCatalogEntry, + sequence: number +): PlanItem { + return { + id, + title: song.title, + itemType: "song", + sequence, + servicePosition: "during", + length: null, + description: "", + htmlDetails: "", + customArrangementSequence: [], + song: { + id: song.id, + title: song.title, + author: song.author, + themes: song.themes, + lastScheduledAt: song.lastScheduledAt, + }, + arrangement: null, + key: null, + layout: null, + }; +} + export function replacePlanItem(items: PlanItem[], updatedItem: PlanItem): PlanItem[] { return items.map((item) => (item.id === updatedItem.id ? updatedItem : item)); } +export function replacePlanItemById( + items: PlanItem[], + itemId: string, + updatedItem: PlanItem +): PlanItem[] { + return items.map((item) => (item.id === itemId ? updatedItem : item)); +} + +export function applyPlanItemDraft( + item: PlanItem, + draft: { + title: string; + servicePosition: string; + description: string; + arrangementId?: string; + keyId?: string; + }, + length: number | null, + arrangement: PlanItemArrangement | null, + key: PlanItemKey | null +): PlanItem { + return { + ...item, + title: item.song ? item.title : draft.title, + servicePosition: draft.servicePosition as PlanItemServicePosition, + length: length && length > 0 ? length : null, + description: draft.description, + arrangement, + key, + }; +} + +export function planItemDraftChangesItem( + item: PlanItem, + draft: { + title: string; + servicePosition: string; + description: string; + arrangementId?: string; + keyId?: string; + }, + length: number | null +): boolean { + const normalizedLength = length && length > 0 ? length : null; + const normalizedArrangementId = draft.arrangementId || null; + const normalizedKeyId = draft.keyId || null; + + if (!item.song && draft.title !== item.title) return true; + if (draft.servicePosition !== item.servicePosition) return true; + if (normalizedLength !== item.length) return true; + if (draft.description !== item.description) return true; + if (item.song && normalizedArrangementId !== (item.arrangement?.id ?? null)) return true; + if (item.song && normalizedKeyId !== (item.key?.id ?? null)) return true; + + return false; +} + export function removePlanItem(items: PlanItem[], itemId: string): PlanItem[] { return items .filter((item) => item.id !== itemId) @@ -48,6 +176,31 @@ export function reorderPlanItems( return movePlanItem(items, fromIndex, toIndex); } +export function planItemsHaveSameOrder( + currentItems: PlanItem[], + nextItems: PlanItem[] +): boolean { + if (currentItems.length !== nextItems.length) return false; + + return currentItems.every((item, index) => item.id === nextItems[index]?.id); +} + +export function collectPlanSongOptionPrefetchIds( + items: PlanItem[], + limit = PLAN_SONG_OPTIONS_PREFETCH_LIMIT +): string[] { + if (limit <= 0) return []; + + const songIds = new Set(); + for (const item of items) { + if (!item.song?.id) continue; + songIds.add(item.song.id); + if (songIds.size >= limit) break; + } + + return Array.from(songIds); +} + export function applyPlanItemsOptimisticUpdate( queryClient: QueryClient, queryKey: PlanItemsQueryKey, @@ -71,3 +224,31 @@ export function restorePlanItemsSnapshot( if (!snapshot) return; queryClient.setQueryData(queryKey, snapshot.previousItems); } + +export function settlePlanItemsQuery( + queryClient: QueryClient, + queryKey: PlanItemsQueryKey +) { + clearCachedPlanItems(); + clearCachedSongOptions(); + clearCachedSongSearch(); + void queryClient.invalidateQueries({ queryKey, refetchType: "inactive" }); + + let clientTimers = activeRefetchTimers.get(queryClient); + if (!clientTimers) { + clientTimers = new Map(); + activeRefetchTimers.set(queryClient, clientTimers); + } + + const timerKey = JSON.stringify(queryKey); + const currentTimer = clientTimers.get(timerKey); + if (currentTimer) { + clearTimeout(currentTimer); + } + + const nextTimer = setTimeout(() => { + clientTimers.delete(timerKey); + void queryClient.refetchQueries({ queryKey, type: "active" }); + }, PLAN_ITEMS_MUTATION_RECONCILE_DELAY_MS); + clientTimers.set(timerKey, nextTimer); +} diff --git a/lib/planning-center/core-client.test.ts b/lib/planning-center/core-client.test.ts new file mode 100644 index 0000000..98ad1e0 --- /dev/null +++ b/lib/planning-center/core-client.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PlanningCenterCoreClient } from "@/lib/planning-center/core-client"; + +const originalClient = process.env.PLANNING_CENTER_CLIENT; +const originalPat = process.env.PLANNING_CENTER_PAT; +const originalFetch = globalThis.fetch; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +describe("PlanningCenterCoreClient", () => { + beforeEach(() => { + process.env.PLANNING_CENTER_CLIENT = "client"; + process.env.PLANNING_CENTER_PAT = "pat"; + }); + + afterEach(() => { + vi.restoreAllMocks(); + globalThis.fetch = originalFetch; + process.env.PLANNING_CENTER_CLIENT = originalClient; + process.env.PLANNING_CENTER_PAT = originalPat; + }); + + it("dedupes concurrent identical GET JSON fetches", async () => { + let resolveFetch: (response: Response) => void = () => {}; + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + globalThis.fetch = fetchMock as typeof fetch; + + const client = new PlanningCenterCoreClient(); + const first = client.fetch<{ attributes: { name: string } }>("/services/v2/people/1"); + const second = client.fetch<{ attributes: { name: string } }>("/services/v2/people/1"); + + await Promise.resolve(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resolveFetch( + jsonResponse({ + data: { + id: "1", + type: "Person", + attributes: { name: "Alex" }, + }, + }) + ); + + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(firstResult).toEqual(secondResult); + expect(firstResult).not.toBe(secondResult); + + firstResult.data.attributes.name = "Changed locally"; + expect(secondResult.data.attributes.name).toBe("Alex"); + }); + + it("does not dedupe writes", async () => { + const fetchMock = vi.fn(() => Promise.resolve(jsonResponse({ data: { id: "1" } }))); + globalThis.fetch = fetchMock as typeof fetch; + + const client = new PlanningCenterCoreClient(); + await Promise.all([ + client.fetch("/services/v2/people", { method: "POST", body: "{}" }), + client.fetch("/services/v2/people", { method: "POST", body: "{}" }), + ]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/planning-center/core-client.ts b/lib/planning-center/core-client.ts index 4b44737..8910065 100644 --- a/lib/planning-center/core-client.ts +++ b/lib/planning-center/core-client.ts @@ -1,6 +1,7 @@ import type { PCApiResponse, PCResource } from "@/lib/types"; import { createHash } from "node:crypto"; import { mergeHeaders } from "@/lib/http/merge-headers"; +import { elapsedMs, formatDurationMs, nowMs } from "@/lib/http/timing"; import { logger } from "@/lib/logger"; import { getPlanningCenterRequestAccessToken } from "@/lib/planning-center/request-auth-context"; @@ -11,6 +12,7 @@ const MAX_RETRIES = 2; const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); const PROACTIVE_RATE_LIMIT_THRESHOLD = 0.8; const PROACTIVE_RATE_LIMIT_DELAY_MS = 1000; +const inFlightJsonFetches = new Map>(); export interface PlanningCenterRateLimitInfo { limit?: number; @@ -116,6 +118,7 @@ export class PlanningCenterCoreClient { for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + const attemptStartedAtMs = nowMs(); try { const response = await fetch(url, { @@ -130,6 +133,14 @@ export class PlanningCenterCoreClient { ), }); clearTimeout(timeout); + logPlanningCenterTiming({ + url, + method, + attempt, + status: response.status, + durationMs: elapsedMs(attemptStartedAtMs), + rateLimit: readRateLimitInfo(response.headers), + }); if (!response.ok) { const errorText = await response.text(); @@ -163,6 +174,13 @@ export class PlanningCenterCoreClient { } catch (error) { clearTimeout(timeout); lastError = error instanceof Error ? error : new Error(String(error)); + logPlanningCenterTiming({ + url, + method, + attempt, + durationMs: elapsedMs(attemptStartedAtMs), + error: lastError, + }); if (lastError instanceof PlanningCenterApiError) { break; } @@ -189,8 +207,37 @@ export class PlanningCenterCoreClient { endpoint: string, options: RequestInit = {} ): Promise> { - const response = await this.request(endpoint, options); - return response.json(); + const method = (options.method ?? "GET").toUpperCase(); + if (method !== "GET" || options.body) { + const response = await this.request(endpoint, options); + return response.json(); + } + + const key = buildJsonFetchDedupeKey({ + authScope: this.getCacheScope(), + endpoint, + headers: options.headers, + method, + }); + const existingRequest = inFlightJsonFetches.get(key) as + | Promise> + | undefined; + if (existingRequest) { + return cloneJson(await existingRequest); + } + + const request = this.request(endpoint, options).then( + async (response) => response.json() as Promise> + ); + inFlightJsonFetches.set(key, request); + + try { + return cloneJson(await request); + } finally { + if (inFlightJsonFetches.get(key) === request) { + inFlightJsonFetches.delete(key); + } + } } buildUrl(endpoint: string, params: Record = {}): string { @@ -322,6 +369,41 @@ function sleep(ms: number): Promise { }); } +function cloneJson(value: T): T { + return structuredClone(value); +} + +function buildJsonFetchDedupeKey({ + authScope, + endpoint, + headers, + method, +}: { + authScope: string; + endpoint: string; + headers: HeadersInit | undefined; + method: string; +}): string { + const url = endpoint.startsWith("http") + ? endpoint + : `${PC_BASE_URL}${endpoint}`; + + return JSON.stringify({ + authScope, + headers: normalizeHeaders(headers), + method, + url, + }); +} + +function normalizeHeaders(headers: HeadersInit | undefined): [string, string][] { + if (!headers) return []; + + return Array.from(new Headers(headers).entries()).sort(([left], [right]) => + left.localeCompare(right) + ); +} + function isSafeToRetry(method: string): boolean { return method === "GET" || method === "HEAD"; } @@ -376,3 +458,47 @@ function readIntegerHeader(headers: Headers, name: string): number | undefined { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? parsed : undefined; } + +function logPlanningCenterTiming({ + url, + method, + attempt, + status, + durationMs, + rateLimit, + error, +}: { + url: string; + method: string; + attempt: number; + status?: number; + durationMs: number; + rateLimit?: PlanningCenterRateLimitInfo; + error?: Error; +}) { + if (process.env.LOG_PLANNING_CENTER_TIMINGS !== "1") return; + + log.debug( + { + method, + status, + attempt: attempt + 1, + durationMs: formatDurationMs(durationMs), + endpoint: describePlanningCenterEndpoint(url), + rateLimit, + error: error?.message.slice(0, 100), + }, + "Planning Center API timing" + ); +} + +function describePlanningCenterEndpoint(value: string): { + path: string; + queryKeys: string[]; +} { + const url = new URL(value, PC_BASE_URL); + return { + path: url.pathname, + queryKeys: Array.from(url.searchParams.keys()).sort(), + }; +} diff --git a/lib/planning-center/services/catalog-service.test.ts b/lib/planning-center/services/catalog-service.test.ts new file mode 100644 index 0000000..97b3d9f --- /dev/null +++ b/lib/planning-center/services/catalog-service.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PlanningCenterCoreClient } from "@/lib/planning-center/core-client"; +import { PlanningCenterCatalogService } from "@/lib/planning-center/services/catalog-service"; +import type { PCResource } from "@/lib/types"; + +function createCoreClientMock() { + const fetchMock = vi.fn(); + const fetchAllMock = vi.fn(); + const fetchAllWithIncludedMock = vi.fn(); + const core = { + fetch: fetchMock, + fetchAll: fetchAllMock, + fetchAllWithIncluded: fetchAllWithIncludedMock, + getCacheScope: () => "test-scope", + } as unknown as PlanningCenterCoreClient; + return { core, fetchMock, fetchAllMock, fetchAllWithIncludedMock }; +} + +function resource(id: string, type: string = "TeamPosition"): PCResource { + return { + id, + type, + attributes: { name: "Acoustic Guitar" }, + }; +} + +describe("PlanningCenterCatalogService read cache", () => { + it("caches service types and returns mutation-safe copies", async () => { + const { core, fetchAllMock } = createCoreClientMock(); + fetchAllMock.mockResolvedValue([resource("st-1", "ServiceType")]); + const service = new PlanningCenterCatalogService(core); + + const first = await service.getServiceTypesCached(); + first[0].attributes.name = "Mutated"; + const second = await service.getServiceTypesCached(); + + expect(fetchAllMock).toHaveBeenCalledTimes(1); + expect(fetchAllMock).toHaveBeenCalledWith("/services/v2/service_types", {}); + expect(second[0].attributes.name).toBe("Acoustic Guitar"); + expect(second[0]).not.toBe(first[0]); + }); + + it("caches service type team positions and returns mutation-safe copies", async () => { + const { core, fetchMock } = createCoreClientMock(); + fetchMock.mockResolvedValue({ + data: [resource("position-1")], + included: [resource("team-1", "Team")], + }); + const service = new PlanningCenterCatalogService(core); + + const first = await service.getServiceTypeTeamPositionsWithTeams("st-1"); + first.data[0].attributes.name = "Mutated"; + const second = await service.getServiceTypeTeamPositionsWithTeams("st-1"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(second.data[0].attributes.name).toBe("Acoustic Guitar"); + expect(second.data[0]).not.toBe(first.data[0]); + }); + + it("caches plan needed positions through the shared cache", async () => { + const { core, fetchAllWithIncludedMock } = createCoreClientMock(); + fetchAllWithIncludedMock.mockResolvedValue({ + data: [resource("needed-1", "NeededPosition")], + included: [resource("team-1", "Team")], + }); + const service = new PlanningCenterCatalogService(core); + + await service.getServiceTypePlanNeededPositionsWithTeams("st-1", "plan-1"); + await service.getServiceTypePlanNeededPositionsWithTeams("st-1", "plan-1"); + + expect(fetchAllWithIncludedMock).toHaveBeenCalledTimes(1); + expect(fetchAllWithIncludedMock).toHaveBeenCalledWith( + "/services/v2/service_types/st-1/plans/plan-1/needed_positions", + { include: "team" } + ); + }); +}); diff --git a/lib/planning-center/services/catalog-service.ts b/lib/planning-center/services/catalog-service.ts index fb9226d..80111df 100644 --- a/lib/planning-center/services/catalog-service.ts +++ b/lib/planning-center/services/catalog-service.ts @@ -1,12 +1,16 @@ import type { PCResource } from "@/lib/types"; import { logger } from "@/lib/logger"; import { PlanningCenterCoreClient } from "@/lib/planning-center/core-client"; +import { PlanningCenterReadCache } from "@/lib/planning-center/services/read-cache"; const log = logger.for("planning-center/catalog"); +const TEAM_POSITIONS_CACHE_TTL_MS = 5 * 60 * 1000; +const NEEDED_POSITIONS_CACHE_TTL_MS = 60 * 1000; export class PlanningCenterCatalogService { private serviceTypesCache: { expiresAt: number; data: PCResource[] } | null = null; + private readonly cache = new PlanningCenterReadCache(); constructor(private readonly core: PlanningCenterCoreClient) {} @@ -40,7 +44,7 @@ export class PlanningCenterCatalogService { ): Promise { const now = Date.now(); if (this.serviceTypesCache && this.serviceTypesCache.expiresAt > now) { - return this.serviceTypesCache.data; + return structuredClone(this.serviceTypesCache.data); } const data = await this.getServiceTypes(); @@ -48,63 +52,105 @@ export class PlanningCenterCatalogService { expiresAt: now + ttlMs, data, }; - return data; + return structuredClone(data); } async getServiceTypeTeamPositionsWithTeams( serviceTypeId: string ): Promise<{ data: PCResource[]; included: PCResource[] }> { - const response = await this.core.fetch( - `/services/v2/service_types/${serviceTypeId}/team_positions?include=team&per_page=100` - ); - - const data = Array.isArray(response.data) ? response.data : [response.data]; - log.info( - { serviceTypeId, positionCount: data.length }, - "Team positions fetched" + const response = await this.cache.get( + this.buildCacheKey("service-type-team-positions", serviceTypeId), + TEAM_POSITIONS_CACHE_TTL_MS, + async () => { + const result = await this.core.fetch( + `/services/v2/service_types/${serviceTypeId}/team_positions?include=team&per_page=100` + ); + + const data = Array.isArray(result.data) ? result.data : [result.data]; + log.info( + { serviceTypeId, positionCount: data.length }, + "Team positions fetched" + ); + + return { + data, + included: result.included || [], + }; + } ); - return { - data, - included: response.included || [], - }; + return cloneResourceResponse(response); } async getPlanNeededPositionsWithTeams( seriesId: string, planId: string ): Promise<{ data: PCResource[]; included: PCResource[] }> { - const response = await this.core.fetchAllWithIncluded( - `/services/v2/series/${seriesId}/plans/${planId}/needed_positions`, - { include: "team" } - ); - - log.info( - { seriesId, planId, neededPositionCount: response.data.length }, - "Plan needed positions fetched" + const response = await this.cache.get( + this.buildCacheKey("series-plan-needed-positions", seriesId, planId), + NEEDED_POSITIONS_CACHE_TTL_MS, + async () => { + const result = await this.core.fetchAllWithIncluded( + `/services/v2/series/${seriesId}/plans/${planId}/needed_positions`, + { include: "team" } + ); + + log.info( + { seriesId, planId, neededPositionCount: result.data.length }, + "Plan needed positions fetched" + ); + + return result; + } ); - return response; + return cloneResourceResponse(response); } async getServiceTypePlanNeededPositionsWithTeams( serviceTypeId: string, planId: string ): Promise<{ data: PCResource[]; included: PCResource[] }> { - const response = await this.core.fetchAllWithIncluded( - `/services/v2/service_types/${serviceTypeId}/plans/${planId}/needed_positions`, - { include: "team" } + const response = await this.cache.get( + this.buildCacheKey("service-type-plan-needed-positions", serviceTypeId, planId), + NEEDED_POSITIONS_CACHE_TTL_MS, + async () => { + const result = await this.core.fetchAllWithIncluded( + `/services/v2/service_types/${serviceTypeId}/plans/${planId}/needed_positions`, + { include: "team" } + ); + + log.info( + { serviceTypeId, planId, neededPositionCount: result.data.length }, + "Service type plan needed positions fetched" + ); + + return result; + } ); - log.info( - { serviceTypeId, planId, neededPositionCount: response.data.length }, - "Service type plan needed positions fetched" - ); + return cloneResourceResponse(response); + } - return response; + private buildCacheKey(namespace: string, ...parts: string[]): string { + return [ + this.core.getCacheScope(), + namespace, + ...parts.map((part) => encodeURIComponent(part)), + ].join(":"); } } +function cloneResourceResponse(response: { + data: PCResource[]; + included: PCResource[]; +}): { data: PCResource[]; included: PCResource[] } { + return { + data: structuredClone(response.data), + included: structuredClone(response.included), + }; +} + export const planningCenterCatalogService = new PlanningCenterCatalogService( new PlanningCenterCoreClient() ); diff --git a/lib/planning-center/services/people-service.test.ts b/lib/planning-center/services/people-service.test.ts index e16eea5..a98bcd2 100644 --- a/lib/planning-center/services/people-service.test.ts +++ b/lib/planning-center/services/people-service.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import type { PlanningCenterCoreClient } from "@/lib/planning-center/core-client"; import { PlanningCenterPeopleService } from "@/lib/planning-center/services/people-service"; +import type { PCResource } from "@/lib/types"; + +function resource(id: string, type: string, attributes: Record = {}): PCResource { + return { + id, + type, + attributes, + }; +} describe("PlanningCenterPeopleService.getPlanTeamMembers", () => { it("uses fetchAllWithIncluded so large rosters are not truncated to the first page", async () => { @@ -22,6 +31,204 @@ describe("PlanningCenterPeopleService.getPlanTeamMembers", () => { }); }); +describe("PlanningCenterPeopleService.getPersonTeamPositionAssignments", () => { + it("caches assignment validation reads used by schedule POST", async () => { + const fetch = vi.fn().mockResolvedValue({ data: [], included: [] }); + const core = { + fetch, + getCacheScope: () => "test-scope", + } as unknown as PlanningCenterCoreClient; + const service = new PlanningCenterPeopleService(core); + + await service.getPersonTeamPositionAssignments("person-123"); + await service.getPersonTeamPositionAssignments("person-123"); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + "/services/v2/people/person-123/person_team_position_assignments?include=team_position,team_position.team" + ); + }); +}); + +describe("PlanningCenterPeopleService.searchPeopleByName", () => { + it("caches normalized people search reads and returns mutation-safe copies", async () => { + const buildUrl = vi.fn((_path: string, params: Record) => + `/people/v2/people?search=${params["where[search_name]"]}&limit=${params.per_page}` + ); + const fetch = vi.fn().mockResolvedValue({ + data: [resource("person-1", "Person", { first_name: "Andrew" })], + }); + const core = { + buildUrl, + fetch, + getCacheScope: () => "test-scope", + } as unknown as PlanningCenterCoreClient; + const service = new PlanningCenterPeopleService(core); + + const first = await service.searchPeopleByName(" Andrew "); + first[0].attributes.first_name = "Mutated"; + const second = await service.searchPeopleByName("andrew"); + + expect(buildUrl).toHaveBeenCalledTimes(1); + expect(buildUrl).toHaveBeenCalledWith("/people/v2/people", { + "where[search_name]": "Andrew", + order: "last_name,first_name", + per_page: "15", + }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith("/people/v2/people?search=Andrew&limit=15"); + expect(second[0].attributes.first_name).toBe("Andrew"); + }); +}); + +describe("PlanningCenterPeopleService.getAllPeopleFromTeams", () => { + it("caches team roster reads and returns mutation-safe copies", async () => { + const fetchAll = vi.fn().mockResolvedValue([ + resource("team-1", "Team", { name: "Band" }), + resource("team-2", "Team", { name: "Hosts" }), + ]); + const fetch = vi.fn(async (endpoint: string) => { + if (endpoint.includes("/teams/team-1/")) { + return { + data: [ + { + id: "assignment-1", + type: "PersonTeamPositionAssignment", + attributes: {}, + relationships: { + person: { data: { id: "person-1", type: "Person" } }, + }, + }, + ], + included: [resource("person-1", "Person", { + first_name: "Alex", + last_name: "Adams", + })], + }; + } + + return { + data: [ + { + id: "assignment-2", + type: "PersonTeamPositionAssignment", + attributes: {}, + relationships: { + person: { data: { id: "person-1", type: "Person" } }, + }, + }, + ], + included: [resource("person-1", "Person", { + first_name: "Alex", + last_name: "Adams", + })], + }; + }); + const core = { + fetch, + fetchAll, + getCacheScope: () => "test-scope", + } as unknown as PlanningCenterCoreClient; + const service = new PlanningCenterPeopleService(core); + + const first = await service.getAllPeopleFromTeams(); + first.people[0].attributes.first_name = "Mutated"; + first.teamNamesByPersonId.get("person-1")?.add("Mutated Team"); + const second = await service.getAllPeopleFromTeams(); + + expect(fetchAll).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); + expect(second.people).toHaveLength(1); + expect(second.people[0].attributes.first_name).toBe("Alex"); + expect([...(second.teamNamesByPersonId.get("person-1") ?? [])]).toEqual([ + "Band", + "Hosts", + ]); + }); +}); + +describe("PlanningCenterPeopleService.updatePlanPersonStatus", () => { + it("invalidates cached plan team members when plan context is available", async () => { + const fetchAllWithIncluded = vi.fn().mockResolvedValue({ data: [], included: [] }); + const fetch = vi.fn().mockResolvedValue({ + data: { id: "pp-123", type: "PlanPerson", attributes: {} }, + }); + const core = { + fetch, + fetchAllWithIncluded, + getCacheScope: () => "test-scope", + } as unknown as PlanningCenterCoreClient; + const service = new PlanningCenterPeopleService(core); + + await service.getPlanTeamMembers("st-789", "plan-101"); + await service.getPlanTeamMembers("st-789", "plan-101"); + + await service.updatePlanPersonStatus("pp-123", "C", { + personId: "person-456", + serviceTypeId: "st-789", + planId: "plan-101", + }); + + await service.getPlanTeamMembers("st-789", "plan-101"); + + expect(fetchAllWithIncluded).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledWith( + "/services/v2/plan_people/pp-123", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: { + type: "PlanPerson", + id: "pp-123", + attributes: { + status: "C", + }, + }, + }), + } + ); + }); +}); + +describe("PlanningCenterPeopleService.invalidateScheduleReadCaches", () => { + it("clears cached plan team members and person schedules for conflict reconciliation", async () => { + const fetchAllWithIncluded = vi.fn().mockResolvedValue({ data: [], included: [] }); + const core = { + fetchAllWithIncluded, + getCacheScope: () => "test-scope", + } as unknown as PlanningCenterCoreClient; + const service = new PlanningCenterPeopleService(core); + + await service.getPlanTeamMembers("st-789", "plan-101"); + await service.getPersonSchedules("person-456"); + await service.getPlanTeamMembers("st-789", "plan-101"); + await service.getPersonSchedules("person-456"); + + service.invalidateScheduleReadCaches({ + personId: "person-456", + serviceTypeId: "st-789", + planId: "plan-101", + }); + + await service.getPlanTeamMembers("st-789", "plan-101"); + await service.getPersonSchedules("person-456"); + + expect( + fetchAllWithIncluded.mock.calls.filter(([endpoint]) => + String(endpoint).includes("/plans/plan-101/team_members") + ) + ).toHaveLength(2); + expect( + fetchAllWithIncluded.mock.calls.filter(([endpoint]) => + String(endpoint).includes("/people/person-456/schedules") + ) + ).toHaveLength(2); + }); +}); + describe("PlanningCenterPeopleService.getPlanPlanTimes", () => { it("fetches all plan times through the shared cache-backed endpoint", async () => { const fetchAll = vi.fn().mockResolvedValue([]); diff --git a/lib/planning-center/services/people-service.ts b/lib/planning-center/services/people-service.ts index d4f0a60..69772f1 100644 --- a/lib/planning-center/services/people-service.ts +++ b/lib/planning-center/services/people-service.ts @@ -6,6 +6,16 @@ const ASSIGNMENTS_CACHE_TTL_MS = 5 * 60 * 1000; const PERSON_READ_CACHE_TTL_MS = 60 * 1000; const PLAN_TEAM_MEMBERS_CACHE_TTL_MS = 30 * 1000; const PLAN_TIMES_CACHE_TTL_MS = 5 * 60 * 1000; +const PERSON_TEAM_POSITION_ASSIGNMENTS_CACHE_TTL_MS = 5 * 60 * 1000; +const ALL_TEAM_PEOPLE_CACHE_TTL_MS = 5 * 60 * 1000; +const PEOPLE_SEARCH_CACHE_TTL_MS = 60 * 1000; +const TEAM_PEOPLE_CONCURRENCY = 8; + +type AllTeamPeopleResponse = { + people: PCResource[]; + included: PCResource[]; + teamNamesByPersonId: Map>; +}; export class PlanningCenterPeopleService { private readonly cache = new PlanningCenterReadCache(); @@ -28,14 +38,29 @@ export class PlanningCenterPeopleService { } async searchPeopleByName(query: string, limit: number = 15): Promise { - const endpoint = this.core.buildUrl("/people/v2/people", { - "where[search_name]": query.trim(), - order: "last_name,first_name", - per_page: String(limit), - }); - const response = await this.core.fetch(endpoint); - const data = Array.isArray(response.data) ? response.data : [response.data]; - return data.slice(0, limit); + const normalizedQuery = query.trim(); + if (!normalizedQuery) return []; + + const data = await this.cache.get( + this.buildCacheKey( + "people-search", + normalizedQuery.toLowerCase(), + String(limit) + ), + PEOPLE_SEARCH_CACHE_TTL_MS, + async () => { + const endpoint = this.core.buildUrl("/people/v2/people", { + "where[search_name]": normalizedQuery, + order: "last_name,first_name", + per_page: String(limit), + }); + const response = await this.core.fetch(endpoint); + const people = Array.isArray(response.data) ? response.data : [response.data]; + return people.slice(0, limit); + } + ); + + return structuredClone(data); } async getPersonTeamPositions(personId: string): Promise { @@ -44,70 +69,14 @@ export class PlanningCenterPeopleService { ); } - async getAllPeopleFromTeams(): Promise<{ - people: PCResource[]; - included: PCResource[]; - teamNamesByPersonId: Map>; - }> { - const teams = await this.core.fetchAll("/services/v2/teams"); - const activeTeams = teams.filter( - (team) => !(team.attributes.archived_at as string | null | undefined) + async getAllPeopleFromTeams(): Promise { + const response = await this.cache.get( + this.buildCacheKey("all-team-people"), + ALL_TEAM_PEOPLE_CACHE_TTL_MS, + () => this.loadAllPeopleFromTeams() ); - const allPeople: PCResource[] = []; - const allIncluded: PCResource[] = []; - const teamNamesByPersonId = new Map>(); - const seenIds = new Set(); - - for (const team of activeTeams) { - try { - const response = await this.core.fetch( - `/services/v2/teams/${team.id}/people?include=person` - ); - - const people = Array.isArray(response.data) ? response.data : [response.data]; - const included = response.included || []; - - for (const person of people) { - let personResource: PCResource | null = null; - - if (person.type === "Person") { - personResource = person; - } else if (person.relationships?.person?.data) { - const personData = person.relationships.person.data; - const personId = Array.isArray(personData) - ? personData[0]?.id - : personData?.id; - - if (personId) { - personResource = - included.find((p) => p.type === "Person" && p.id === personId) || null; - } - } - - if (personResource && !seenIds.has(personResource.id)) { - seenIds.add(personResource.id); - allPeople.push(personResource); - } - - if (personResource) { - const teamName = team.attributes.name; - if (typeof teamName === "string" && teamName.trim()) { - if (!teamNamesByPersonId.has(personResource.id)) { - teamNamesByPersonId.set(personResource.id, new Set()); - } - teamNamesByPersonId.get(personResource.id)!.add(teamName); - } - } - } - - allIncluded.push(...included); - } catch { - // Skip teams with partial-access or transient API failures. - } - } - - return { people: allPeople, included: allIncluded, teamNamesByPersonId }; + return cloneAllTeamPeopleResponse(response); } async getPersonBlockouts( @@ -259,19 +228,26 @@ export class PlanningCenterPeopleService { async getPersonTeamPositionAssignments( personId: string ): Promise<{ data: PCResource[]; included: PCResource[] }> { - const response = await this.core.fetch( - `/services/v2/people/${personId}/person_team_position_assignments?include=team_position,team_position.team` - ); + return this.cache.get( + this.buildCacheKey("person-team-position-assignments", personId), + PERSON_TEAM_POSITION_ASSIGNMENTS_CACHE_TTL_MS, + async () => { + const response = await this.core.fetch( + `/services/v2/people/${personId}/person_team_position_assignments?include=team_position,team_position.team` + ); - return { - data: Array.isArray(response.data) ? response.data : [response.data], - included: response.included || [], - }; + return { + data: Array.isArray(response.data) ? response.data : [response.data], + included: response.included || [], + }; + } + ); } async updatePlanPersonStatus( planPersonId: string, - status: "C" | "U" | "D" + status: "C" | "U" | "D", + context?: { personId?: string; serviceTypeId?: string; planId?: string } ): Promise { const response = await this.core.fetch( `/services/v2/plan_people/${planPersonId}`, @@ -291,6 +267,13 @@ export class PlanningCenterPeopleService { }), } ); + if (context?.serviceTypeId && context.planId) { + this.invalidateScheduleReadCaches({ + personId: context.personId, + serviceTypeId: context.serviceTypeId, + planId: context.planId, + }); + } return response.data; } @@ -309,7 +292,7 @@ export class PlanningCenterPeopleService { method: "DELETE", }); if (context?.serviceTypeId && context.planId) { - this.invalidateScheduleCaches({ + this.invalidateScheduleReadCaches({ personId: context.personId, serviceTypeId: context.serviceTypeId, planId: context.planId, @@ -347,23 +330,11 @@ export class PlanningCenterPeopleService { }), } ); - this.invalidateScheduleCaches({ personId, serviceTypeId, planId }); + this.invalidateScheduleReadCaches({ personId, serviceTypeId, planId }); return response.data; } - private buildCacheKey(namespace: string, ...parts: string[]): string { - return [ - this.core.getCacheScope(), - namespace, - ...parts.map((part) => encodeURIComponent(part)), - ].join(":"); - } - - getCacheScope(): string { - return this.core.getCacheScope(); - } - - private invalidateScheduleCaches({ + invalidateScheduleReadCaches({ personId, serviceTypeId, planId, @@ -394,6 +365,129 @@ export class PlanningCenterPeopleService { ); }); } + + private buildCacheKey(namespace: string, ...parts: string[]): string { + return [ + this.core.getCacheScope(), + namespace, + ...parts.map((part) => encodeURIComponent(part)), + ].join(":"); + } + + private async loadAllPeopleFromTeams(): Promise { + const teams = await this.core.fetchAll("/services/v2/teams"); + const activeTeams = teams.filter( + (team) => !(team.attributes.archived_at as string | null | undefined) + ); + + const teamResponses = await mapWithConcurrency( + activeTeams, + TEAM_PEOPLE_CONCURRENCY, + async (team) => { + try { + const response = await this.core.fetch( + `/services/v2/teams/${team.id}/people?include=person` + ); + return { team, response }; + } catch { + // Skip teams with partial-access or transient API failures. + return null; + } + } + ); + + const allPeople: PCResource[] = []; + const allIncluded: PCResource[] = []; + const teamNamesByPersonId = new Map>(); + const seenIds = new Set(); + + for (const result of teamResponses) { + if (!result) continue; + + const { team, response } = result; + const people = Array.isArray(response.data) ? response.data : [response.data]; + const included = response.included || []; + + for (const person of people) { + let personResource: PCResource | null = null; + + if (person.type === "Person") { + personResource = person; + } else if (person.relationships?.person?.data) { + const personData = person.relationships.person.data; + const personId = Array.isArray(personData) + ? personData[0]?.id + : personData?.id; + + if (personId) { + personResource = + included.find((p) => p.type === "Person" && p.id === personId) || null; + } + } + + if (personResource && !seenIds.has(personResource.id)) { + seenIds.add(personResource.id); + allPeople.push(personResource); + } + + if (personResource) { + const teamName = team.attributes.name; + if (typeof teamName === "string" && teamName.trim()) { + if (!teamNamesByPersonId.has(personResource.id)) { + teamNamesByPersonId.set(personResource.id, new Set()); + } + teamNamesByPersonId.get(personResource.id)!.add(teamName); + } + } + } + + allIncluded.push(...included); + } + + return { people: allPeople, included: allIncluded, teamNamesByPersonId }; + } + + getCacheScope(): string { + return this.core.getCacheScope(); + } + +} + +async function mapWithConcurrency( + items: T[], + concurrency: number, + mapper: (item: T, index: number) => Promise +): Promise { + if (items.length === 0) return []; + + const safeConcurrency = Math.max(1, Math.min(concurrency, items.length)); + const results = Array.from({ length: items.length }, () => undefined as R); + let nextIndex = 0; + + async function worker() { + while (nextIndex < items.length) { + const current = nextIndex; + nextIndex += 1; + results[current] = await mapper(items[current], current); + } + } + + const workers = Array.from({ length: safeConcurrency }, () => worker()); + await Promise.all(workers); + return results; +} + +function cloneAllTeamPeopleResponse(response: AllTeamPeopleResponse): AllTeamPeopleResponse { + return { + people: structuredClone(response.people), + included: structuredClone(response.included), + teamNamesByPersonId: new Map( + [...response.teamNamesByPersonId.entries()].map(([personId, teamNames]) => [ + personId, + new Set(teamNames), + ]) + ), + }; } export const planningCenterPeopleService = new PlanningCenterPeopleService( diff --git a/lib/planning-center/services/plan-items-service.test.ts b/lib/planning-center/services/plan-items-service.test.ts new file mode 100644 index 0000000..f3600cd --- /dev/null +++ b/lib/planning-center/services/plan-items-service.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PlanningCenterCoreClient } from "@/lib/planning-center/core-client"; +import { PlanningCenterPlanItemsService } from "@/lib/planning-center/services/plan-items-service"; +import type { PCResource } from "@/lib/types"; + +function createCoreClientMock() { + const fetchAllWithIncluded = vi.fn(); + const fetch = vi.fn(); + const request = vi.fn(); + const core = { + fetchAllWithIncluded, + fetch, + request, + getCacheScope: () => "test-scope", + } as unknown as PlanningCenterCoreClient; + + return { core, fetchAllWithIncluded, fetch, request }; +} + +function itemResource(id: string): PCResource { + return { + id, + type: "Item", + attributes: { + title: "Opening Song", + }, + }; +} + +describe("PlanningCenterPlanItemsService read cache", () => { + it("caches plan item reads and returns mutation-safe copies", async () => { + const { core, fetchAllWithIncluded } = createCoreClientMock(); + fetchAllWithIncluded.mockResolvedValue({ + data: [itemResource("item-1")], + included: [itemResource("song-1")], + }); + const service = new PlanningCenterPlanItemsService(core); + + const first = await service.getPlanItems("st-1", "plan-1"); + first.data[0].attributes.title = "Mutated"; + const second = await service.getPlanItems("st-1", "plan-1"); + + expect(fetchAllWithIncluded).toHaveBeenCalledTimes(1); + expect(second.data[0].attributes.title).toBe("Opening Song"); + expect(second.data[0]).not.toBe(first.data[0]); + }); + + it("invalidates cached plan items after create, update, delete, and reorder", async () => { + const { core, fetchAllWithIncluded, fetch, request } = createCoreClientMock(); + fetchAllWithIncluded.mockResolvedValue({ data: [itemResource("item-1")], included: [] }); + fetch.mockResolvedValue({ data: itemResource("item-2"), included: [] }); + request.mockResolvedValue(new Response(null, { status: 204 })); + const service = new PlanningCenterPlanItemsService(core); + + await service.getPlanItems("st-1", "plan-1"); + await service.createPlanItem("st-1", "plan-1", { title: "New Item" }); + await service.getPlanItems("st-1", "plan-1"); + await service.updatePlanItem("st-1", "plan-1", "item-2", { title: "Updated" }); + await service.getPlanItems("st-1", "plan-1"); + await service.deletePlanItem("st-1", "plan-1", "item-2"); + await service.getPlanItems("st-1", "plan-1"); + await service.reorderPlanItems("st-1", "plan-1", ["item-1"]); + await service.getPlanItems("st-1", "plan-1"); + + expect(fetchAllWithIncluded).toHaveBeenCalledTimes(5); + }); +}); diff --git a/lib/planning-center/services/plan-items-service.ts b/lib/planning-center/services/plan-items-service.ts index e335fac..cdb267e 100644 --- a/lib/planning-center/services/plan-items-service.ts +++ b/lib/planning-center/services/plan-items-service.ts @@ -1,8 +1,10 @@ import type { PCResource } from "@/lib/types"; import { logger } from "@/lib/logger"; import { PlanningCenterCoreClient } from "@/lib/planning-center/core-client"; +import { PlanningCenterReadCache } from "@/lib/planning-center/services/read-cache"; const log = logger.for("planning-center/plan-items"); +const PLAN_ITEMS_CACHE_TTL_MS = 30 * 1000; function buildItemPayload(attributes: Record, id?: string) { return { @@ -15,25 +17,35 @@ function buildItemPayload(attributes: Record, id?: string) { } export class PlanningCenterPlanItemsService { + private readonly cache = new PlanningCenterReadCache(); + constructor(private readonly core: PlanningCenterCoreClient) {} async getPlanItems( serviceTypeId: string, planId: string ): Promise<{ data: PCResource[]; included: PCResource[] }> { - const response = await this.core.fetchAllWithIncluded( - `/services/v2/service_types/${serviceTypeId}/plans/${planId}/items`, - { - include: "song,arrangement,key,item_notes,item_times", - } - ); + const response = await this.cache.get( + this.buildPlanItemsCacheKey(serviceTypeId, planId), + PLAN_ITEMS_CACHE_TTL_MS, + async () => { + const result = await this.core.fetchAllWithIncluded( + `/services/v2/service_types/${serviceTypeId}/plans/${planId}/items`, + { + include: "song,arrangement,key,item_notes,item_times", + } + ); - log.info( - { serviceTypeId, planId, itemCount: response.data.length }, - "Plan items fetched" + log.info( + { serviceTypeId, planId, itemCount: result.data.length }, + "Plan items fetched" + ); + + return result; + } ); - return response; + return clonePlanItemsResponse(response); } async getPlanItem( @@ -66,6 +78,7 @@ export class PlanningCenterPlanItemsService { body: JSON.stringify(buildItemPayload(attributes)), } ); + this.invalidatePlanItemsCache(serviceTypeId, planId); return { data: response.data, @@ -89,6 +102,7 @@ export class PlanningCenterPlanItemsService { body: JSON.stringify(buildItemPayload(attributes, itemId)), } ); + this.invalidatePlanItemsCache(serviceTypeId, planId); return { data: response.data, @@ -107,6 +121,7 @@ export class PlanningCenterPlanItemsService { method: "DELETE", } ); + this.invalidatePlanItemsCache(serviceTypeId, planId); } async reorderPlanItems( @@ -131,9 +146,34 @@ export class PlanningCenterPlanItemsService { }), } ); + this.invalidatePlanItemsCache(serviceTypeId, planId); + } + + private buildPlanItemsCacheKey(serviceTypeId: string, planId: string): string { + return [ + this.core.getCacheScope(), + "plan-items", + encodeURIComponent(serviceTypeId), + encodeURIComponent(planId), + ].join(":"); + } + + private invalidatePlanItemsCache(serviceTypeId: string, planId: string) { + const cacheKey = this.buildPlanItemsCacheKey(serviceTypeId, planId); + this.cache.deleteWhere((key) => key === cacheKey); } } +function clonePlanItemsResponse(response: { + data: PCResource[]; + included: PCResource[]; +}): { data: PCResource[]; included: PCResource[] } { + return { + data: structuredClone(response.data), + included: structuredClone(response.included), + }; +} + export const planningCenterPlanItemsService = new PlanningCenterPlanItemsService( new PlanningCenterCoreClient() ); diff --git a/lib/planning-center/services/plans-service.test.ts b/lib/planning-center/services/plans-service.test.ts new file mode 100644 index 0000000..8f7b9ba --- /dev/null +++ b/lib/planning-center/services/plans-service.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PlanningCenterCoreClient } from "@/lib/planning-center/core-client"; +import { PlanningCenterPlansService } from "@/lib/planning-center/services/plans-service"; +import type { PCResource } from "@/lib/types"; + +vi.mock("@/lib/planning-center/resolve-organization-timezone", () => ({ + resolveOrganizationTimeZone: vi.fn().mockResolvedValue("America/Los_Angeles"), +})); + +function planResource(id: string, sortDate: string): PCResource { + return { + id, + type: "Plan", + attributes: { + sort_date: sortDate, + }, + }; +} + +describe("PlanningCenterPlansService.getPlansWithIncludedInDateRange", () => { + it("caches range reads and returns mutation-safe copies", async () => { + const fetchAllWithIncluded = vi.fn().mockResolvedValue({ + data: [ + planResource("plan-1", "2026-05-24T10:00:00-07:00"), + planResource("plan-2", "2026-06-01T10:00:00-07:00"), + ], + included: [ + { + id: "series-1", + type: "Series", + attributes: { title: "Original Series" }, + relationships: { + plan: { data: { id: "plan-1", type: "Plan" } }, + }, + }, + ], + }); + const core = { + fetchAllWithIncluded, + getCacheScope: () => "test-scope", + } as unknown as PlanningCenterCoreClient; + const service = new PlanningCenterPlansService(core); + + const first = await service.getPlansWithIncludedInDateRange( + "st-1", + "2026-05-23", + "2026-06-30", + "series" + ); + first.data[0].attributes.sort_date = "mutated"; + first.included[0].attributes.title = "Mutated Series"; + + const second = await service.getPlansWithIncludedInDateRange( + "st-1", + "2026-05-23", + "2026-06-30", + "series" + ); + + expect(fetchAllWithIncluded).toHaveBeenCalledTimes(1); + expect(second.data[0].attributes.sort_date).toBe("2026-05-24T10:00:00-07:00"); + expect(second.included[0].attributes.title).toBe("Original Series"); + }); +}); diff --git a/lib/planning-center/services/plans-service.ts b/lib/planning-center/services/plans-service.ts index 8870eeb..182b5ea 100644 --- a/lib/planning-center/services/plans-service.ts +++ b/lib/planning-center/services/plans-service.ts @@ -64,7 +64,7 @@ export class PlanningCenterPlansService { stableParams(params), ].join(":"); - return this.cache.get(cacheKey, PLANS_RANGE_CACHE_TTL_MS, async () => { + const response = await this.cache.get(cacheKey, PLANS_RANGE_CACHE_TTL_MS, async () => { log.info( { serviceTypeId, after: afterDayKey, before: beforeDayKey, include: include || null }, "Fetching plans in date range" @@ -103,6 +103,8 @@ export class PlanningCenterPlansService { ); return { data: plans, included }; }); + + return cloneResourceResponse(response); } async getPlan(planId: string): Promise { @@ -124,6 +126,16 @@ export class PlanningCenterPlansService { } } +function cloneResourceResponse(response: { + data: PCResource[]; + included: PCResource[]; +}): { data: PCResource[]; included: PCResource[] } { + return { + data: structuredClone(response.data), + included: structuredClone(response.included), + }; +} + export const planningCenterPlansService = new PlanningCenterPlansService( new PlanningCenterCoreClient() ); diff --git a/lib/planning-center/services/songs-service.test.ts b/lib/planning-center/services/songs-service.test.ts index 6958594..251a2f5 100644 --- a/lib/planning-center/services/songs-service.test.ts +++ b/lib/planning-center/services/songs-service.test.ts @@ -7,14 +7,104 @@ import { PlanningCenterSongsService } from "@/lib/planning-center/services/songs function createCoreClientMock() { const fetchMock = vi.fn(); + const fetchAllMock = vi.fn(); + const fetchAllWithIncludedMock = vi.fn(); const core = { fetch: fetchMock, - fetchAll: vi.fn(), - fetchAllWithIncluded: vi.fn(), + fetchAll: fetchAllMock, + fetchAllWithIncluded: fetchAllWithIncludedMock, + getCacheScope: vi.fn(() => "test-scope"), } as unknown as PlanningCenterCoreClient; - return { core, fetchMock }; + return { core, fetchMock, fetchAllMock, fetchAllWithIncludedMock }; } +describe("PlanningCenterSongsService", () => { + it("dedupes song catalog loads and returns defensive clones", async () => { + const { core, fetchAllMock } = createCoreClientMock(); + fetchAllMock.mockResolvedValue([ + { + id: "song-1", + type: "Song", + attributes: { title: "Build My Life" }, + }, + ]); + + const service = new PlanningCenterSongsService(core); + const [first, second] = await Promise.all([ + service.getSongsCatalogCached("account-1:service-1"), + service.getSongsCatalogCached("account-1:service-1"), + ]); + + expect(fetchAllMock).toHaveBeenCalledTimes(1); + expect(fetchAllMock).toHaveBeenCalledWith("/services/v2/songs", { order: "title" }, 15); + expect(first).toEqual(second); + expect(first).not.toBe(second); + expect(first[0]).not.toBe(second[0]); + + first[0]!.attributes.title = "Changed locally"; + const third = await service.getSongsCatalogCached("account-1:service-1"); + + expect(fetchAllMock).toHaveBeenCalledTimes(1); + expect(second[0]!.attributes.title).toBe("Build My Life"); + expect(third[0]!.attributes.title).toBe("Build My Life"); + }); + + it("caches song details and returns defensive clones", async () => { + const { core, fetchMock } = createCoreClientMock(); + fetchMock.mockResolvedValue({ + data: { + id: "song-1", + type: "Song", + attributes: { title: "Build My Life" }, + }, + }); + + const service = new PlanningCenterSongsService(core); + const first = await service.getSong("song-1"); + const second = await service.getSong("song-1"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(first).toEqual(second); + expect(first).not.toBe(second); + + first.attributes.title = "Changed locally"; + expect(second.attributes.title).toBe("Build My Life"); + }); + + it("caches arrangement and key responses and returns defensive clones", async () => { + const { core, fetchAllWithIncludedMock } = createCoreClientMock(); + fetchAllWithIncludedMock.mockResolvedValue({ + data: [ + { + id: "arr-1", + type: "Arrangement", + attributes: { name: "Default" }, + }, + ], + included: [ + { + id: "key-1", + type: "Key", + attributes: { name: "G" }, + }, + ], + }); + + const service = new PlanningCenterSongsService(core); + const first = await service.getSongArrangementsWithKeys("song-1"); + const second = await service.getSongArrangementsWithKeys("song-1"); + + expect(fetchAllWithIncludedMock).toHaveBeenCalledTimes(1); + expect(first).toEqual(second); + expect(first).not.toBe(second); + expect(first.data).not.toBe(second.data); + expect(first.included).not.toBe(second.included); + + first.data[0]!.attributes.name = "Changed locally"; + expect(second.data[0]!.attributes.name).toBe("Default"); + }); +}); + describe("PlanningCenterSongsService.getSongLastScheduledItem", () => { it("returns null for 404 responses only", async () => { const { core, fetchMock } = createCoreClientMock(); diff --git a/lib/planning-center/services/songs-service.ts b/lib/planning-center/services/songs-service.ts index 42f746a..74e392c 100644 --- a/lib/planning-center/services/songs-service.ts +++ b/lib/planning-center/services/songs-service.ts @@ -4,18 +4,21 @@ import { PlanningCenterApiError, PlanningCenterCoreClient, } from "@/lib/planning-center/core-client"; +import { PlanningCenterReadCache } from "@/lib/planning-center/services/read-cache"; const log = logger.for("planning-center/songs"); const DEFAULT_CATALOG_TTL_MS = 15 * 60 * 1000; const DEFAULT_CATALOG_MAX_PAGES = 15; +const SONG_DETAILS_CACHE_TTL_MS = 5 * 60 * 1000; type CatalogCacheEntry = { expiresAt: number; - data: PCResource[]; + promise: Promise; }; export class PlanningCenterSongsService { private readonly catalogCache = new Map(); + private readonly readCache = new PlanningCenterReadCache(); constructor(private readonly core: PlanningCenterCoreClient) {} @@ -36,38 +39,60 @@ export class PlanningCenterSongsService { const cached = this.catalogCache.get(cacheKey); if (cached && cached.expiresAt > now) { - return cached.data; + return structuredClone(await cached.promise); } - const data = await this.core.fetchAll( - "/services/v2/songs", - { order: "title" }, - maxPages - ); + const promise = this.core + .fetchAll("/services/v2/songs", { order: "title" }, maxPages) + .then((data) => { + log.info({ cacheKey, songCount: data.length }, "Songs catalog cached"); + return data; + }) + .catch((error: unknown) => { + if (this.catalogCache.get(cacheKey)?.promise === promise) { + this.catalogCache.delete(cacheKey); + } + throw error; + }); this.catalogCache.set(cacheKey, { expiresAt: now + ttlMs, - data, + promise, }); - log.info({ cacheKey, songCount: data.length }, "Songs catalog cached"); - return data; + return structuredClone(await promise); } async getSong(songId: string): Promise { - const response = await this.core.fetch(`/services/v2/songs/${songId}`); - return response.data; + const resource = await this.readCache.get( + this.buildSongCacheKey("song", songId), + SONG_DETAILS_CACHE_TTL_MS, + async () => { + const response = await this.core.fetch(`/services/v2/songs/${songId}`); + return response.data; + } + ); + + return structuredClone(resource); } async getSongArrangementsWithKeys( songId: string ): Promise<{ data: PCResource[]; included: PCResource[] }> { - const response = await this.core.fetchAllWithIncluded( - `/services/v2/songs/${songId}/arrangements`, - { include: "keys" } + const response = await this.readCache.get( + this.buildSongCacheKey("arrangements", songId), + SONG_DETAILS_CACHE_TTL_MS, + () => + this.core.fetchAllWithIncluded( + `/services/v2/songs/${songId}/arrangements`, + { include: "keys" } + ) ); - return response; + return { + data: structuredClone(response.data), + included: structuredClone(response.included), + }; } async getSongLastScheduledItem( @@ -93,6 +118,15 @@ export class PlanningCenterSongsService { throw error; } } + + private buildSongCacheKey(kind: "song" | "arrangements", songId: string): string { + return [ + this.core.getCacheScope(), + "songs", + kind, + encodeURIComponent(songId), + ].join(":"); + } } export const planningCenterSongsService = new PlanningCenterSongsService( diff --git a/lib/query-cache-hydration.ts b/lib/query-cache-hydration.ts new file mode 100644 index 0000000..219da4b --- /dev/null +++ b/lib/query-cache-hydration.ts @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { useQueryClient, type QueryKey } from "@tanstack/react-query"; + +type ClientCacheEntry = { + data: TData; + savedAt: number; +}; + +export function useHydrateQueryFromCache( + queryKey: QueryKey, + readCache: () => ClientCacheEntry | undefined +) { + const queryClient = useQueryClient(); + + useEffect(() => { + const cached = readCache(); + if (!cached) return; + + const state = queryClient.getQueryState(queryKey); + if (state?.data !== undefined && state.dataUpdatedAt >= cached.savedAt) return; + + queryClient.setQueryData(queryKey, cached.data, { + updatedAt: cached.savedAt, + }); + }, [queryClient, queryKey, readCache]); +} diff --git a/lib/query-keys.ts b/lib/query-keys.ts index 780f367..3e2dd81 100644 --- a/lib/query-keys.ts +++ b/lib/query-keys.ts @@ -5,8 +5,8 @@ export const queryKeys = { teamPositions: ( serviceTypeId: string | null, planId: string | null, - seriesId: string | null - ) => ["team-positions", serviceTypeId, planId, seriesId] as const, + _seriesId: string | null + ) => ["team-positions", serviceTypeId, planId] as const, people: ( serviceTypeId: string | null, teamId: string | null, diff --git a/lib/schedule-cache-optimism.test.ts b/lib/schedule-cache-optimism.test.ts new file mode 100644 index 0000000..0b9b376 --- /dev/null +++ b/lib/schedule-cache-optimism.test.ts @@ -0,0 +1,472 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + cancelScheduleMutationQueries, + optimisticallySchedulePerson, + optimisticallyUnschedulePlanPerson, + optimisticallyUpdatePlanPersonStatus, + reconcileOptimisticPlanPersonId, + restoreScheduleCaches, + SCHEDULE_MUTATION_RECONCILE_DELAY_MS, + settleScheduleMutationQueries, +} from "@/hooks/use-schedule-cache-optimism"; +import { + readCachedMyScheduledPlans, + writeCachedMyScheduledPlans, +} from "@/lib/my-scheduled-plans-cache"; +import { readCachedPeople, writeCachedPeople } from "@/lib/people-cache"; +import { + readCachedTeamPositions, + writeCachedTeamPositions, +} from "@/lib/team-positions-cache"; +import { queryKeys } from "@/lib/query-keys"; +import type { PersonWithAvailability, TeamPositionGroup } from "@/lib/types"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function person(overrides: Partial = {}): PersonWithAvailability { + return { + id: "person-1", + firstName: "Andrew", + lastName: "Hinea", + fullName: "Andrew Hinea", + photoUrl: null, + photoThumbnailUrl: null, + archived: false, + positions: [], + ...overrides, + }; +} + +function teamGroups(): TeamPositionGroup[] { + return [ + { + teamId: "team-1", + teamName: "Band", + positions: [ + { + id: "position-1", + name: "Acoustic Guitar", + teamId: "team-1", + teamName: "Band", + neededCount: 1, + filledPendingCount: 0, + filledConfirmedCount: 0, + }, + ], + }, + ]; +} + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +describe("schedule cache optimism", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("marks a scheduled person and slot immediately, then reconciles the real plan person id", () => { + const queryClient = createQueryClient(); + const peopleKey = queryKeys.people( + "service-type-1", + "team-1", + "position-1", + "plan-1", + "2026-05-24T10:00:00.000Z" + ); + const teamPositionsKey = queryKeys.teamPositions("service-type-1", "plan-1", "series-1"); + queryClient.setQueryData(peopleKey, [person()]); + queryClient.setQueryData(teamPositionsKey, teamGroups()); + + optimisticallySchedulePerson( + queryClient, + { + serviceTypeId: "service-type-1", + planId: "plan-1", + teamId: "team-1", + positionId: "position-1", + }, + { id: "person-1", fullName: "Andrew Hinea", photoThumbnailUrl: null }, + "optimistic-plan-person" + ); + + expect(queryClient.getQueryData(peopleKey)?.[0]).toMatchObject({ + isScheduledForSelectedPlanPosition: true, + isConfirmedForSelectedPlanPosition: false, + isDeclinedForSelectedPlanPosition: false, + scheduledPlanPersonId: "optimistic-plan-person", + }); + expect( + queryClient.getQueryData(teamPositionsKey)?.[0]?.positions[0] + ).toMatchObject({ + filledPendingCount: 1, + filledConfirmedCount: 0, + filledPeople: [ + { + id: "person-1", + planPersonId: "optimistic-plan-person", + name: "Andrew Hinea", + status: "pending", + }, + ], + }); + + reconcileOptimisticPlanPersonId( + queryClient, + "optimistic-plan-person", + "plan-person-1" + ); + + expect(queryClient.getQueryData(peopleKey)?.[0]).toMatchObject({ + scheduledPlanPersonId: "plan-person-1", + }); + expect( + queryClient.getQueryData(teamPositionsKey)?.[0]?.positions[0] + ?.filledPeople?.[0] + ).toMatchObject({ planPersonId: "plan-person-1" }); + }); + + it("inserts a one-off scheduled person into the selected people cache immediately", () => { + const queryClient = createQueryClient(); + const peopleKey = queryKeys.people( + "service-type-1", + "team-1", + "position-1", + "plan-1", + "2026-05-24T10:00:00.000Z" + ); + const teamPositionsKey = queryKeys.teamPositions("service-type-1", "plan-1", "series-1"); + queryClient.setQueryData(peopleKey, [person()]); + queryClient.setQueryData(teamPositionsKey, teamGroups()); + + optimisticallySchedulePerson( + queryClient, + { + serviceTypeId: "service-type-1", + planId: "plan-1", + teamId: "team-1", + positionId: "position-1", + }, + { + id: "person-2", + firstName: "Samuel", + lastName: "Stefan", + fullName: "Samuel Stefan", + photoThumbnailUrl: "https://example.com/samuel.jpg", + }, + "optimistic-plan-person-2" + ); + + expect(queryClient.getQueryData(peopleKey)?.[0]).toMatchObject({ + id: "person-2", + firstName: "Samuel", + lastName: "Stefan", + fullName: "Samuel Stefan", + photoThumbnailUrl: "https://example.com/samuel.jpg", + isScheduledForSelectedPlanPosition: true, + scheduledPlanPersonId: "optimistic-plan-person-2", + }); + expect(queryClient.getQueryData(peopleKey)).toHaveLength(2); + expect( + queryClient.getQueryData(teamPositionsKey)?.[0]?.positions[0] + ?.filledPeople?.[0] + ).toMatchObject({ + id: "person-2", + planPersonId: "optimistic-plan-person-2", + name: "Samuel Stefan", + status: "pending", + }); + }); + + it("updates status, removes declined people from filled slot counts, and restores snapshots", () => { + const queryClient = createQueryClient(); + const peopleKey = queryKeys.people( + "service-type-1", + "team-1", + "position-1", + "plan-1", + "2026-05-24T10:00:00.000Z" + ); + const teamPositionsKey = queryKeys.teamPositions("service-type-1", "plan-1", "series-1"); + queryClient.setQueryData(peopleKey, [ + person({ + isScheduledForSelectedPlanPosition: true, + isConfirmedForSelectedPlanPosition: false, + isDeclinedForSelectedPlanPosition: false, + scheduledPlanPersonId: "plan-person-1", + }), + ]); + queryClient.setQueryData(teamPositionsKey, [ + { + teamId: "team-1", + teamName: "Band", + positions: [ + { + ...teamGroups()[0].positions[0], + filledPendingCount: 1, + filledConfirmedCount: 0, + filledPeople: [ + { + id: "person-1", + planPersonId: "plan-person-1", + name: "Andrew Hinea", + status: "pending", + rawStatus: "U", + photoThumbnailUrl: null, + }, + ], + }, + ], + }, + ]); + + const snapshot = optimisticallyUpdatePlanPersonStatus( + queryClient, + "plan-person-1", + "C" + ); + + expect(queryClient.getQueryData(peopleKey)?.[0]).toMatchObject({ + isScheduledForSelectedPlanPosition: true, + isConfirmedForSelectedPlanPosition: true, + isDeclinedForSelectedPlanPosition: false, + }); + expect( + queryClient.getQueryData(teamPositionsKey)?.[0]?.positions[0] + ).toMatchObject({ + filledPendingCount: 0, + filledConfirmedCount: 1, + }); + + optimisticallyUpdatePlanPersonStatus(queryClient, "plan-person-1", "D"); + + expect(queryClient.getQueryData(peopleKey)?.[0]).toMatchObject({ + isScheduledForSelectedPlanPosition: true, + isConfirmedForSelectedPlanPosition: false, + isDeclinedForSelectedPlanPosition: true, + }); + expect( + queryClient.getQueryData(teamPositionsKey)?.[0]?.positions[0] + ).toMatchObject({ + filledPendingCount: 0, + filledConfirmedCount: 0, + filledPeople: undefined, + }); + + restoreScheduleCaches(queryClient, snapshot); + + expect(queryClient.getQueryData(peopleKey)?.[0]).toMatchObject({ + isScheduledForSelectedPlanPosition: true, + isConfirmedForSelectedPlanPosition: false, + scheduledPlanPersonId: "plan-person-1", + }); + expect( + queryClient.getQueryData(teamPositionsKey)?.[0]?.positions[0] + ).toMatchObject({ + filledPendingCount: 1, + filledConfirmedCount: 0, + }); + }); + + it("clears schedule state when a plan person is unscheduled", () => { + const queryClient = createQueryClient(); + const peopleKey = queryKeys.people( + "service-type-1", + "team-1", + "position-1", + "plan-1", + "2026-05-24T10:00:00.000Z" + ); + const teamPositionsKey = queryKeys.teamPositions("service-type-1", "plan-1", "series-1"); + queryClient.setQueryData(peopleKey, [ + person({ + isScheduledForSelectedPlanPosition: true, + isConfirmedForSelectedPlanPosition: true, + scheduledPlanPersonId: "plan-person-1", + }), + ]); + queryClient.setQueryData(teamPositionsKey, [ + { + teamId: "team-1", + teamName: "Band", + positions: [ + { + ...teamGroups()[0].positions[0], + filledPendingCount: 0, + filledConfirmedCount: 1, + filledPeople: [ + { + id: "person-1", + planPersonId: "plan-person-1", + name: "Andrew Hinea", + status: "confirmed", + rawStatus: "C", + photoThumbnailUrl: null, + }, + ], + }, + ], + }, + ]); + + optimisticallyUnschedulePlanPerson(queryClient, "plan-person-1", "person-1"); + + expect(queryClient.getQueryData(peopleKey)?.[0]).toMatchObject({ + isScheduledForSelectedPlanPosition: false, + isConfirmedForSelectedPlanPosition: false, + isDeclinedForSelectedPlanPosition: false, + scheduledPlanPersonId: undefined, + }); + expect( + queryClient.getQueryData(teamPositionsKey)?.[0]?.positions[0] + ).toMatchObject({ + filledPendingCount: 0, + filledConfirmedCount: 0, + filledPeople: undefined, + }); + }); + + it("cancels current-user plan membership and affected schedule query families", async () => { + const queryClient = createQueryClient(); + const cancelQueries = vi + .spyOn(queryClient, "cancelQueries") + .mockResolvedValue(undefined); + + await cancelScheduleMutationQueries(queryClient, { + serviceTypeId: "service-type-1", + planId: "plan-1", + teamId: "team-1", + positionId: "position-1", + }); + + expect(cancelQueries).toHaveBeenCalledWith({ + queryKey: ["my-scheduled-plans"], + }); + expect(cancelQueries).toHaveBeenCalledWith({ + queryKey: ["team-positions", "service-type-1", "plan-1"], + }); + expect(cancelQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.peopleForSlot( + "service-type-1", + "team-1", + "position-1", + "plan-1" + ), + }); + expect(cancelQueries).toHaveBeenCalledTimes(3); + }); + + it("settles optimistic mutations without immediately refetching the active view", () => { + vi.useFakeTimers(); + const queryClient = createQueryClient(); + const invalidateQueries = vi + .spyOn(queryClient, "invalidateQueries") + .mockResolvedValue(undefined); + const refetchQueries = vi + .spyOn(queryClient, "refetchQueries") + .mockResolvedValue(undefined); + + settleScheduleMutationQueries(queryClient, { + serviceTypeId: "service-type-1", + planId: "plan-1", + teamId: "team-1", + positionId: "position-1", + }); + settleScheduleMutationQueries(queryClient, { + serviceTypeId: "service-type-1", + planId: "plan-1", + teamId: "team-1", + positionId: "position-1", + }); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["my-scheduled-plans"], + refetchType: "inactive", + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["team-positions", "service-type-1", "plan-1"], + refetchType: "inactive", + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.peopleForSlot( + "service-type-1", + "team-1", + "position-1", + "plan-1" + ), + refetchType: "inactive", + }); + expect(refetchQueries).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(SCHEDULE_MUTATION_RECONCILE_DELAY_MS); + + expect(refetchQueries).toHaveBeenCalledWith({ + queryKey: ["my-scheduled-plans"], + type: "active", + }); + expect(refetchQueries).toHaveBeenCalledWith({ + queryKey: ["team-positions", "service-type-1", "plan-1"], + type: "active", + }); + expect(refetchQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.peopleForSlot( + "service-type-1", + "team-1", + "position-1", + "plan-1" + ), + type: "active", + }); + expect(refetchQueries).toHaveBeenCalledTimes(3); + }); + + it("clears persisted schedule snapshots when schedule mutations settle", () => { + installLocalStorageMock(); + const queryClient = createQueryClient(); + const dateKey = "2026-05-24T10:00:00.000Z"; + writeCachedMyScheduledPlans("plan-1,plan-2", { planIds: ["plan-2"] }); + writeCachedPeople("service-type-1", "team-1", "position-1", "plan-1", dateKey, [ + person(), + ]); + writeCachedTeamPositions("service-type-1", "plan-1", "series-1", teamGroups()); + + settleScheduleMutationQueries(queryClient, { + serviceTypeId: "service-type-1", + planId: "plan-1", + teamId: "team-1", + positionId: "position-1", + }); + + expect(readCachedMyScheduledPlans("plan-1,plan-2")).toBeUndefined(); + expect( + readCachedPeople("service-type-1", "team-1", "position-1", "plan-1", dateKey) + ).toBeUndefined(); + expect(readCachedTeamPositions("service-type-1", "plan-1", "series-1")).toBeUndefined(); + }); +}); diff --git a/lib/schedule-catalog-cache.test.ts b/lib/schedule-catalog-cache.test.ts new file mode 100644 index 0000000..c466ecc --- /dev/null +++ b/lib/schedule-catalog-cache.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedScheduleCatalog, + readCachedPlans, + readCachedPlansEntry, + readCachedServiceTypes, + readCachedServiceTypesEntry, + writeCachedPlans, + writeCachedServiceTypes, +} from "@/lib/schedule-catalog-cache"; +import type { Plan, ServiceType } from "@/lib/types"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +function serviceTypes(): ServiceType[] { + return [ + { id: "st-1", name: "Agape Worship Services", sequence: 1 }, + { id: "st-2", name: "Children's Ministry", sequence: 2 }, + ]; +} + +function plans(): Plan[] { + return [ + { + id: "plan-1", + title: "May 31", + seriesTitle: undefined, + seriesId: "series-1", + planningCenterUrl: "https://example.com/plan-1", + createdAt: new Date("2026-05-01T12:00:00.000Z"), + sortDate: new Date("2026-05-31T16:00:00.000Z"), + }, + ]; +} + +describe("schedule catalog cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("round-trips service types", () => { + const savedAt = new Date("2026-05-23T12:00:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedServiceTypes(serviceTypes()); + + expect(readCachedServiceTypes()).toEqual(serviceTypes()); + expect(readCachedServiceTypesEntry()?.savedAt).toBe(savedAt); + }); + + it("round-trips plans and restores date fields", () => { + const savedAt = new Date("2026-05-23T12:05:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedPlans("st-1", plans()); + + const cached = readCachedPlans("st-1"); + const cachedEntry = readCachedPlansEntry("st-1"); + + expect(cachedEntry?.savedAt).toBe(savedAt); + expect(cached?.[0].id).toBe("plan-1"); + expect(cached?.[0].createdAt).toBeInstanceOf(Date); + expect(cached?.[0].sortDate).toBeInstanceOf(Date); + expect(cached?.[0].sortDate?.toISOString()).toBe("2026-05-31T16:00:00.000Z"); + }); + + it("does not read plans without a service type id", () => { + expect(readCachedPlans(null)).toBeUndefined(); + }); + + it("clears schedule catalog snapshots without touching unrelated storage", () => { + writeCachedServiceTypes(serviceTypes()); + writeCachedPlans("st-1", plans()); + window.localStorage.setItem("unrelated", "keep"); + + clearCachedScheduleCatalog(); + + expect(readCachedServiceTypes()).toBeUndefined(); + expect(readCachedPlans("st-1")).toBeUndefined(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); +}); diff --git a/lib/schedule-catalog-cache.ts b/lib/schedule-catalog-cache.ts new file mode 100644 index 0000000..2e56c15 --- /dev/null +++ b/lib/schedule-catalog-cache.ts @@ -0,0 +1,149 @@ +import type { Plan, ServiceType } from "@/lib/types"; + +const CACHE_VERSION = "v1"; +const CACHE_KEY_PREFIX = `worshipadmin:schedule-catalog:${CACHE_VERSION}:`; +const SERVICE_TYPES_KEY = `${CACHE_KEY_PREFIX}service-types`; +const PLANS_KEY_PREFIX = `${CACHE_KEY_PREFIX}plans:`; + +interface CachedPayload { + savedAt: number; + data: T; +} + +export interface ScheduleCatalogCacheEntry { + savedAt: number; + data: T; +} + +export function readCachedServiceTypes(): ServiceType[] | undefined { + return readCachedServiceTypesEntry()?.data; +} + +export function readCachedServiceTypesEntry(): ScheduleCatalogCacheEntry | undefined { + return readCache(SERVICE_TYPES_KEY, isServiceTypeArray); +} + +export function writeCachedServiceTypes(serviceTypes: ServiceType[]) { + writeCache(SERVICE_TYPES_KEY, serviceTypes); +} + +export function readCachedPlans(serviceTypeId: string | null): Plan[] | undefined { + return readCachedPlansEntry(serviceTypeId)?.data; +} + +export function readCachedPlansEntry( + serviceTypeId: string | null +): ScheduleCatalogCacheEntry | undefined { + if (!serviceTypeId) return undefined; + const cached = readCache( + buildPlansKey(serviceTypeId), + isPlanArray + ); + if (!cached) return undefined; + + return { + savedAt: cached.savedAt, + data: cached.data.map((plan) => ({ + ...plan, + createdAt: new Date(plan.createdAt), + sortDate: plan.sortDate ? new Date(plan.sortDate) : undefined, + })), + }; +} + +export function writeCachedPlans(serviceTypeId: string | null, plans: Plan[]) { + if (!serviceTypeId) return; + writeCache(buildPlansKey(serviceTypeId), plans); +} + +export function clearCachedScheduleCatalog() { + if (typeof window === "undefined") return; + + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(CACHE_KEY_PREFIX)) { + window.localStorage.removeItem(key); + } + } + } catch { + // Ignore storage failures; query invalidation still refreshes live data. + } +} + +function buildPlansKey(serviceTypeId: string) { + return `${PLANS_KEY_PREFIX}${encodeURIComponent(serviceTypeId)}`; +} + +function readCache( + key: string, + validate: (value: unknown) => value is T +): ScheduleCatalogCacheEntry | undefined { + if (typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(key); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial>; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!validate(parsed.data)) return undefined; + return { + savedAt: parsed.savedAt, + data: parsed.data, + }; + } catch { + return undefined; + } +} + +function writeCache(key: string, data: T) { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem( + key, + JSON.stringify({ + savedAt: Date.now(), + data, + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +function isServiceTypeArray(value: unknown): value is ServiceType[] { + return Array.isArray(value) && + value.every((item) => { + if (!item || typeof item !== "object") return false; + const candidate = item as Partial; + return typeof candidate.id === "string" && + typeof candidate.name === "string" && + typeof candidate.sequence === "number"; + }); +} + +function isPlanArray(value: unknown): value is Plan[] { + return Array.isArray(value) && + value.every((item) => { + if (!item || typeof item !== "object") return false; + const candidate = item as Partial>; + return typeof candidate.id === "string" && + typeof candidate.title === "string" && + isOptionalString(candidate.seriesTitle) && + isOptionalString(candidate.seriesId) && + isOptionalString(candidate.planningCenterUrl) && + isDateLike(candidate.createdAt) && + (candidate.sortDate === undefined || isDateLike(candidate.sortDate)); + }); +} + +function isOptionalString(value: unknown) { + return value === undefined || value === null || typeof value === "string"; +} + +function isDateLike(value: unknown) { + if (typeof value !== "string" && !(value instanceof Date)) return false; + return !Number.isNaN(new Date(value).getTime()); +} diff --git a/lib/song-options-cache.test.ts b/lib/song-options-cache.test.ts new file mode 100644 index 0000000..4c81fc4 --- /dev/null +++ b/lib/song-options-cache.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedSongOptions, + readCachedSongOptions, + writeCachedSongOptions, +} from "@/lib/song-options-cache"; +import type { SongOptionSet } from "@/lib/types"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +function optionSet(): SongOptionSet { + return { + song: { + id: "song-1", + title: "Build My Life", + author: "Pat Barrett", + themes: "Worship", + hidden: false, + lastScheduledAt: new Date("2026-02-15T00:00:00.000Z"), + }, + arrangements: [ + { + id: "arrangement-1", + name: "Default", + sequence: ["Verse", "Chorus"], + length: 300, + archived: false, + keys: [ + { + id: "key-1", + name: "A", + startingKey: "A", + endingKey: "A", + }, + ], + }, + ], + layouts: [], + currentLayout: null, + suggestedArrangementId: "arrangement-1", + suggestedKeyId: "key-1", + suggestedLayoutId: null, + layoutMode: "unavailable", + }; +} + +describe("song options cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("round-trips song options and restores date fields", () => { + const savedAt = new Date("2026-05-23T19:00:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedSongOptions("song-1", "st-1", optionSet()); + + const cached = readCachedSongOptions("song-1", "st-1"); + + expect(cached?.savedAt).toBe(savedAt); + expect(cached?.data.song.title).toBe("Build My Life"); + expect(cached?.data.song.lastScheduledAt).toBeInstanceOf(Date); + expect(cached?.data.song.lastScheduledAt?.toISOString()).toBe("2026-02-15T00:00:00.000Z"); + expect(cached?.data.arrangements[0].keys[0].name).toBe("A"); + }); + + it("does not read a different service type or song snapshot", () => { + writeCachedSongOptions("song-1", "st-1", optionSet()); + + expect(readCachedSongOptions("song-2", "st-1")).toBeUndefined(); + expect(readCachedSongOptions("song-1", "st-2")).toBeUndefined(); + }); + + it("ignores invalid cache payloads", () => { + window.localStorage.setItem( + "worshipadmin:song-options:v1:st-1:song-1", + JSON.stringify({ savedAt: Date.now(), data: { song: { id: "song-1" } } }) + ); + + expect(readCachedSongOptions("song-1", "st-1")).toBeUndefined(); + }); + + it("clears song option snapshots without touching unrelated storage", () => { + writeCachedSongOptions("song-1", "st-1", optionSet()); + writeCachedSongOptions("song-2", "st-1", optionSet()); + window.localStorage.setItem("unrelated", "keep"); + + clearCachedSongOptions(); + + expect(readCachedSongOptions("song-1", "st-1")).toBeUndefined(); + expect(readCachedSongOptions("song-2", "st-1")).toBeUndefined(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); +}); diff --git a/lib/song-options-cache.ts b/lib/song-options-cache.ts new file mode 100644 index 0000000..4f9da46 --- /dev/null +++ b/lib/song-options-cache.ts @@ -0,0 +1,168 @@ +import { + hydrateSongOptionSet, + type SerializedSongCatalogEntry, + type SerializedSongOptionSet, +} from "@/lib/song-catalog-client"; +import type { ArrangementOption, KeyOption, LayoutOption, SongOptionSet } from "@/lib/types"; + +const CACHE_VERSION = "v1"; +const CACHE_KEY_PREFIX = `worshipadmin:song-options:${CACHE_VERSION}:`; + +interface CachedPayload { + savedAt: number; + data: SerializedSongOptionSet; +} + +export interface SongOptionsCacheEntry { + savedAt: number; + data: SongOptionSet; +} + +export function readCachedSongOptions( + songId: string | null, + serviceTypeId: string | null +): SongOptionsCacheEntry | undefined { + if (!songId || !serviceTypeId || typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(buildCacheKey(songId, serviceTypeId)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isSerializedSongOptionSet(parsed.data)) return undefined; + + return { + savedAt: parsed.savedAt, + data: hydrateSongOptionSet(parsed.data), + }; + } catch { + return undefined; + } +} + +export function writeCachedSongOptions( + songId: string | null, + serviceTypeId: string | null, + optionSet: SongOptionSet +) { + if (!songId || !serviceTypeId || typeof window === "undefined") return; + + try { + window.localStorage.setItem( + buildCacheKey(songId, serviceTypeId), + JSON.stringify({ + savedAt: Date.now(), + data: serializeSongOptionSet(optionSet), + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function clearCachedSongOptions() { + if (typeof window === "undefined") return; + + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(CACHE_KEY_PREFIX)) { + window.localStorage.removeItem(key); + } + } + } catch { + // Ignore storage failures; live queries will still fetch Planning Center. + } +} + +function buildCacheKey(songId: string, serviceTypeId: string) { + return `${CACHE_KEY_PREFIX}${encodeURIComponent(serviceTypeId)}:${encodeURIComponent(songId)}`; +} + +function serializeSongOptionSet(optionSet: SongOptionSet): SerializedSongOptionSet { + return { + ...optionSet, + song: { + ...optionSet.song, + lastScheduledAt: optionSet.song.lastScheduledAt + ? optionSet.song.lastScheduledAt.toISOString() + : null, + }, + }; +} + +function isSerializedSongOptionSet(value: unknown): value is SerializedSongOptionSet { + if (!value || typeof value !== "object") return false; + const optionSet = value as Partial; + + return isSerializedSongCatalogEntry(optionSet.song) && + Array.isArray(optionSet.arrangements) && + optionSet.arrangements.every(isArrangementOption) && + Array.isArray(optionSet.layouts) && + optionSet.layouts.every(isLayoutOption) && + (optionSet.currentLayout === null || isLayoutOption(optionSet.currentLayout)) && + isNullableString(optionSet.suggestedArrangementId) && + isNullableString(optionSet.suggestedKeyId) && + isNullableString(optionSet.suggestedLayoutId) && + ( + optionSet.layoutMode === "unavailable" || + optionSet.layoutMode === "existing-only" || + optionSet.layoutMode === "editable" + ); +} + +function isSerializedSongCatalogEntry(value: unknown): value is SerializedSongCatalogEntry { + if (!value || typeof value !== "object") return false; + const entry = value as Partial; + + return typeof entry.id === "string" && + typeof entry.title === "string" && + typeof entry.author === "string" && + typeof entry.themes === "string" && + typeof entry.hidden === "boolean" && + isNullableDateLike(entry.lastScheduledAt) && + (entry.matchScore === undefined || typeof entry.matchScore === "number"); +} + +function isArrangementOption(value: unknown): value is ArrangementOption { + if (!value || typeof value !== "object") return false; + const arrangement = value as Partial; + + return typeof arrangement.id === "string" && + typeof arrangement.name === "string" && + Array.isArray(arrangement.sequence) && + arrangement.sequence.every((line) => typeof line === "string") && + (arrangement.length === null || typeof arrangement.length === "number") && + typeof arrangement.archived === "boolean" && + Array.isArray(arrangement.keys) && + arrangement.keys.every(isKeyOption); +} + +function isKeyOption(value: unknown): value is KeyOption { + if (!value || typeof value !== "object") return false; + const key = value as Partial; + + return typeof key.id === "string" && + typeof key.name === "string" && + isNullableString(key.startingKey) && + isNullableString(key.endingKey); +} + +function isLayoutOption(value: unknown): value is LayoutOption { + if (!value || typeof value !== "object") return false; + const layout = value as Partial; + + return typeof layout.id === "string" && + typeof layout.name === "string"; +} + +function isNullableString(value: unknown) { + return value === null || typeof value === "string"; +} + +function isNullableDateLike(value: unknown) { + if (value === null) return true; + if (typeof value !== "string" && !(value instanceof Date)) return false; + return !Number.isNaN(new Date(value).getTime()); +} diff --git a/lib/song-search-cache.test.ts b/lib/song-search-cache.test.ts new file mode 100644 index 0000000..9151220 --- /dev/null +++ b/lib/song-search-cache.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedSongSearch, + normalizeSongSearchQuery, + readCachedSongSearch, + writeCachedSongSearch, +} from "@/lib/song-search-cache"; +import type { SongCatalogEntry } from "@/lib/types"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +function songs(): SongCatalogEntry[] { + return [ + { + id: "song-1", + title: "Build My Life", + author: "Pat Barrett", + themes: "Adoration, Worship", + hidden: false, + lastScheduledAt: new Date("2026-02-15T00:00:00.000Z"), + matchScore: 120, + }, + ]; +} + +describe("song search cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("normalizes query casing and whitespace", () => { + expect(normalizeSongSearchQuery(" Build My Life ")).toBe("build my life"); + }); + + it("round-trips song results and restores date fields", () => { + const savedAt = new Date("2026-05-23T16:00:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedSongSearch("st-1", "Build My Life", songs()); + + const cached = readCachedSongSearch("st-1", " build my life "); + + expect(cached?.savedAt).toBe(savedAt); + expect(cached?.data[0].title).toBe("Build My Life"); + expect(cached?.data[0].lastScheduledAt).toBeInstanceOf(Date); + expect(cached?.data[0].lastScheduledAt?.toISOString()).toBe("2026-02-15T00:00:00.000Z"); + }); + + it("does not read a different service type or query snapshot", () => { + writeCachedSongSearch("st-1", "build", songs()); + + expect(readCachedSongSearch("st-2", "build")).toBeUndefined(); + expect(readCachedSongSearch("st-1", "life")).toBeUndefined(); + }); + + it("ignores invalid cache payloads", () => { + window.localStorage.setItem( + "worshipadmin:song-search:v1:st-1:build", + JSON.stringify({ savedAt: Date.now(), data: [{ id: "song-1" }] }) + ); + + expect(readCachedSongSearch("st-1", "build")).toBeUndefined(); + }); + + it("clears song search snapshots without touching unrelated storage", () => { + writeCachedSongSearch("st-1", "build", songs()); + writeCachedSongSearch("st-1", "life", songs()); + window.localStorage.setItem("unrelated", "keep"); + + clearCachedSongSearch(); + + expect(readCachedSongSearch("st-1", "build")).toBeUndefined(); + expect(readCachedSongSearch("st-1", "life")).toBeUndefined(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); +}); diff --git a/lib/song-search-cache.ts b/lib/song-search-cache.ts new file mode 100644 index 0000000..bf98c10 --- /dev/null +++ b/lib/song-search-cache.ts @@ -0,0 +1,116 @@ +import { + hydrateSongCatalogEntry, + type SerializedSongCatalogEntry, +} from "@/lib/song-catalog-client"; +import type { SongCatalogEntry } from "@/lib/types"; + +const CACHE_VERSION = "v1"; +const CACHE_KEY_PREFIX = `worshipadmin:song-search:${CACHE_VERSION}:`; + +interface CachedPayload { + savedAt: number; + data: SerializedSongCatalogEntry[]; +} + +export interface SongSearchCacheEntry { + savedAt: number; + data: SongCatalogEntry[]; +} + +export function readCachedSongSearch( + serviceTypeId: string | null, + query: string +): SongSearchCacheEntry | undefined { + const normalizedQuery = normalizeSongSearchQuery(query); + if (!serviceTypeId || !normalizedQuery || typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(buildCacheKey(serviceTypeId, normalizedQuery)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isSerializedSongCatalogEntryArray(parsed.data)) return undefined; + + return { + savedAt: parsed.savedAt, + data: parsed.data.map(hydrateSongCatalogEntry), + }; + } catch { + return undefined; + } +} + +export function writeCachedSongSearch( + serviceTypeId: string | null, + query: string, + songs: SongCatalogEntry[] +) { + const normalizedQuery = normalizeSongSearchQuery(query); + if (!serviceTypeId || !normalizedQuery || typeof window === "undefined") return; + + try { + window.localStorage.setItem( + buildCacheKey(serviceTypeId, normalizedQuery), + JSON.stringify({ + savedAt: Date.now(), + data: songs.map(serializeSongCatalogEntry), + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function clearCachedSongSearch() { + if (typeof window === "undefined") return; + + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(CACHE_KEY_PREFIX)) { + window.localStorage.removeItem(key); + } + } + } catch { + // Ignore storage failures; live search will still query Planning Center. + } +} + +export function normalizeSongSearchQuery(query: string) { + return query.trim().toLowerCase(); +} + +function buildCacheKey(serviceTypeId: string, query: string) { + return `${CACHE_KEY_PREFIX}${encodeURIComponent(serviceTypeId)}:${encodeURIComponent(query)}`; +} + +function serializeSongCatalogEntry(entry: SongCatalogEntry): SerializedSongCatalogEntry { + return { + ...entry, + lastScheduledAt: entry.lastScheduledAt ? entry.lastScheduledAt.toISOString() : null, + }; +} + +function isSerializedSongCatalogEntryArray(value: unknown): value is SerializedSongCatalogEntry[] { + return Array.isArray(value) && value.every(isSerializedSongCatalogEntry); +} + +function isSerializedSongCatalogEntry(value: unknown): value is SerializedSongCatalogEntry { + if (!value || typeof value !== "object") return false; + const entry = value as Partial; + + return typeof entry.id === "string" && + typeof entry.title === "string" && + typeof entry.author === "string" && + typeof entry.themes === "string" && + typeof entry.hidden === "boolean" && + isNullableDateLike(entry.lastScheduledAt) && + (entry.matchScore === undefined || typeof entry.matchScore === "number"); +} + +function isNullableDateLike(value: unknown) { + if (value === null) return true; + if (typeof value !== "string" && !(value instanceof Date)) return false; + return !Number.isNaN(new Date(value).getTime()); +} diff --git a/lib/team-positions-cache.test.ts b/lib/team-positions-cache.test.ts new file mode 100644 index 0000000..658794a --- /dev/null +++ b/lib/team-positions-cache.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCachedTeamPositions, + readCachedTeamPositions, + writeCachedTeamPositions, +} from "@/lib/team-positions-cache"; +import type { TeamPositionGroup } from "@/lib/types"; + +function installLocalStorageMock() { + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + get length() { + return storage.size; + }, + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => [...storage.keys()][index] ?? null, + removeItem: (key: string) => { + storage.delete(key); + }, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }); +} + +function teamPositionGroups(): TeamPositionGroup[] { + return [ + { + teamId: "team-1", + teamName: "Vocals", + positions: [ + { + id: "position-1", + name: "Vocal", + teamId: "team-1", + teamName: "Vocals", + neededCount: 2, + filledPendingCount: 1, + filledConfirmedCount: 1, + filledPeople: [ + { + id: "person-1", + planPersonId: "plan-person-1", + name: "Andrew Hinea", + status: "confirmed", + rawStatus: "C", + photoThumbnailUrl: null, + }, + { + id: "person-2", + planPersonId: "plan-person-2", + name: "Mina Lee", + status: "pending", + rawStatus: "U", + photoThumbnailUrl: "https://example.com/person-2.jpg", + }, + ], + }, + ], + }, + ]; +} + +describe("team positions cache", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + installLocalStorageMock(); + }); + + it("round-trips team position groups with the saved timestamp", () => { + const savedAt = new Date("2026-05-23T14:00:00.000Z").getTime(); + vi.spyOn(Date, "now").mockReturnValue(savedAt); + + writeCachedTeamPositions("st-1", "plan-1", "series-1", teamPositionGroups()); + + const cached = readCachedTeamPositions("st-1", "plan-1", "series-1"); + + expect(cached).toEqual({ + savedAt, + data: teamPositionGroups(), + }); + }); + + it("does not read a different plan or series snapshot", () => { + writeCachedTeamPositions("st-1", "plan-1", "series-1", teamPositionGroups()); + + expect(readCachedTeamPositions("st-1", "plan-2", "series-1")).toBeUndefined(); + expect(readCachedTeamPositions("st-1", "plan-1", null)).toBeUndefined(); + expect(readCachedTeamPositions("st-2", "plan-1", "series-1")).toBeUndefined(); + }); + + it("ignores invalid cache payloads", () => { + window.localStorage.setItem( + "worshipadmin:team-positions:v1:st-1:plan-1:series-1", + JSON.stringify({ savedAt: Date.now(), data: [{ teamId: "team-1" }] }) + ); + + expect(readCachedTeamPositions("st-1", "plan-1", "series-1")).toBeUndefined(); + }); + + it("clears team-position snapshots without touching unrelated storage", () => { + writeCachedTeamPositions("st-1", "plan-1", "series-1", teamPositionGroups()); + writeCachedTeamPositions("st-1", "plan-2", null, teamPositionGroups()); + window.localStorage.setItem("unrelated", "keep"); + + clearCachedTeamPositions(); + + expect(readCachedTeamPositions("st-1", "plan-1", "series-1")).toBeUndefined(); + expect(readCachedTeamPositions("st-1", "plan-2", null)).toBeUndefined(); + expect(window.localStorage.getItem("unrelated")).toBe("keep"); + }); +}); diff --git a/lib/team-positions-cache.ts b/lib/team-positions-cache.ts new file mode 100644 index 0000000..ac88b0b --- /dev/null +++ b/lib/team-positions-cache.ts @@ -0,0 +1,137 @@ +import type { FilledPositionPerson, TeamPosition, TeamPositionGroup } from "@/lib/types"; + +const CACHE_VERSION = "v1"; +const CACHE_KEY_PREFIX = `worshipadmin:team-positions:${CACHE_VERSION}:`; + +interface CachedPayload { + savedAt: number; + data: TeamPositionGroup[]; +} + +export interface TeamPositionsCacheEntry { + savedAt: number; + data: TeamPositionGroup[]; +} + +export function readCachedTeamPositions( + serviceTypeId: string | null, + planId: string | null, + seriesId: string | null +): TeamPositionsCacheEntry | undefined { + if (!serviceTypeId || !planId || typeof window === "undefined") return undefined; + + try { + const raw = window.localStorage.getItem(buildCacheKey(serviceTypeId, planId, seriesId)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== "object") return undefined; + if (typeof parsed.savedAt !== "number") return undefined; + if (!isTeamPositionGroupArray(parsed.data)) return undefined; + + return { + savedAt: parsed.savedAt, + data: parsed.data, + }; + } catch { + return undefined; + } +} + +export function writeCachedTeamPositions( + serviceTypeId: string | null, + planId: string | null, + seriesId: string | null, + groups: TeamPositionGroup[] +) { + if (!serviceTypeId || !planId || typeof window === "undefined") return; + + try { + window.localStorage.setItem( + buildCacheKey(serviceTypeId, planId, seriesId), + JSON.stringify({ + savedAt: Date.now(), + data: groups, + } satisfies CachedPayload) + ); + } catch { + // Ignore storage write failures (private mode/quota). + } +} + +export function clearCachedTeamPositions() { + if (typeof window === "undefined") return; + + try { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(CACHE_KEY_PREFIX)) { + window.localStorage.removeItem(key); + } + } + } catch { + // Ignore storage failures; live queries will still fetch from Planning Center. + } +} + +function buildCacheKey( + serviceTypeId: string, + planId: string, + seriesId: string | null +) { + return [ + CACHE_KEY_PREFIX, + encodeURIComponent(serviceTypeId), + ":", + encodeURIComponent(planId), + ":", + encodeURIComponent(seriesId ?? "none"), + ].join(""); +} + +function isTeamPositionGroupArray(value: unknown): value is TeamPositionGroup[] { + return Array.isArray(value) && value.every(isTeamPositionGroup); +} + +function isTeamPositionGroup(value: unknown): value is TeamPositionGroup { + if (!value || typeof value !== "object") return false; + const group = value as Partial; + return typeof group.teamId === "string" && + typeof group.teamName === "string" && + Array.isArray(group.positions) && + group.positions.every(isTeamPosition); +} + +function isTeamPosition(value: unknown): value is TeamPosition { + if (!value || typeof value !== "object") return false; + const position = value as Partial; + return typeof position.id === "string" && + typeof position.name === "string" && + typeof position.teamId === "string" && + isOptionalString(position.teamName) && + isOptionalNumber(position.neededCount) && + isOptionalNumber(position.filledPendingCount) && + isOptionalNumber(position.filledConfirmedCount) && + ( + position.filledPeople === undefined || + (Array.isArray(position.filledPeople) && position.filledPeople.every(isFilledPositionPerson)) + ); +} + +function isFilledPositionPerson(value: unknown): value is FilledPositionPerson { + if (!value || typeof value !== "object") return false; + const person = value as Partial; + return typeof person.id === "string" && + typeof person.planPersonId === "string" && + typeof person.name === "string" && + (person.status === "pending" || person.status === "confirmed") && + typeof person.rawStatus === "string" && + isOptionalString(person.photoThumbnailUrl); +} + +function isOptionalString(value: unknown) { + return value === undefined || value === null || typeof value === "string"; +} + +function isOptionalNumber(value: unknown) { + return value === undefined || typeof value === "number"; +} diff --git a/lib/use-cases/planning-center/get-people-dashboard.test.ts b/lib/use-cases/planning-center/get-people-dashboard.test.ts new file mode 100644 index 0000000..2fc0dcb --- /dev/null +++ b/lib/use-cases/planning-center/get-people-dashboard.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getPeopleDashboard } from "@/lib/use-cases/planning-center/get-people-dashboard"; +import type { PCResource } from "@/lib/types"; + +vi.mock("@/lib/planning-center/resolve-organization-timezone", () => ({ + resolveOrganizationTimeZone: vi.fn(() => Promise.resolve("UTC")), +})); + +function person(id: string, firstName: string, lastName: string): PCResource { + return { + id, + type: "Person", + attributes: { + first_name: firstName, + last_name: lastName, + }, + }; +} + +function schedule(id: string, startsAt: string): PCResource { + return { + id, + type: "Schedule", + attributes: { + sort_date: startsAt, + status: "C", + team_position_name: "Vocals", + service_type_name: "Sunday", + }, + }; +} + +describe("getPeopleDashboard", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("hydrates a bounded roster sample for the initial dashboard response", async () => { + vi.useFakeTimers({ now: new Date("2026-05-23T12:00:00.000Z") }); + const getAllPeopleFromTeams = vi.fn().mockResolvedValue({ + people: [ + person("person-3", "Casey", "Carter"), + person("person-1", "Alex", "Adams"), + person("person-2", "Blair", "Baker"), + ], + included: [], + teamNamesByPersonId: new Map([ + ["person-1", new Set(["Band"])], + ["person-2", new Set(["Band"])], + ["person-3", new Set(["Band"])], + ]), + }); + const getPersonSchedules = vi.fn(async (personId: string) => ({ + data: personId === "person-1" + ? [schedule("schedule-1", "2026-05-31T17:00:00.000Z")] + : [], + included: [], + })); + + const dashboard = await getPeopleDashboard({ + maxHydratedPeople: 2, + peopleService: { + getAllPeopleFromTeams, + getPersonSchedules, + }, + }); + + expect(getPersonSchedules).toHaveBeenCalledTimes(2); + expect(getPersonSchedules.mock.calls.map(([personId]) => personId)).toEqual([ + "person-1", + "person-2", + ]); + expect(dashboard.people.map((person) => person.id)).toEqual([ + "person-1", + "person-2", + ]); + expect(dashboard.requestBudget).toMatchObject({ + rosterPeopleCount: 3, + hydratedPeopleCount: 2, + scheduleRequests: 2, + sampled: true, + }); + expect(dashboard.stats.scheduledPeople).toBe(1); + }); +}); diff --git a/lib/use-cases/planning-center/get-people-dashboard.ts b/lib/use-cases/planning-center/get-people-dashboard.ts index a42ce1e..68b3955 100644 --- a/lib/use-cases/planning-center/get-people-dashboard.ts +++ b/lib/use-cases/planning-center/get-people-dashboard.ts @@ -18,8 +18,9 @@ import { mapWithConcurrency } from "@/lib/use-cases/planning-center/shared"; const SCHEDULE_CONCURRENCY = 4; const SCHEDULE_MAX_PAGES = 6; +const PEOPLE_DASHBOARD_HYDRATION_LIMIT = 48; const PEOPLE_DASHBOARD_CACHE_TTL_MS = 2 * 60 * 1000; -const PEOPLE_DASHBOARD_CACHE_VERSION = "v4"; +const PEOPLE_DASHBOARD_CACHE_VERSION = "v5"; const peopleDashboardCache = new PlanningCenterReadCache(); interface RosterPerson { @@ -48,30 +49,40 @@ export interface ScheduleItem { export async function getPeopleDashboard({ range = "month", peopleService = planningCenterPeopleService, + maxHydratedPeople = PEOPLE_DASHBOARD_HYDRATION_LIMIT, }: { range?: PeopleDashboardRange; peopleService?: Pick< PlanningCenterPeopleService, "getAllPeopleFromTeams" | "getPersonSchedules" >; + maxHydratedPeople?: number; } = {}): Promise { if (peopleService === planningCenterPeopleService) { return peopleDashboardCache.get( - `${PEOPLE_DASHBOARD_CACHE_VERSION}:people-dashboard:${range}`, + [ + planningCenterPeopleService.getCacheScope(), + PEOPLE_DASHBOARD_CACHE_VERSION, + "people-dashboard", + range, + `limit:${normalizeHydrationLimit(maxHydratedPeople)}`, + ].join(":"), PEOPLE_DASHBOARD_CACHE_TTL_MS, - () => buildPeopleDashboard({ range, peopleService }) + () => buildPeopleDashboard({ range, peopleService, maxHydratedPeople }) ); } - return buildPeopleDashboard({ range, peopleService }); + return buildPeopleDashboard({ range, peopleService, maxHydratedPeople }); } async function buildPeopleDashboard({ range, peopleService, + maxHydratedPeople, }: { range: PeopleDashboardRange; peopleService: Pick; + maxHydratedPeople: number; }): Promise { const orgTimeZone = await resolveOrganizationTimeZone(); const now = new Date(); @@ -82,7 +93,8 @@ async function buildPeopleDashboard({ rosterResponse.included, rosterResponse.teamNamesByPersonId ); - const hydratedRoster = rosterPeople; + const hydrationLimit = normalizeHydrationLimit(maxHydratedPeople); + const hydratedRoster = rosterPeople.slice(0, hydrationLimit); const hydratedPeople = await mapWithConcurrency( hydratedRoster, @@ -121,11 +133,16 @@ async function buildPeopleDashboard({ blockoutRequests: 0, rosterPeopleCount: rosterPeople.length, hydratedPeopleCount: hydratedPeople.length, - sampled: false, + sampled: hydratedPeople.length < rosterPeople.length, }, }; } +function normalizeHydrationLimit(limit: number) { + if (!Number.isFinite(limit)) return PEOPLE_DASHBOARD_HYDRATION_LIMIT; + return Math.max(0, Math.floor(limit)); +} + function buildRosterPeople( people: PCResource[], included: PCResource[], diff --git a/lib/use-cases/planning-center/get-service-types.test.ts b/lib/use-cases/planning-center/get-service-types.test.ts index 2da1d58..09f531f 100644 --- a/lib/use-cases/planning-center/get-service-types.test.ts +++ b/lib/use-cases/planning-center/get-service-types.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import { getServiceTypes } from "@/lib/use-cases/planning-center/get-service-types"; -const { getServiceTypesMock } = vi.hoisted(() => ({ - getServiceTypesMock: vi.fn(), +const { getServiceTypesCachedMock } = vi.hoisted(() => ({ + getServiceTypesCachedMock: vi.fn(), })); vi.mock("@/lib/planning-center/services/catalog-service", () => ({ planningCenterCatalogService: { - getServiceTypes: getServiceTypesMock, + getServiceTypesCached: getServiceTypesCachedMock, }, })); describe("getServiceTypes", () => { it("filters archived service types and sorts by sequence", async () => { - getServiceTypesMock.mockResolvedValue([ + getServiceTypesCachedMock.mockResolvedValue([ { id: "st-excluded", type: "ServiceType", diff --git a/lib/use-cases/planning-center/get-service-types.ts b/lib/use-cases/planning-center/get-service-types.ts index 493bb94..7552b45 100644 --- a/lib/use-cases/planning-center/get-service-types.ts +++ b/lib/use-cases/planning-center/get-service-types.ts @@ -5,9 +5,9 @@ import { import type { RawServiceType, ServiceType } from "@/lib/types"; export async function getServiceTypes( - catalogService: Pick = planningCenterCatalogService + catalogService: Pick = planningCenterCatalogService ): Promise { - const rawServiceTypes = await catalogService.getServiceTypes(); + const rawServiceTypes = await catalogService.getServiceTypesCached(); const activeRawServiceTypes = rawServiceTypes.filter((raw) => { const archivedAt = (raw.attributes.archived_at as string | null) || null; return !archivedAt; diff --git a/lib/use-cases/planning-center/plan-item-payload.test.ts b/lib/use-cases/planning-center/plan-item-payload.test.ts index 5aa5a1b..e6e80b6 100644 --- a/lib/use-cases/planning-center/plan-item-payload.test.ts +++ b/lib/use-cases/planning-center/plan-item-payload.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildPlanItemAttributes, resolvePlanItemSongDefaults, @@ -13,6 +13,10 @@ vi.mock("@/lib/use-cases/planning-center/get-song-options", () => ({ })); describe("plan item payload helpers", () => { + beforeEach(() => { + getSongOptionsMock.mockReset(); + }); + it("backfills song defaults and builds a trimmed payload", async () => { getSongOptionsMock.mockResolvedValue({ song: { @@ -62,6 +66,25 @@ describe("plan item payload helpers", () => { }); }); + it("does not fetch song defaults when the client already supplied them", async () => { + const resolved = await resolvePlanItemSongDefaults({ + serviceTypeId: "service-1", + songId: "song-1", + title: "Build My Life", + arrangementId: "arr-1", + keyId: "key-1", + selectedLayoutId: "layout-1", + }); + + expect(getSongOptionsMock).not.toHaveBeenCalled(); + expect(resolved).toMatchObject({ + title: "Build My Life", + arrangementId: "arr-1", + keyId: "key-1", + selectedLayoutId: "layout-1", + }); + }); + it("encodes header items explicitly and generic items implicitly", () => { expect( buildPlanItemAttributes({ diff --git a/lib/use-cases/planning-center/search-songs.test.ts b/lib/use-cases/planning-center/search-songs.test.ts index 9f3d936..0e4ab35 100644 --- a/lib/use-cases/planning-center/search-songs.test.ts +++ b/lib/use-cases/planning-center/search-songs.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { searchSongs } from "@/lib/use-cases/planning-center/search-songs"; const { getSongsCatalogCachedMock } = vi.hoisted(() => ({ @@ -12,6 +12,10 @@ vi.mock("@/lib/planning-center/services/songs-service", () => ({ })); describe("searchSongs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("keeps fuzzy relevance first and orders ties by title", async () => { getSongsCatalogCachedMock.mockResolvedValue([ { @@ -60,4 +64,29 @@ describe("searchSongs", () => { expect(songs.some((song) => song.id === "song-3")).toBe(false); expect(getSongsCatalogCachedMock).toHaveBeenCalledWith("account-1:service-1"); }); + + it("caches normalized result sets and returns mutation-safe copies", async () => { + getSongsCatalogCachedMock.mockResolvedValue([ + { + id: "song-1", + type: "Song", + attributes: { + title: "Build My Life", + hidden: false, + }, + }, + ]); + + const first = await searchSongs("account-2", "service-1", " BUILD "); + first[0].title = "Changed locally"; + const second = await searchSongs("account-2", "service-1", "build"); + + expect(getSongsCatalogCachedMock).toHaveBeenCalledTimes(1); + expect(second).toEqual([ + expect.objectContaining({ + id: "song-1", + title: "Build My Life", + }), + ]); + }); }); diff --git a/lib/use-cases/planning-center/search-songs.ts b/lib/use-cases/planning-center/search-songs.ts index f7bc7dd..dfed683 100644 --- a/lib/use-cases/planning-center/search-songs.ts +++ b/lib/use-cases/planning-center/search-songs.ts @@ -6,13 +6,33 @@ import { } from "@/lib/use-cases/planning-center/plan-items-shared"; const MAX_RESULTS = 24; +const SONG_SEARCH_RESULT_CACHE_TTL_MS = 5 * 60 * 1000; + +interface SongSearchResultCacheEntry { + expiresAt: number; + songs: SongCatalogEntry[]; +} + +const songSearchResultCache = new Map(); export async function searchSongs( cacheKey: string, serviceTypeId: string, query: string ): Promise { - if (!query.trim()) return []; + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return []; + + const resultCacheKey = [ + cacheKey, + serviceTypeId, + normalizedQuery, + ].join(":"); + const now = Date.now(); + const cached = songSearchResultCache.get(resultCacheKey); + if (cached && cached.expiresAt > now) { + return structuredClone(cached.songs); + } const catalog = await planningCenterSongsService.getSongsCatalogCached(`${cacheKey}:${serviceTypeId}`); const normalized = catalog @@ -20,7 +40,7 @@ export async function searchSongs( .filter((song) => !song.hidden) .map((song) => ({ ...song, - matchScore: scoreSongSearch(song, query), + matchScore: scoreSongSearch(song, normalizedQuery), })) .filter((song) => (song.matchScore ?? 0) > 0) .toSorted((a, b) => { @@ -30,5 +50,11 @@ export async function searchSongs( return a.title.localeCompare(b.title); }); - return normalized.slice(0, MAX_RESULTS); + const results = normalized.slice(0, MAX_RESULTS); + songSearchResultCache.set(resultCacheKey, { + expiresAt: now + SONG_SEARCH_RESULT_CACHE_TTL_MS, + songs: results, + }); + + return structuredClone(results); }