diff --git a/apps/web/src/components/timeline/ProjectColorLegend.tsx b/apps/web/src/components/timeline/ProjectColorLegend.tsx index 232dbd1..a77d47d 100644 --- a/apps/web/src/components/timeline/ProjectColorLegend.tsx +++ b/apps/web/src/components/timeline/ProjectColorLegend.tsx @@ -2,12 +2,13 @@ import { clsx } from "clsx"; import { memo, useMemo, useState } from "react"; -import { useTimelineContext } from "./TimelineContext.js"; +import { useTimelineData, useTimelineView } from "./TimelineContext.js"; import { getProjectColor } from "~/lib/project-colors.js"; import { FadeIn } from "~/components/ui/FadeIn.js"; function ProjectColorLegendInner() { - const { visibleAssignments, viewMode, projectGroups } = useTimelineContext(); + const { visibleAssignments, projectGroups } = useTimelineData(); + const { viewMode } = useTimelineView(); const [dismissed, setDismissed] = useState(false); // Collect unique visible projects with their colors @@ -76,7 +77,13 @@ function ProjectColorLegendInner() { )} aria-label="Dismiss color legend" > - + diff --git a/apps/web/src/components/timeline/TimelineContext.tsx b/apps/web/src/components/timeline/TimelineContext.tsx index b92b6fa..3d99cfa 100644 --- a/apps/web/src/components/timeline/TimelineContext.tsx +++ b/apps/web/src/components/timeline/TimelineContext.tsx @@ -176,10 +176,9 @@ export type HolidayOverlayEntry = { metroCityName?: string | null; }; -// ─── Context shape ────────────────────────────────────────────────────────── +// ─── Context shapes ───────────────────────────────────────────────────────── -export interface TimelineContextValue { - // ─ Data +export interface TimelineDataContextValue { assignments: TimelineAssignmentEntry[]; demands: TimelineDemandEntry[]; visibleAssignments: TimelineAssignmentEntry[]; @@ -190,8 +189,13 @@ export interface TimelineContextValue { allocsByResource: Map; projectGroups: ProjectGroup[]; openDemandsByProject: Map; + isLoading: boolean; + isInitialLoading: boolean; + isEntriesError: boolean; + totalAllocCount: number; +} - // ─ View state +export interface TimelineViewContextValue { viewStart: Date; viewEnd: Date; viewDays: number; @@ -204,32 +208,46 @@ export interface TimelineContextValue { viewMode: ViewMode; setViewMode: React.Dispatch>; today: Date; + activeFilterCount: number; +} - // ─ Display preferences +export interface TimelineDisplayContextValue { displayMode: TimelineDisplayMode; heatmapScheme: HeatmapColorScheme; blinkOverbookedDays: boolean; - - // ─ Loading - isLoading: boolean; - isInitialLoading: boolean; - isEntriesError: boolean; - totalAllocCount: number; - activeFilterCount: number; - - // ─ SSE is initialized by the provider (no value exposed) } -const TimelineContext = createContext(null); +export type TimelineContextValue = TimelineDataContextValue & + TimelineViewContextValue & + TimelineDisplayContextValue; -export function useTimelineContext(): TimelineContextValue { - const ctx = useContext(TimelineContext); - if (!ctx) { - throw new Error("useTimelineContext must be used within a "); - } +const TimelineDataContext = createContext(null); +const TimelineViewContext = createContext(null); +const TimelineDisplayContext = createContext(null); + +export function useTimelineData(): TimelineDataContextValue { + const ctx = useContext(TimelineDataContext); + if (!ctx) throw new Error("useTimelineData must be used within a "); return ctx; } +export function useTimelineView(): TimelineViewContextValue { + const ctx = useContext(TimelineViewContext); + if (!ctx) throw new Error("useTimelineView must be used within a "); + return ctx; +} + +export function useTimelineDisplay(): TimelineDisplayContextValue { + const ctx = useContext(TimelineDisplayContext); + if (!ctx) throw new Error("useTimelineDisplay must be used within a "); + return ctx; +} + +/** Combined hook — use the specific hooks above to avoid unnecessary re-renders. */ +export function useTimelineContext(): TimelineContextValue { + return { ...useTimelineData(), ...useTimelineView(), ...useTimelineDisplay() }; +} + // ─── Provider ─────────────────────────────────────────────────────────────── interface TimelineProviderProps { @@ -752,7 +770,7 @@ export function TimelineProvider({ filters.projectIds.length + filters.countryCodes.length; - const value = useMemo( + const dataValue = useMemo( () => ({ assignments, demands, @@ -764,26 +782,10 @@ export function TimelineProvider({ allocsByResource, projectGroups, openDemandsByProject, - viewStart, - viewEnd, - viewDays, - setViewStart, - setViewDays, - filters, - setFilters, - filterOpen, - setFilterOpen, - viewMode, - setViewMode, - today, - displayMode, - heatmapScheme, - blinkOverbookedDays, isLoading, isInitialLoading, isEntriesError, totalAllocCount, - activeFilterCount, }), [ assignments, @@ -796,23 +798,44 @@ export function TimelineProvider({ allocsByResource, projectGroups, openDemandsByProject, - viewStart, - viewEnd, - viewDays, - filters, - filterOpen, - viewMode, - today, - displayMode, - heatmapScheme, - blinkOverbookedDays, isLoading, isInitialLoading, isEntriesError, totalAllocCount, - activeFilterCount, ], ); - return {children}; + const viewValue = useMemo( + () => ({ + viewStart, + viewEnd, + viewDays, + setViewStart, + setViewDays, + filters, + setFilters, + filterOpen, + setFilterOpen, + viewMode, + setViewMode, + today, + activeFilterCount, + }), + [viewStart, viewEnd, viewDays, filters, filterOpen, viewMode, today, activeFilterCount], + ); + + const displayValue = useMemo( + () => ({ displayMode, heatmapScheme, blinkOverbookedDays }), + [displayMode, heatmapScheme, blinkOverbookedDays], + ); + + return ( + + + + {children} + + + + ); } diff --git a/apps/web/src/components/timeline/TimelineProjectPanel.tsx b/apps/web/src/components/timeline/TimelineProjectPanel.tsx index 3ebb17e..1d47b6e 100644 --- a/apps/web/src/components/timeline/TimelineProjectPanel.tsx +++ b/apps/web/src/components/timeline/TimelineProjectPanel.tsx @@ -5,7 +5,9 @@ import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useStat import { useVirtualizer } from "@tanstack/react-virtual"; import type { CSSProperties } from "react"; import { - useTimelineContext, + useTimelineData, + useTimelineView, + useTimelineDisplay, type TimelineAssignmentEntry, type TimelineDemandEntry, } from "./TimelineContext.js"; @@ -32,7 +34,12 @@ import { ORDER_TYPE_COLORS, } from "./timelineConstants.js"; import { getProjectColor } from "~/lib/project-colors.js"; -import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js"; +import type { + DragState, + AllocDragState, + RangeState, + MultiSelectState, +} from "~/hooks/useTimelineDrag.js"; import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js"; import { buildVacationBlocksByResource, @@ -50,10 +57,7 @@ import { } from "./timelineHover.js"; import { buildResourceHeatmapSeries } from "./timelineHeatmap.js"; import { buildResourceCapacitySeries } from "./timelineCapacity.js"; -import { - buildProjectRowMetrics, - type ProjectDayMetric, -} from "./timelineProjectMetrics.js"; +import { buildProjectRowMetrics, type ProjectDayMetric } from "./timelineProjectMetrics.js"; import { buildProjectFlatRows, estimateProjectRowHeight, @@ -147,18 +151,10 @@ function TimelineProjectPanelInner({ gridLines, xToDate, }: TimelineProjectPanelProps) { - const { - projectGroups, - openDemandsByProject, - allocsByResource, - vacationsByResource, - filters, - displayMode, - heatmapScheme, - blinkOverbookedDays, - activeFilterCount, - today, - } = useTimelineContext(); + const { projectGroups, openDemandsByProject, allocsByResource, vacationsByResource } = + useTimelineData(); + const { filters, activeFilterCount, today } = useTimelineView(); + const { displayMode, heatmapScheme, blinkOverbookedDays } = useTimelineDisplay(); const visualAllocsByResource = useMemo(() => { if (optimisticAllocations.size === 0) return allocsByResource; @@ -171,13 +167,14 @@ function TimelineProjectPanelInner({ }, [allocsByResource, optimisticAllocations]); const visualProjectGroups = useMemo( - () => projectGroups.map((project) => ({ - ...project, - resourceRows: project.resourceRows.map((row) => ({ - ...row, - allocs: applyVisualOverrides(row.allocs, optimisticAllocations), + () => + projectGroups.map((project) => ({ + ...project, + resourceRows: project.resourceRows.map((row) => ({ + ...row, + allocs: applyVisualOverrides(row.allocs, optimisticAllocations), + })), })), - })), [projectGroups, optimisticAllocations], ); @@ -224,7 +221,15 @@ function TimelineProjectPanelInner({ totalCanvasWidth, filters.showWeekends, ), - [CELL_WIDTH, filters.showVacations, filters.showWeekends, toLeft, toWidth, totalCanvasWidth, vacationsByResource], + [ + CELL_WIDTH, + filters.showVacations, + filters.showWeekends, + toLeft, + toWidth, + totalCanvasWidth, + vacationsByResource, + ], ); const projectRowMetrics = useMemo(() => { @@ -262,10 +267,7 @@ function TimelineProjectPanelInner({ const rect = e.currentTarget.getBoundingClientRect(); const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH); - if ( - dayIndex === lastHeatmapDayRef.current && - resourceId === lastHeatmapResourceRef.current - ) + if (dayIndex === lastHeatmapDayRef.current && resourceId === lastHeatmapResourceRef.current) return; pendingHeatmapRef.current = { clientX: e.clientX, rect, resourceId }; @@ -310,7 +312,14 @@ function TimelineProjectPanelInner({ return; } - updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8); + updateTooltipPosition( + vacationTooltipPosRef, + vacationTooltipRef, + e.clientX, + e.clientY, + 14, + -8, + ); scheduleVacationHoverUpdate({ frameRef: vacationHoverRafRef, hoveredKeyRef: hoveredVacationKeyRef, @@ -350,16 +359,13 @@ function TimelineProjectPanelInner({ } }, [demandHover]); - const handleDemandHoverMove = useCallback( - (e: React.MouseEvent, demand: TimelineDemandEntry) => { - updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36); + const handleDemandHoverMove = useCallback((e: React.MouseEvent, demand: TimelineDemandEntry) => { + updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36); - startTransition(() => { - setDemandHover(buildDemandHoverData(demand)); - }); - }, - [], - ); + startTransition(() => { + setDemandHover(buildDemandHoverData(demand)); + }); + }, []); useEffect( () => () => { @@ -432,8 +438,14 @@ function TimelineProjectPanelInner({ return (
{row.resource.displayName}
-
{row.resource.eid}
+
+ {row.resource.eid} +
@@ -721,7 +735,9 @@ function renderOpenDemandRow( ?
-
Open demand
+
+ Open demand +
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
@@ -741,19 +757,23 @@ function renderOpenDemandRow( const allocStart = new Date(alloc.startDate); const allocEnd = new Date(alloc.endDate); - const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id; + const isAllocDragged = + allocDragState.isActive && allocDragState.allocationId === alloc.id; const dispStart = isAllocDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : allocStart; const dispEnd = - isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd; + isAllocDragged && allocDragState.currentEndDate + ? allocDragState.currentEndDate + : allocEnd; // Multi-drag visual offset const isMultiDragTarget = - multiSelectState.isMultiDragging && - selectedAllocationSet.has(alloc.id); - const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0; + multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id); + const multiDragPx = isMultiDragTarget + ? multiSelectState.multiDragDaysDelta * CELL_WIDTH + : 0; const multiDragMode = multiSelectState.multiDragMode; let left = toLeft(dispStart); @@ -838,9 +858,12 @@ function renderOpenDemandRow( border: `2px dashed ${roleColor}B3`, ...((multiDragPx && multiDragMode === "move") || dragTransform ? { - transform: [dragTransform, multiDragPx && multiDragMode === "move" - ? `translateX(${multiDragPx}px)` - : null] + transform: [ + dragTransform, + multiDragPx && multiDragMode === "move" + ? `translateX(${multiDragPx}px)` + : null, + ] .filter(Boolean) .join(" "), } @@ -964,18 +987,18 @@ function renderProjectUtilOverlay( const projPct = (projH / capacityH) * 100; const totalPct = (totalH / capacityH) * 100; const projColor = useHeatmapColors - ? heatmapColor( + ? (heatmapColor( projPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar", - ) ?? "rgba(59,130,246,0.8)" + ) ?? "rgba(59,130,246,0.8)") : "rgba(96,165,250,0.8)"; const totalColor = useHeatmapColors - ? heatmapColor( + ? (heatmapColor( totalPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar", - ) ?? "rgba(156,163,175,0.5)" + ) ?? "rgba(156,163,175,0.5)") : isOver ? "rgba(252,211,77,0.8)" : "rgba(209,213,219,0.8)"; @@ -1081,8 +1104,7 @@ function renderProjectDragHandles( // Multi-drag visual offset const isMultiDragTarget = - multiSelectState.isMultiDragging && - selectedAllocationSet.has(alloc.id); + multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id); const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0; const multiDragMode = multiSelectState.multiDragMode; @@ -1132,9 +1154,7 @@ function renderProjectDragHandles( ? { transform: [ dragTransform, - multiDragPx && multiDragMode === "move" - ? `translateX(${multiDragPx}px)` - : null, + multiDragPx && multiDragMode === "move" ? `translateX(${multiDragPx}px)` : null, ] .filter(Boolean) .join(" "), diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx index ca51952..f0719fc 100644 --- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx +++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx @@ -3,7 +3,12 @@ import { clsx } from "clsx"; import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useTimelineContext, type TimelineAssignmentEntry } from "./TimelineContext.js"; +import { + useTimelineData, + useTimelineView, + useTimelineDisplay, + type TimelineAssignmentEntry, +} from "./TimelineContext.js"; import { applyVisualOverrides, type TimelineVisualOverrides } from "./allocationVisualState.js"; import { ConflictOverlay } from "./ConflictOverlay.js"; import { computeSubLanes } from "./utils.js"; @@ -117,18 +122,9 @@ function TimelineResourcePanelInner({ gridLines, xToDate, }: TimelineResourcePanelProps) { - const { - resources, - allocsByResource, - vacationsByResource, - filters, - viewStart, - viewEnd, - displayMode, - heatmapScheme, - blinkOverbookedDays, - activeFilterCount, - } = useTimelineContext(); + const { resources, allocsByResource, vacationsByResource } = useTimelineData(); + const { filters, viewStart, viewEnd, activeFilterCount } = useTimelineView(); + const { displayMode, heatmapScheme, blinkOverbookedDays } = useTimelineDisplay(); // ─── Heatmap hover state ──────────────────────────────────────────────────── const heatmapRafRef = useRef(null); diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index c63e1bc..505cc95 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -28,7 +28,8 @@ import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineC import { formatDateShort } from "~/lib/format.js"; import { TimelineProvider, - useTimelineContext, + useTimelineData, + useTimelineView, type TimelineAssignmentEntry, } from "./TimelineContext.js"; import { TimelineResourcePanel } from "./TimelineResourcePanel.js"; @@ -339,17 +340,22 @@ function TimelineViewContent({ undo: () => Promise; redo: () => Promise; }) { - const ctx = useTimelineContext(); const { resources, projectGroups, allocsByResource, openDemandsByProject, + visibleAssignments, + visibleDemands, + isLoading, + isInitialLoading, + isEntriesError, + totalAllocCount, + } = useTimelineData(); + const { viewStart, viewEnd, viewDays, - visibleAssignments, - visibleDemands, setViewStart, setViewDays, filters, @@ -359,11 +365,7 @@ function TimelineViewContent({ viewMode, setViewMode, today, - isLoading, - isInitialLoading, - isEntriesError, - totalAllocCount, - } = ctx; + } = useTimelineView(); const scrollContainerRef = useRef(null); const canvasRef = useRef(null);