feat(platform): checkpoint current implementation state

This commit is contained in:
2026-04-01 07:42:03 +02:00
parent 3e53471f05
commit 8c5be51251
125 changed files with 10269 additions and 17808 deletions
@@ -9,6 +9,7 @@ import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
@@ -33,6 +34,7 @@ import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
import { ProjectColorLegend } from "./ProjectColorLegend.js";
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
import type { TimelineVisualOverrides } from "./allocationVisualState.js";
// ─── Entry point ────────────────────────────────────────────────────────────
// Two-layer mount: the outer shell creates drag state + project context,
@@ -56,8 +58,10 @@ export function TimelineView() {
const [popover, setPopover] = useState<{
allocationId: string;
projectId: string;
allocation?: TimelineAssignmentEntry | null;
x: number;
y: number;
contextDate?: Date;
} | null>(null);
const [newAllocPopover, setNewAllocPopover] = useState<{
resourceId: string;
@@ -88,6 +92,8 @@ export function TimelineView() {
rangeState,
multiSelectState,
setMultiSelectState,
optimisticAllocations,
reconcileOptimisticAllocations,
shiftPreview,
isPreviewLoading,
isApplying,
@@ -106,7 +112,7 @@ export function TimelineView() {
onCanvasTouchMove,
onCanvasTouchEnd,
} = useTimelineDrag({
cellWidth: cellWidthRef.current,
cellWidthRef,
onBlockClick: (info) => {
setPopover({
allocationId: info.allocationId,
@@ -170,6 +176,8 @@ export function TimelineView() {
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
optimisticAllocations={optimisticAllocations}
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
@@ -214,6 +222,8 @@ function TimelineViewContent({
rangeState,
multiSelectState,
setMultiSelectState,
optimisticAllocations,
reconcileOptimisticAllocations,
onCanvasRightMouseDown,
clearMultiSelect,
shiftPreview,
@@ -251,6 +261,8 @@ function TimelineViewContent({
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
optimisticAllocations: TimelineVisualOverrides;
reconcileOptimisticAllocations: ReturnType<typeof useTimelineDrag>["reconcileOptimisticAllocations"];
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
@@ -269,7 +281,14 @@ function TimelineViewContent({
onCanvasTouchMove: ReturnType<typeof useTimelineDrag>["onCanvasTouchMove"];
onCanvasTouchEnd: ReturnType<typeof useTimelineDrag>["onCanvasTouchEnd"];
contextResourceIds: string[];
popover: { allocationId: string; projectId: string; x: number; y: number } | null;
popover: {
allocationId: string;
projectId: string;
allocation?: TimelineAssignmentEntry | null;
x: number;
y: number;
contextDate?: Date;
} | null;
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
newAllocPopover: {
resourceId: string;
@@ -300,6 +319,8 @@ function TimelineViewContent({
viewStart,
viewEnd,
viewDays,
visibleAssignments,
visibleDemands,
setViewStart,
setViewDays,
filters,
@@ -348,6 +369,23 @@ function TimelineViewContent({
const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
useEffect(() => {
if (optimisticAllocations.size === 0) return;
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
}, [
optimisticAllocations,
reconcileOptimisticAllocations,
visibleAssignments,
visibleDemands,
]);
useEffect(() => {
if (!hasActivePointerOverlay) return;
setPopover(null);
setDemandPopover(null);
setResourceHover(null);
}, [hasActivePointerOverlay]);
// ─── Keep selection overlay visible while popover is open ───────────────────
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
? rangeState
@@ -386,10 +424,12 @@ function TimelineViewContent({
info: {
allocationId: string;
projectId: string;
contextDate?: Date;
},
anchorX: number,
anchorY: number,
) {
if (hasActivePointerOverlay) return;
// Check if this is a demand (not an assignment) — route to DemandPopover
const demands = openDemandsByProject.get(info.projectId);
const demand = demands?.find((d) => d.id === info.allocationId);
@@ -397,11 +437,19 @@ function TimelineViewContent({
setDemandPopover({ demand, x: anchorX, y: anchorY });
return;
}
const allocation = visibleAssignments.find((entry) => (
entry.id === info.allocationId
|| entry.entityId === info.allocationId
|| entry.sourceAllocationId === info.allocationId
|| getPlanningEntryMutationId(entry) === info.allocationId
)) ?? null;
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
allocation,
x: anchorX,
y: anchorY,
...(info.contextDate ? { contextDate: info.contextDate } : {}),
});
}
@@ -505,12 +553,22 @@ function TimelineViewContent({
// ─── Resource hover card — event delegation on label columns ──────────────
useEffect(() => {
if (hasActivePointerOverlay) {
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
setResourceHover(null);
return;
}
const canvas = canvasRef.current;
if (!canvas) return;
const HOVER_DELAY = 400;
function onMouseOver(e: MouseEvent) {
if (hasActivePointerOverlay) return;
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-resource-hover-id]");
if (!target) return;
const rid = target.dataset.resourceHoverId;
@@ -532,6 +590,7 @@ function TimelineViewContent({
}
function onMouseOut(e: MouseEvent) {
if (hasActivePointerOverlay) return;
const related = e.relatedTarget as HTMLElement | null;
// Don't close if moving into another resource-hover target or the hover card itself
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
@@ -557,7 +616,7 @@ function TimelineViewContent({
resourceHoverTimerRef.current = null;
}
};
}, [resourceHover?.resourceId, isInitialLoading]); // eslint-disable-line react-hooks/exhaustive-deps
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
function handleContainerScroll() {
@@ -682,6 +741,8 @@ function TimelineViewContent({
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
multiSelectState={multiSelectState}
optimisticAllocations={optimisticAllocations}
suppressHoverInteractions={hasActivePointerOverlay}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
@@ -707,9 +768,12 @@ function TimelineViewContent({
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
onOpenPanel={isSelfServiceTimeline ? () => undefined : setOpenPanelProjectId}
onOpenDemandClick={isSelfServiceTimeline ? () => undefined : (demand, anchorX, anchorY) => {
if (hasActivePointerOverlay) return;
setDemandPopover({ demand, x: anchorX, y: anchorY });
}}
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
optimisticAllocations={optimisticAllocations}
suppressHoverInteractions={hasActivePointerOverlay}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
@@ -827,7 +891,7 @@ function TimelineViewContent({
)}
{/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline && popover && (() => {
{!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
@@ -863,6 +927,7 @@ function TimelineViewContent({
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
@@ -870,12 +935,13 @@ function TimelineViewContent({
}}
anchorX={popover.x}
anchorY={popover.y}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
})()}
{/* Demand popover */}
{!isSelfServiceTimeline && demandPopover && (
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
@@ -962,7 +1028,7 @@ function TimelineViewContent({
)}
{/* Resource hover card */}
{resourceHover && (
{!hasActivePointerOverlay && resourceHover && (
<ResourceHoverCard
resourceId={resourceHover.resourceId}
anchorEl={resourceHover.anchorEl}