"use client"; import { VacationStatus, type AllocationLike, type AllocationReadModel, type Assignment, type DemandRequirement, } from "@capakraken/shared"; import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useSession } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { useTimelineSSE } from "~/hooks/useTimelineSSE.js"; import { trpc } from "~/lib/trpc/client.js"; import { readAppPreferences, useAppPreferences } from "~/hooks/useAppPreferences.js"; import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; export type TimelineDisplayMode = "strip" | "bar" | "heatmap"; import { addDays } from "./utils.js"; import { DEFAULT_FILTERS, type TimelineFilters } from "./TimelineFilter.js"; import { DONE_STATUSES } from "./timelineConstants.js"; // ─── Local timeline types ───────────────────────────────────────────────────── // These re-declare the shapes that the original TimelineView used internally. // Kept here so every timeline sub-component can import them from one place. export type TimelineResource = { id: string; displayName: string; eid: string; chapter?: string | null; lcrCents: number; availability?: unknown; }; export type TimelineProject = { id: string; name: string; shortCode: string; status: string; startDate: Date | string; endDate: Date | string; orderType: string; clientId?: string | null; budgetCents?: number; winProbability?: number; color?: string | null; staffingReqs?: unknown; responsiblePerson?: string | null; }; export type TimelineRole = { id: string; name: string; color: string | null; }; export type TimelineAllocation = Omit< AllocationLike, "resource" | "project" | "roleEntity" | "metadata" > & { resource?: TimelineResource | null; project: TimelineProject; roleEntity?: TimelineRole | null; metadata: Record | null; }; export type TimelineAssignmentEntry = Assignment; export type TimelineDemandEntry = DemandRequirement; export type TimelineEntriesView = AllocationReadModel & { assignments: TimelineAssignmentEntry[]; demands: TimelineDemandEntry[]; }; export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry; export type ViewMode = "resource" | "project"; function buildTimelineFiltersFromSearchParams(searchParams: ReturnType): TimelineFilters { const savedPrefs = readAppPreferences(); const next: TimelineFilters = { ...DEFAULT_FILTERS, hideCompletedProjects: savedPrefs.hideCompletedProjects, showPlaceholders: savedPrefs.showDemandProjects, }; const eids = searchParams.get("eids"); if (eids) next.eids = eids.split(",").filter(Boolean); const projectIds = searchParams.get("projectIds"); if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean); const chapters = searchParams.get("chapters"); if (chapters) next.chapters = chapters.split(",").filter(Boolean); const clientIds = searchParams.get("clientIds"); if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean); const countryCodes = searchParams.get("countryCodes"); if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean); if (eids || projectIds) { next.showDrafts = true; next.hideCompletedProjects = false; } return next; } // ─── Derived resource type used throughout the timeline ───────────────────── export type ResourceBrief = { id: string; displayName: string; eid: string; chapter: string | null | undefined; }; // ─── Project group type (project view) ────────────────────────────────────── export type ProjectGroup = { id: string; name: string; shortCode: string; orderType: string; clientId: string | null; startDate: Date; endDate: Date; status: string; color?: string | null; resourceRows: { resource: ResourceBrief; allocs: TimelineAssignmentEntry[] }[]; }; // ─── Vacation entry type (inferred from tRPC) ────────────────────────────── export type VacationEntry = { id: string; resourceId: string; type: string; startDate: Date | string; endDate: Date | string; note?: string | null; status: string; requestedBy?: { name?: string | null; email: string } | null; approvedBy?: { name?: string | null; email: string } | null; approvedAt?: Date | string | null; isHalfDay?: boolean; halfDayPart?: string | null; }; export type HolidayOverlayEntry = { id: string; resourceId: string; type: string; status: string; startDate: Date | string; endDate: Date | string; note?: string | null; }; // ─── Context shape ────────────────────────────────────────────────────────── export interface TimelineContextValue { // ─ Data assignments: TimelineAssignmentEntry[]; demands: TimelineDemandEntry[]; visibleAssignments: TimelineAssignmentEntry[]; visibleDemands: TimelineDemandEntry[]; vacationsByResource: Map; resources: ResourceBrief[]; resourceMap: Map; allocsByResource: Map; projectGroups: ProjectGroup[]; openDemandsByProject: Map; // ─ View state viewStart: Date; viewEnd: Date; viewDays: number; setViewStart: React.Dispatch>; setViewDays: React.Dispatch>; filters: TimelineFilters; setFilters: React.Dispatch>; filterOpen: boolean; setFilterOpen: React.Dispatch>; viewMode: ViewMode; setViewMode: React.Dispatch>; today: Date; // ─ Display preferences displayMode: TimelineDisplayMode; heatmapScheme: HeatmapColorScheme; blinkOverbookedDays: boolean; // ─ Loading isLoading: boolean; isInitialLoading: boolean; totalAllocCount: number; activeFilterCount: number; // ─ SSE is initialized by the provider (no value exposed) } const TimelineContext = createContext(null); export function useTimelineContext(): TimelineContextValue { const ctx = useContext(TimelineContext); if (!ctx) { throw new Error("useTimelineContext must be used within a "); } return ctx; } // ─── Provider ─────────────────────────────────────────────────────────────── interface TimelineProviderProps { /** Cross-project context resource IDs from drag — injected from the parent. */ contextAllocations: TimelineAssignmentEntry[]; isDragging: boolean; children: ReactNode; } export function TimelineProvider({ contextAllocations, isDragging, children, }: TimelineProviderProps) { const { data: session, status: sessionStatus } = useSession(); const searchParams = useSearchParams(); const role = sessionStatus === "authenticated" ? ((session.user as { role?: string } | undefined)?.role ?? "USER") : null; const isSelfServiceTimeline = role === "USER" || role === "VIEWER"; const isRoleLoading = sessionStatus !== "authenticated"; const today = useMemo(() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }, []); // Support URL params: ?startDate=2026-01-01&days=120 const [viewStart, setViewStart] = useState(() => { const sp = searchParams.get("startDate"); if (sp) { const d = new Date(sp); if (!isNaN(d.getTime())) return d; } return addDays(today, -30); }); const [viewDays, setViewDays] = useState(() => { const sp = searchParams.get("days"); if (sp) { const n = parseInt(sp, 10); if (n > 0 && n <= 365) return n; } return 180; }); const viewEnd = addDays(viewStart, viewDays); // Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1 const [filters, setFilters] = useState(() => buildTimelineFiltersFromSearchParams(searchParams)); // Sync filters/viewStart/viewDays from URL params on mount and after later changes // (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline) useEffect(() => { const spStart = searchParams.get("startDate"); setViewStart(() => { if (spStart) { const d = new Date(spStart); if (!isNaN(d.getTime())) return d; } return addDays(today, -30); }); const spDays = searchParams.get("days"); setViewDays(() => { if (spDays) { const n = parseInt(spDays, 10); if (n > 0 && n <= 365) return n; } return 180; }); setFilters(buildTimelineFiltersFromSearchParams(searchParams)); }, [searchParams, today]); const [filterOpen, setFilterOpen] = useState(false); const [viewMode, setViewMode] = useState("resource"); useTimelineSSE(); const { prefs: appPrefs } = useAppPreferences(); const displayMode = appPrefs.timelineDisplayMode; const heatmapScheme = appPrefs.heatmapColorScheme; const blinkOverbookedDays = appPrefs.blinkOverbookedDays; // ─── Data queries ────────────────────────────────────────────────────────── const mountedRef = useRef(false); const timelineQueryInput = { startDate: viewStart, endDate: viewEnd, ...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}), ...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}), ...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}), ...(filters.eids.length > 0 ? { eids: filters.eids } : {}), ...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}), }; const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery( timelineQueryInput, // eslint-disable-next-line @typescript-eslint/no-explicit-any { enabled: !isRoleLoading && !isSelfServiceTimeline, placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any ) as { data: TimelineEntriesView | undefined; isLoading: boolean; refetch: () => Promise; }; const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery( timelineQueryInput, // eslint-disable-next-line @typescript-eslint/no-explicit-any { enabled: !isRoleLoading && isSelfServiceTimeline, placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any ) as { data: TimelineEntriesView | undefined; isLoading: boolean; refetch: () => Promise; }; const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery; const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery; const assignments = entriesView?.assignments ?? []; const demands = entriesView?.demands ?? []; const { data: vacationEntries = [], refetch: refetchVacations, } = trpc.vacation.list.useQuery( { startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 }, { placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 }, ); const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery( timelineQueryInput, { enabled: !isRoleLoading && !isSelfServiceTimeline, placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000, }, ); const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery( timelineQueryInput, { enabled: !isRoleLoading && isSelfServiceTimeline, placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000, }, ); const activeHolidayOverlayQuery = isSelfServiceTimeline ? selfHolidayOverlayQuery : staffHolidayOverlayQuery; const { data: holidayOverlayEntries = [], refetch: refetchHolidayOverlays, } = activeHolidayOverlayQuery; useEffect(() => { if (mountedRef.current) return; if (isRoleLoading) return; mountedRef.current = true; // Harden client-side route transitions: the timeline must actively refresh // its core read models once on mount instead of relying on a prefetched shell. void refetchEntriesView(); void refetchVacations(); void refetchHolidayOverlays(); }, [isRoleLoading, refetchEntriesView, refetchHolidayOverlays, refetchVacations]); const vacationsByResource = useMemo(() => { const map = new Map(); const mergedEntries = [...(vacationEntries as VacationEntry[])]; const existingKeys = new Set( mergedEntries.map((vacation) => { const start = new Date(vacation.startDate).toISOString().slice(0, 10); const end = new Date(vacation.endDate).toISOString().slice(0, 10); return `${vacation.resourceId}:${vacation.type}:${start}:${end}`; }), ); for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) { const start = new Date(holiday.startDate).toISOString().slice(0, 10); const end = new Date(holiday.endDate).toISOString().slice(0, 10); const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`; if (existingKeys.has(key)) { continue; } existingKeys.add(key); mergedEntries.push(holiday as VacationEntry); } for (const vacation of mergedEntries) { const existing = map.get(vacation.resourceId); if (existing) { existing.push(vacation); } else { map.set(vacation.resourceId, [vacation]); } } return map; }, [holidayOverlayEntries, vacationEntries]); // When EID filter is active, explicitly fetch those resources. const { data: eidFilterData } = trpc.resource.directory.useQuery( { eids: filters.eids, limit: 100 }, { enabled: !isSelfServiceTimeline && filters.eids.length > 0, staleTime: 30_000 }, ); // ─── Filtered entries ────────────────────────────────────────────────────── const visibleAssignments = useMemo( () => assignments.filter((entry) => { if (entry.project.status === "DRAFT" && !filters.showDrafts) return false; if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false; return true; }), [assignments, filters.hideCompletedProjects, filters.showDrafts], ); const visibleDemands = useMemo( () => demands.filter((entry) => { if (entry.project.status === "DRAFT" && !filters.showDrafts) return false; if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false; if (!filters.showPlaceholders) return false; // Hide fully-filled demands (status COMPLETED or unfilledHeadcount <= 0) const demandEntry = entry as { status?: string; unfilledHeadcount?: number }; if (demandEntry.status === "COMPLETED") return false; if (typeof demandEntry.unfilledHeadcount === "number" && demandEntry.unfilledHeadcount <= 0) return false; return true; }), [demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders], ); const openDemandsByProject = useMemo(() => { const map = new Map(); for (const demand of visibleDemands) { const arr = map.get(demand.projectId) ?? []; arr.push(demand); map.set(demand.projectId, arr); } return map; }, [visibleDemands]); // ─── Resource map + allocsByResource ────────────────────────────────────── const { resourceMap, allocsByResource, resources } = useMemo(() => { const resourceMap = new Map(); const allocsByResource = new Map(); const firstAssignmentByResource = new Map(); const projectIdsByResource = new Map>(); const clientIdsByResource = new Map>(); const chapterFilter = new Set(filters.chapters); const eidFilter = new Set(filters.eids); const projectFilter = new Set(filters.projectIds); const clientFilter = new Set(filters.clientIds); if (eidFilterData?.resources) { for (const r of eidFilterData.resources as { id: string; displayName: string; eid: string; chapter: string | null; }[]) { if (!resourceMap.has(r.id)) { resourceMap.set(r.id, { id: r.id, displayName: r.displayName, eid: r.eid, chapter: r.chapter, }); } } } for (const entry of visibleAssignments) { if (!entry.resourceId) continue; firstAssignmentByResource.set(entry.resourceId, entry); if (!resourceMap.has(entry.resourceId)) { resourceMap.set(entry.resourceId, { id: entry.resource!.id, displayName: entry.resource!.displayName, eid: entry.resource!.eid, chapter: entry.resource!.chapter, }); } const arr = allocsByResource.get(entry.resourceId) ?? []; arr.push(entry); allocsByResource.set(entry.resourceId, arr); const projectIds = projectIdsByResource.get(entry.resourceId) ?? new Set(); projectIds.add(entry.projectId); projectIdsByResource.set(entry.resourceId, projectIds); if (typeof entry.project.clientId === "string") { const clientIds = clientIdsByResource.get(entry.resourceId) ?? new Set(); clientIds.add(entry.project.clientId); clientIdsByResource.set(entry.resourceId, clientIds); } } // Merge cross-project context allocations so they appear during drag if (isDragging && contextAllocations.length > 0) { for (const ca of contextAllocations) { if (!ca.resourceId) continue; const existing = firstAssignmentByResource.get(ca.resourceId); if (existing && !resourceMap.has(ca.resourceId)) { resourceMap.set(ca.resourceId, { id: existing.resource!.id, displayName: existing.resource!.displayName, eid: existing.resource!.eid, chapter: existing.resource!.chapter, }); } } } let resources = [...resourceMap.values()].sort((a, b) => a.displayName.localeCompare(b.displayName), ); if (chapterFilter.size > 0) { resources = resources.filter((r) => r.chapter && chapterFilter.has(r.chapter)); } if (eidFilter.size > 0) { resources = resources.filter((r) => eidFilter.has(r.eid)); } if (projectFilter.size > 0) { resources = resources.filter((r) => { const projectIds = projectIdsByResource.get(r.id); if (!projectIds) return false; for (const projectId of projectIds) { if (projectFilter.has(projectId)) { return true; } } return false; }); } if (clientFilter.size > 0) { resources = resources.filter((r) => { const clientIds = clientIdsByResource.get(r.id); if (!clientIds) return false; for (const clientId of clientIds) { if (clientFilter.has(clientId)) { return true; } } return false; }); } return { resourceMap, allocsByResource, resources }; }, [ visibleAssignments, eidFilterData, isDragging, contextAllocations, filters.chapters, filters.eids, filters.projectIds, filters.clientIds, ]); // eslint-disable-line react-hooks/exhaustive-deps // ─── Project groups (for project view) ──────────────────────────────────── const projectGroups = useMemo(() => { const projectGroupMap = new Map(); const resourceRowMapByProject = new Map< string, Map >(); const chapterFilter = new Set(filters.chapters); const eidFilter = new Set(filters.eids); const clientFilter = new Set(filters.clientIds); const projectFilter = new Set(filters.projectIds); const allGroupEntries: TimelineProjectEntry[] = [...visibleAssignments, ...visibleDemands]; for (const entry of allGroupEntries) { let group = projectGroupMap.get(entry.projectId); if (!group) { group = { id: entry.projectId, name: entry.project.name, shortCode: entry.project.shortCode, orderType: entry.project.orderType, clientId: entry.project.clientId ?? null, startDate: new Date(entry.project.startDate as unknown as string), endDate: new Date(entry.project.endDate as unknown as string), status: entry.project.status, color: (entry.project as { color?: string | null }).color ?? null, resourceRows: [], }; projectGroupMap.set(entry.projectId, group); resourceRowMapByProject.set(entry.projectId, new Map()); } const currentGroup = group; if (!currentGroup) continue; if (entry.kind === "assignment" && entry.resourceId) { const rowMap = resourceRowMapByProject.get(entry.projectId); const existingRow = rowMap?.get(entry.resourceId); if (existingRow) { existingRow.allocs.push(entry); } else { const res = resourceMap.get(entry.resourceId); if (res) { const row = { resource: res, allocs: [entry] }; currentGroup.resourceRows.push(row); rowMap?.set(entry.resourceId, row); } } } } for (const group of projectGroupMap.values()) { group.resourceRows = group.resourceRows.filter(({ resource }) => { if (chapterFilter.size > 0) { if (!resource.chapter || !chapterFilter.has(resource.chapter)) { return false; } } if (eidFilter.size > 0 && !eidFilter.has(resource.eid)) { return false; } if (clientFilter.size > 0 && (!group.clientId || !clientFilter.has(group.clientId))) { return false; } return true; }); group.resourceRows.sort((a, b) => a.resource.displayName.localeCompare(b.resource.displayName), ); } return [...projectGroupMap.values()] .sort((a, b) => a.startDate.getTime() - b.startDate.getTime()) .filter((pg) => { if (projectFilter.size > 0 && !projectFilter.has(pg.id)) return false; if ( clientFilter.size > 0 && (!pg.clientId || !clientFilter.has(pg.clientId)) ) return false; if ( chapterFilter.size > 0 && pg.resourceRows.length === 0 ) return false; if (eidFilter.size > 0 && pg.resourceRows.length === 0) return false; return true; }); }, [ visibleAssignments, visibleDemands, resourceMap, filters.projectIds, filters.clientIds, filters.chapters, filters.eids, ]); // eslint-disable-line react-hooks/exhaustive-deps // ─── Derived counts ─────────────────────────────────────────────────────── const isInitialLoading = (isRoleLoading || isLoading) && !entriesView; const totalAllocCount = entriesView?.allocations.length ?? 0; const activeFilterCount = filters.clientIds.length + filters.chapters.length + filters.eids.length + filters.projectIds.length + filters.countryCodes.length; const value = useMemo( () => ({ assignments, demands, visibleAssignments, visibleDemands, vacationsByResource, resources, resourceMap, allocsByResource, projectGroups, openDemandsByProject, viewStart, viewEnd, viewDays, setViewStart, setViewDays, filters, setFilters, filterOpen, setFilterOpen, viewMode, setViewMode, today, displayMode, heatmapScheme, blinkOverbookedDays, isLoading, isInitialLoading, totalAllocCount, activeFilterCount, }), [ assignments, demands, visibleAssignments, visibleDemands, vacationsByResource, resources, resourceMap, allocsByResource, projectGroups, openDemandsByProject, viewStart, viewEnd, viewDays, filters, filterOpen, viewMode, today, displayMode, heatmapScheme, blinkOverbookedDays, isLoading, isInitialLoading, totalAllocCount, activeFilterCount, ], ); return {children}; }