refactor(web): split TimelineContext into data, view, and display contexts

Reduces unnecessary re-renders by separating the monolithic 20+ property
context into TimelineDataContext, TimelineViewContext, and
TimelineDisplayContext. Panel components now subscribe only to the
slices they need.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:17:58 +02:00
parent 7eac5816d6
commit f18777c365
5 changed files with 182 additions and 134 deletions
@@ -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"
>
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 2l8 8M10 2l-8 8" />
</svg>
</button>
@@ -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<string, TimelineAssignmentEntry[]>;
projectGroups: ProjectGroup[];
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
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<React.SetStateAction<ViewMode>>;
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<TimelineContextValue | null>(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 <TimelineProvider>");
}
const TimelineDataContext = createContext<TimelineDataContextValue | null>(null);
const TimelineViewContext = createContext<TimelineViewContextValue | null>(null);
const TimelineDisplayContext = createContext<TimelineDisplayContextValue | null>(null);
export function useTimelineData(): TimelineDataContextValue {
const ctx = useContext(TimelineDataContext);
if (!ctx) throw new Error("useTimelineData must be used within a <TimelineProvider>");
return ctx;
}
export function useTimelineView(): TimelineViewContextValue {
const ctx = useContext(TimelineViewContext);
if (!ctx) throw new Error("useTimelineView must be used within a <TimelineProvider>");
return ctx;
}
export function useTimelineDisplay(): TimelineDisplayContextValue {
const ctx = useContext(TimelineDisplayContext);
if (!ctx) throw new Error("useTimelineDisplay must be used within a <TimelineProvider>");
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<TimelineContextValue>(
const dataValue = useMemo<TimelineDataContextValue>(
() => ({
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 <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
const viewValue = useMemo<TimelineViewContextValue>(
() => ({
viewStart,
viewEnd,
viewDays,
setViewStart,
setViewDays,
filters,
setFilters,
filterOpen,
setFilterOpen,
viewMode,
setViewMode,
today,
activeFilterCount,
}),
[viewStart, viewEnd, viewDays, filters, filterOpen, viewMode, today, activeFilterCount],
);
const displayValue = useMemo<TimelineDisplayContextValue>(
() => ({ displayMode, heatmapScheme, blinkOverbookedDays }),
[displayMode, heatmapScheme, blinkOverbookedDays],
);
return (
<TimelineDataContext.Provider value={dataValue}>
<TimelineViewContext.Provider value={viewValue}>
<TimelineDisplayContext.Provider value={displayValue}>
{children}
</TimelineDisplayContext.Provider>
</TimelineViewContext.Provider>
</TimelineDataContext.Provider>
);
}
@@ -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 (
<div
data-project-group="true"
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
style={{ height: PROJECT_HEADER_HEIGHT, borderLeft: `4px solid ${customColor ?? projectColor.hex}` }}
className={clsx(
"flex border-b border-gray-200 dark:border-gray-700 group/proj",
colors.light,
)}
style={{
height: PROJECT_HEADER_HEIGHT,
borderLeft: `4px solid ${customColor ?? projectColor.hex}`,
}}
>
<div
className={clsx(
@@ -570,7 +582,9 @@ function TimelineProjectPanelInner({
<div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
{row.resource.displayName}
</div>
<div className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{row.resource.eid}</div>
<div className="text-[10px] text-gray-400 dark:text-gray-500 truncate">
{row.resource.eid}
</div>
</div>
</div>
@@ -721,7 +735,9 @@ function renderOpenDemandRow(
?
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">Open demand</div>
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">
Open demand
</div>
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
</div>
@@ -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(" "),
@@ -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<number | null>(null);
@@ -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<void>;
redo: () => Promise<void>;
}) {
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<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);