Files
CapaKraken/apps/web/src/components/timeline/TimelineContext.tsx
T

746 lines
25 KiB
TypeScript

"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<string, unknown> | null;
};
export type TimelineAssignmentEntry = Assignment<TimelineAllocation>;
export type TimelineDemandEntry = DemandRequirement<TimelineAllocation>;
export type TimelineEntriesView = AllocationReadModel<TimelineAllocation> & {
assignments: TimelineAssignmentEntry[];
demands: TimelineDemandEntry[];
};
export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry;
export type ViewMode = "resource" | "project";
function buildTimelineFiltersFromSearchParams(searchParams: ReturnType<typeof useSearchParams>): 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<string, VacationEntry[]>;
resources: ResourceBrief[];
resourceMap: Map<string, ResourceBrief>;
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
projectGroups: ProjectGroup[];
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
// ─ View state
viewStart: Date;
viewEnd: Date;
viewDays: number;
setViewStart: React.Dispatch<React.SetStateAction<Date>>;
setViewDays: React.Dispatch<React.SetStateAction<number>>;
filters: TimelineFilters;
setFilters: React.Dispatch<React.SetStateAction<TimelineFilters>>;
filterOpen: boolean;
setFilterOpen: React.Dispatch<React.SetStateAction<boolean>>;
viewMode: ViewMode;
setViewMode: React.Dispatch<React.SetStateAction<ViewMode>>;
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<TimelineContextValue | null>(null);
export function useTimelineContext(): TimelineContextValue {
const ctx = useContext(TimelineContext);
if (!ctx) {
throw new Error("useTimelineContext must be used within a <TimelineProvider>");
}
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<TimelineFilters>(() => 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<ViewMode>("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<unknown>;
};
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<unknown>;
};
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<string, VacationEntry[]>();
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<string, TimelineDemandEntry[]>();
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<string, ResourceBrief>();
const allocsByResource = new Map<string, TimelineAssignmentEntry[]>();
const firstAssignmentByResource = new Map<string, TimelineAssignmentEntry>();
const projectIdsByResource = new Map<string, Set<string>>();
const clientIdsByResource = new Map<string, Set<string>>();
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<string>();
projectIds.add(entry.projectId);
projectIdsByResource.set(entry.resourceId, projectIds);
if (typeof entry.project.clientId === "string") {
const clientIds = clientIdsByResource.get(entry.resourceId) ?? new Set<string>();
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<string, ProjectGroup>();
const resourceRowMapByProject = new Map<
string,
Map<string, ProjectGroup["resourceRows"][number]>
>();
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<TimelineContextValue>(
() => ({
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 <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
}