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:
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { memo, useMemo, useState } from "react";
|
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 { getProjectColor } from "~/lib/project-colors.js";
|
||||||
import { FadeIn } from "~/components/ui/FadeIn.js";
|
import { FadeIn } from "~/components/ui/FadeIn.js";
|
||||||
|
|
||||||
function ProjectColorLegendInner() {
|
function ProjectColorLegendInner() {
|
||||||
const { visibleAssignments, viewMode, projectGroups } = useTimelineContext();
|
const { visibleAssignments, projectGroups } = useTimelineData();
|
||||||
|
const { viewMode } = useTimelineView();
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
// Collect unique visible projects with their colors
|
// Collect unique visible projects with their colors
|
||||||
@@ -76,7 +77,13 @@ function ProjectColorLegendInner() {
|
|||||||
)}
|
)}
|
||||||
aria-label="Dismiss color legend"
|
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" />
|
<path d="M2 2l8 8M10 2l-8 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -176,10 +176,9 @@ export type HolidayOverlayEntry = {
|
|||||||
metroCityName?: string | null;
|
metroCityName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Context shape ──────────────────────────────────────────────────────────
|
// ─── Context shapes ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TimelineContextValue {
|
export interface TimelineDataContextValue {
|
||||||
// ─ Data
|
|
||||||
assignments: TimelineAssignmentEntry[];
|
assignments: TimelineAssignmentEntry[];
|
||||||
demands: TimelineDemandEntry[];
|
demands: TimelineDemandEntry[];
|
||||||
visibleAssignments: TimelineAssignmentEntry[];
|
visibleAssignments: TimelineAssignmentEntry[];
|
||||||
@@ -190,8 +189,13 @@ export interface TimelineContextValue {
|
|||||||
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
|
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
|
||||||
projectGroups: ProjectGroup[];
|
projectGroups: ProjectGroup[];
|
||||||
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
|
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
|
||||||
|
isLoading: boolean;
|
||||||
|
isInitialLoading: boolean;
|
||||||
|
isEntriesError: boolean;
|
||||||
|
totalAllocCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ─ View state
|
export interface TimelineViewContextValue {
|
||||||
viewStart: Date;
|
viewStart: Date;
|
||||||
viewEnd: Date;
|
viewEnd: Date;
|
||||||
viewDays: number;
|
viewDays: number;
|
||||||
@@ -204,32 +208,46 @@ export interface TimelineContextValue {
|
|||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
setViewMode: React.Dispatch<React.SetStateAction<ViewMode>>;
|
setViewMode: React.Dispatch<React.SetStateAction<ViewMode>>;
|
||||||
today: Date;
|
today: Date;
|
||||||
|
activeFilterCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ─ Display preferences
|
export interface TimelineDisplayContextValue {
|
||||||
displayMode: TimelineDisplayMode;
|
displayMode: TimelineDisplayMode;
|
||||||
heatmapScheme: HeatmapColorScheme;
|
heatmapScheme: HeatmapColorScheme;
|
||||||
blinkOverbookedDays: boolean;
|
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 TimelineDataContext = createContext<TimelineDataContextValue | null>(null);
|
||||||
const ctx = useContext(TimelineContext);
|
const TimelineViewContext = createContext<TimelineViewContextValue | null>(null);
|
||||||
if (!ctx) {
|
const TimelineDisplayContext = createContext<TimelineDisplayContextValue | null>(null);
|
||||||
throw new Error("useTimelineContext must be used within a <TimelineProvider>");
|
|
||||||
}
|
export function useTimelineData(): TimelineDataContextValue {
|
||||||
|
const ctx = useContext(TimelineDataContext);
|
||||||
|
if (!ctx) throw new Error("useTimelineData must be used within a <TimelineProvider>");
|
||||||
return ctx;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Provider ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface TimelineProviderProps {
|
interface TimelineProviderProps {
|
||||||
@@ -752,7 +770,7 @@ export function TimelineProvider({
|
|||||||
filters.projectIds.length +
|
filters.projectIds.length +
|
||||||
filters.countryCodes.length;
|
filters.countryCodes.length;
|
||||||
|
|
||||||
const value = useMemo<TimelineContextValue>(
|
const dataValue = useMemo<TimelineDataContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
assignments,
|
assignments,
|
||||||
demands,
|
demands,
|
||||||
@@ -764,26 +782,10 @@ export function TimelineProvider({
|
|||||||
allocsByResource,
|
allocsByResource,
|
||||||
projectGroups,
|
projectGroups,
|
||||||
openDemandsByProject,
|
openDemandsByProject,
|
||||||
viewStart,
|
|
||||||
viewEnd,
|
|
||||||
viewDays,
|
|
||||||
setViewStart,
|
|
||||||
setViewDays,
|
|
||||||
filters,
|
|
||||||
setFilters,
|
|
||||||
filterOpen,
|
|
||||||
setFilterOpen,
|
|
||||||
viewMode,
|
|
||||||
setViewMode,
|
|
||||||
today,
|
|
||||||
displayMode,
|
|
||||||
heatmapScheme,
|
|
||||||
blinkOverbookedDays,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isInitialLoading,
|
isInitialLoading,
|
||||||
isEntriesError,
|
isEntriesError,
|
||||||
totalAllocCount,
|
totalAllocCount,
|
||||||
activeFilterCount,
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
assignments,
|
assignments,
|
||||||
@@ -796,23 +798,44 @@ export function TimelineProvider({
|
|||||||
allocsByResource,
|
allocsByResource,
|
||||||
projectGroups,
|
projectGroups,
|
||||||
openDemandsByProject,
|
openDemandsByProject,
|
||||||
viewStart,
|
|
||||||
viewEnd,
|
|
||||||
viewDays,
|
|
||||||
filters,
|
|
||||||
filterOpen,
|
|
||||||
viewMode,
|
|
||||||
today,
|
|
||||||
displayMode,
|
|
||||||
heatmapScheme,
|
|
||||||
blinkOverbookedDays,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isInitialLoading,
|
isInitialLoading,
|
||||||
isEntriesError,
|
isEntriesError,
|
||||||
totalAllocCount,
|
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 { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import {
|
import {
|
||||||
useTimelineContext,
|
useTimelineData,
|
||||||
|
useTimelineView,
|
||||||
|
useTimelineDisplay,
|
||||||
type TimelineAssignmentEntry,
|
type TimelineAssignmentEntry,
|
||||||
type TimelineDemandEntry,
|
type TimelineDemandEntry,
|
||||||
} from "./TimelineContext.js";
|
} from "./TimelineContext.js";
|
||||||
@@ -32,7 +34,12 @@ import {
|
|||||||
ORDER_TYPE_COLORS,
|
ORDER_TYPE_COLORS,
|
||||||
} from "./timelineConstants.js";
|
} from "./timelineConstants.js";
|
||||||
import { getProjectColor } from "~/lib/project-colors.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 type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
|
||||||
import {
|
import {
|
||||||
buildVacationBlocksByResource,
|
buildVacationBlocksByResource,
|
||||||
@@ -50,10 +57,7 @@ import {
|
|||||||
} from "./timelineHover.js";
|
} from "./timelineHover.js";
|
||||||
import { buildResourceHeatmapSeries } from "./timelineHeatmap.js";
|
import { buildResourceHeatmapSeries } from "./timelineHeatmap.js";
|
||||||
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
|
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
|
||||||
import {
|
import { buildProjectRowMetrics, type ProjectDayMetric } from "./timelineProjectMetrics.js";
|
||||||
buildProjectRowMetrics,
|
|
||||||
type ProjectDayMetric,
|
|
||||||
} from "./timelineProjectMetrics.js";
|
|
||||||
import {
|
import {
|
||||||
buildProjectFlatRows,
|
buildProjectFlatRows,
|
||||||
estimateProjectRowHeight,
|
estimateProjectRowHeight,
|
||||||
@@ -147,18 +151,10 @@ function TimelineProjectPanelInner({
|
|||||||
gridLines,
|
gridLines,
|
||||||
xToDate,
|
xToDate,
|
||||||
}: TimelineProjectPanelProps) {
|
}: TimelineProjectPanelProps) {
|
||||||
const {
|
const { projectGroups, openDemandsByProject, allocsByResource, vacationsByResource } =
|
||||||
projectGroups,
|
useTimelineData();
|
||||||
openDemandsByProject,
|
const { filters, activeFilterCount, today } = useTimelineView();
|
||||||
allocsByResource,
|
const { displayMode, heatmapScheme, blinkOverbookedDays } = useTimelineDisplay();
|
||||||
vacationsByResource,
|
|
||||||
filters,
|
|
||||||
displayMode,
|
|
||||||
heatmapScheme,
|
|
||||||
blinkOverbookedDays,
|
|
||||||
activeFilterCount,
|
|
||||||
today,
|
|
||||||
} = useTimelineContext();
|
|
||||||
|
|
||||||
const visualAllocsByResource = useMemo(() => {
|
const visualAllocsByResource = useMemo(() => {
|
||||||
if (optimisticAllocations.size === 0) return allocsByResource;
|
if (optimisticAllocations.size === 0) return allocsByResource;
|
||||||
@@ -171,13 +167,14 @@ function TimelineProjectPanelInner({
|
|||||||
}, [allocsByResource, optimisticAllocations]);
|
}, [allocsByResource, optimisticAllocations]);
|
||||||
|
|
||||||
const visualProjectGroups = useMemo(
|
const visualProjectGroups = useMemo(
|
||||||
() => projectGroups.map((project) => ({
|
() =>
|
||||||
...project,
|
projectGroups.map((project) => ({
|
||||||
resourceRows: project.resourceRows.map((row) => ({
|
...project,
|
||||||
...row,
|
resourceRows: project.resourceRows.map((row) => ({
|
||||||
allocs: applyVisualOverrides(row.allocs, optimisticAllocations),
|
...row,
|
||||||
|
allocs: applyVisualOverrides(row.allocs, optimisticAllocations),
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
|
||||||
[projectGroups, optimisticAllocations],
|
[projectGroups, optimisticAllocations],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,7 +221,15 @@ function TimelineProjectPanelInner({
|
|||||||
totalCanvasWidth,
|
totalCanvasWidth,
|
||||||
filters.showWeekends,
|
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(() => {
|
const projectRowMetrics = useMemo(() => {
|
||||||
@@ -262,10 +267,7 @@ function TimelineProjectPanelInner({
|
|||||||
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
||||||
if (
|
if (dayIndex === lastHeatmapDayRef.current && resourceId === lastHeatmapResourceRef.current)
|
||||||
dayIndex === lastHeatmapDayRef.current &&
|
|
||||||
resourceId === lastHeatmapResourceRef.current
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
pendingHeatmapRef.current = { clientX: e.clientX, rect, resourceId };
|
pendingHeatmapRef.current = { clientX: e.clientX, rect, resourceId };
|
||||||
@@ -310,7 +312,14 @@ function TimelineProjectPanelInner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8);
|
updateTooltipPosition(
|
||||||
|
vacationTooltipPosRef,
|
||||||
|
vacationTooltipRef,
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
14,
|
||||||
|
-8,
|
||||||
|
);
|
||||||
scheduleVacationHoverUpdate({
|
scheduleVacationHoverUpdate({
|
||||||
frameRef: vacationHoverRafRef,
|
frameRef: vacationHoverRafRef,
|
||||||
hoveredKeyRef: hoveredVacationKeyRef,
|
hoveredKeyRef: hoveredVacationKeyRef,
|
||||||
@@ -350,16 +359,13 @@ function TimelineProjectPanelInner({
|
|||||||
}
|
}
|
||||||
}, [demandHover]);
|
}, [demandHover]);
|
||||||
|
|
||||||
const handleDemandHoverMove = useCallback(
|
const handleDemandHoverMove = useCallback((e: React.MouseEvent, demand: TimelineDemandEntry) => {
|
||||||
(e: React.MouseEvent, demand: TimelineDemandEntry) => {
|
updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36);
|
||||||
updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36);
|
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setDemandHover(buildDemandHoverData(demand));
|
setDemandHover(buildDemandHoverData(demand));
|
||||||
});
|
});
|
||||||
},
|
}, []);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -432,8 +438,14 @@ function TimelineProjectPanelInner({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-project-group="true"
|
data-project-group="true"
|
||||||
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
|
className={clsx(
|
||||||
style={{ height: PROJECT_HEADER_HEIGHT, borderLeft: `4px solid ${customColor ?? projectColor.hex}` }}
|
"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
|
<div
|
||||||
className={clsx(
|
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">
|
<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}
|
{row.resource.displayName}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -721,7 +735,9 @@ function renderOpenDemandRow(
|
|||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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">
|
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
|
||||||
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
|
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -741,19 +757,23 @@ function renderOpenDemandRow(
|
|||||||
const allocStart = new Date(alloc.startDate);
|
const allocStart = new Date(alloc.startDate);
|
||||||
const allocEnd = new Date(alloc.endDate);
|
const allocEnd = new Date(alloc.endDate);
|
||||||
|
|
||||||
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
const isAllocDragged =
|
||||||
|
allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
||||||
const dispStart =
|
const dispStart =
|
||||||
isAllocDragged && allocDragState.currentStartDate
|
isAllocDragged && allocDragState.currentStartDate
|
||||||
? allocDragState.currentStartDate
|
? allocDragState.currentStartDate
|
||||||
: allocStart;
|
: allocStart;
|
||||||
const dispEnd =
|
const dispEnd =
|
||||||
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
|
isAllocDragged && allocDragState.currentEndDate
|
||||||
|
? allocDragState.currentEndDate
|
||||||
|
: allocEnd;
|
||||||
|
|
||||||
// Multi-drag visual offset
|
// Multi-drag visual offset
|
||||||
const isMultiDragTarget =
|
const isMultiDragTarget =
|
||||||
multiSelectState.isMultiDragging &&
|
multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id);
|
||||||
selectedAllocationSet.has(alloc.id);
|
const multiDragPx = isMultiDragTarget
|
||||||
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
? multiSelectState.multiDragDaysDelta * CELL_WIDTH
|
||||||
|
: 0;
|
||||||
const multiDragMode = multiSelectState.multiDragMode;
|
const multiDragMode = multiSelectState.multiDragMode;
|
||||||
|
|
||||||
let left = toLeft(dispStart);
|
let left = toLeft(dispStart);
|
||||||
@@ -838,9 +858,12 @@ function renderOpenDemandRow(
|
|||||||
border: `2px dashed ${roleColor}B3`,
|
border: `2px dashed ${roleColor}B3`,
|
||||||
...((multiDragPx && multiDragMode === "move") || dragTransform
|
...((multiDragPx && multiDragMode === "move") || dragTransform
|
||||||
? {
|
? {
|
||||||
transform: [dragTransform, multiDragPx && multiDragMode === "move"
|
transform: [
|
||||||
? `translateX(${multiDragPx}px)`
|
dragTransform,
|
||||||
: null]
|
multiDragPx && multiDragMode === "move"
|
||||||
|
? `translateX(${multiDragPx}px)`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" "),
|
.join(" "),
|
||||||
}
|
}
|
||||||
@@ -964,18 +987,18 @@ function renderProjectUtilOverlay(
|
|||||||
const projPct = (projH / capacityH) * 100;
|
const projPct = (projH / capacityH) * 100;
|
||||||
const totalPct = (totalH / capacityH) * 100;
|
const totalPct = (totalH / capacityH) * 100;
|
||||||
const projColor = useHeatmapColors
|
const projColor = useHeatmapColors
|
||||||
? heatmapColor(
|
? (heatmapColor(
|
||||||
projPct,
|
projPct,
|
||||||
heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme,
|
heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme,
|
||||||
"bar",
|
"bar",
|
||||||
) ?? "rgba(59,130,246,0.8)"
|
) ?? "rgba(59,130,246,0.8)")
|
||||||
: "rgba(96,165,250,0.8)";
|
: "rgba(96,165,250,0.8)";
|
||||||
const totalColor = useHeatmapColors
|
const totalColor = useHeatmapColors
|
||||||
? heatmapColor(
|
? (heatmapColor(
|
||||||
totalPct,
|
totalPct,
|
||||||
heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme,
|
heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme,
|
||||||
"bar",
|
"bar",
|
||||||
) ?? "rgba(156,163,175,0.5)"
|
) ?? "rgba(156,163,175,0.5)")
|
||||||
: isOver
|
: isOver
|
||||||
? "rgba(252,211,77,0.8)"
|
? "rgba(252,211,77,0.8)"
|
||||||
: "rgba(209,213,219,0.8)";
|
: "rgba(209,213,219,0.8)";
|
||||||
@@ -1081,8 +1104,7 @@ function renderProjectDragHandles(
|
|||||||
|
|
||||||
// Multi-drag visual offset
|
// Multi-drag visual offset
|
||||||
const isMultiDragTarget =
|
const isMultiDragTarget =
|
||||||
multiSelectState.isMultiDragging &&
|
multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id);
|
||||||
selectedAllocationSet.has(alloc.id);
|
|
||||||
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
||||||
const multiDragMode = multiSelectState.multiDragMode;
|
const multiDragMode = multiSelectState.multiDragMode;
|
||||||
|
|
||||||
@@ -1132,9 +1154,7 @@ function renderProjectDragHandles(
|
|||||||
? {
|
? {
|
||||||
transform: [
|
transform: [
|
||||||
dragTransform,
|
dragTransform,
|
||||||
multiDragPx && multiDragMode === "move"
|
multiDragPx && multiDragMode === "move" ? `translateX(${multiDragPx}px)` : null,
|
||||||
? `translateX(${multiDragPx}px)`
|
|
||||||
: null,
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" "),
|
.join(" "),
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
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 { applyVisualOverrides, type TimelineVisualOverrides } from "./allocationVisualState.js";
|
||||||
import { ConflictOverlay } from "./ConflictOverlay.js";
|
import { ConflictOverlay } from "./ConflictOverlay.js";
|
||||||
import { computeSubLanes } from "./utils.js";
|
import { computeSubLanes } from "./utils.js";
|
||||||
@@ -117,18 +122,9 @@ function TimelineResourcePanelInner({
|
|||||||
gridLines,
|
gridLines,
|
||||||
xToDate,
|
xToDate,
|
||||||
}: TimelineResourcePanelProps) {
|
}: TimelineResourcePanelProps) {
|
||||||
const {
|
const { resources, allocsByResource, vacationsByResource } = useTimelineData();
|
||||||
resources,
|
const { filters, viewStart, viewEnd, activeFilterCount } = useTimelineView();
|
||||||
allocsByResource,
|
const { displayMode, heatmapScheme, blinkOverbookedDays } = useTimelineDisplay();
|
||||||
vacationsByResource,
|
|
||||||
filters,
|
|
||||||
viewStart,
|
|
||||||
viewEnd,
|
|
||||||
displayMode,
|
|
||||||
heatmapScheme,
|
|
||||||
blinkOverbookedDays,
|
|
||||||
activeFilterCount,
|
|
||||||
} = useTimelineContext();
|
|
||||||
|
|
||||||
// ─── Heatmap hover state ────────────────────────────────────────────────────
|
// ─── Heatmap hover state ────────────────────────────────────────────────────
|
||||||
const heatmapRafRef = useRef<number | null>(null);
|
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 { formatDateShort } from "~/lib/format.js";
|
||||||
import {
|
import {
|
||||||
TimelineProvider,
|
TimelineProvider,
|
||||||
useTimelineContext,
|
useTimelineData,
|
||||||
|
useTimelineView,
|
||||||
type TimelineAssignmentEntry,
|
type TimelineAssignmentEntry,
|
||||||
} from "./TimelineContext.js";
|
} from "./TimelineContext.js";
|
||||||
import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
|
import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
|
||||||
@@ -339,17 +340,22 @@ function TimelineViewContent({
|
|||||||
undo: () => Promise<void>;
|
undo: () => Promise<void>;
|
||||||
redo: () => Promise<void>;
|
redo: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const ctx = useTimelineContext();
|
|
||||||
const {
|
const {
|
||||||
resources,
|
resources,
|
||||||
projectGroups,
|
projectGroups,
|
||||||
allocsByResource,
|
allocsByResource,
|
||||||
openDemandsByProject,
|
openDemandsByProject,
|
||||||
|
visibleAssignments,
|
||||||
|
visibleDemands,
|
||||||
|
isLoading,
|
||||||
|
isInitialLoading,
|
||||||
|
isEntriesError,
|
||||||
|
totalAllocCount,
|
||||||
|
} = useTimelineData();
|
||||||
|
const {
|
||||||
viewStart,
|
viewStart,
|
||||||
viewEnd,
|
viewEnd,
|
||||||
viewDays,
|
viewDays,
|
||||||
visibleAssignments,
|
|
||||||
visibleDemands,
|
|
||||||
setViewStart,
|
setViewStart,
|
||||||
setViewDays,
|
setViewDays,
|
||||||
filters,
|
filters,
|
||||||
@@ -359,11 +365,7 @@ function TimelineViewContent({
|
|||||||
viewMode,
|
viewMode,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
today,
|
today,
|
||||||
isLoading,
|
} = useTimelineView();
|
||||||
isInitialLoading,
|
|
||||||
isEntriesError,
|
|
||||||
totalAllocCount,
|
|
||||||
} = ctx;
|
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user