chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
+653
View File
@@ -0,0 +1,653 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
// ─── Project-shift drag state ───────────────────────────────────────────────
export interface DragState {
isDragging: boolean;
projectId: string | null;
projectName: string | null;
allocationId: string | null;
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
startMouseX: number;
originalLeft: number;
blockWidth: number;
daysDelta: number;
}
export interface BlockClickInfo {
allocationId: string;
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
}
export interface ShiftPreviewData {
valid: boolean;
deltaCents: number;
wouldExceedBudget: boolean;
budgetUtilizationAfter: number;
conflictCount: number;
errors: string[];
warnings: string[];
}
const INITIAL_DRAG_STATE: DragState = {
isDragging: false,
projectId: null,
projectName: null,
allocationId: null,
originalStartDate: null,
originalEndDate: null,
currentStartDate: null,
currentEndDate: null,
startMouseX: 0,
originalLeft: 0,
blockWidth: 0,
daysDelta: 0,
};
// ─── Per-allocation drag state ──────────────────────────────────────────────
export type AllocDragMode = "move" | "resize-start" | "resize-end";
export interface AllocDragState {
isActive: boolean;
mode: AllocDragMode;
allocationId: string | null;
mutationAllocationId: string | null;
projectId: string | null;
projectName: string | null;
resourceId: string | null;
originalStartDate: Date | null;
originalEndDate: Date | null;
currentStartDate: Date | null;
currentEndDate: Date | null;
startMouseX: number;
daysDelta: number;
}
const INITIAL_ALLOC_DRAG: AllocDragState = {
isActive: false,
mode: "move",
allocationId: null,
mutationAllocationId: null,
projectId: null,
projectName: null,
resourceId: null,
originalStartDate: null,
originalEndDate: null,
currentStartDate: null,
currentEndDate: null,
startMouseX: 0,
daysDelta: 0,
};
// ─── Range-select state ─────────────────────────────────────────────────────
export interface RangeState {
isSelecting: boolean;
resourceId: string | null;
startDate: Date | null;
currentDate: Date | null;
suggestedProjectId: string | null;
startClientX: number;
}
export interface RangeSelectedInfo {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
}
const INITIAL_RANGE_STATE: RangeState = {
isSelecting: false,
resourceId: null,
startDate: null,
currentDate: null,
suggestedProjectId: null,
startClientX: 0,
};
// ─── Hook ───────────────────────────────────────────────────────────────────
export interface AllocationMovedSnapshot {
allocationId: string;
mutationAllocationId: string;
projectName: string;
before: { startDate: Date; endDate: Date };
after: { startDate: Date; endDate: Date };
}
export function useTimelineDrag({
cellWidth,
onShiftApplied,
onBlockClick,
onRangeSelected,
onAllocationMoved,
}: {
cellWidth: number;
onShiftApplied?: (projectId: string) => void;
onBlockClick?: (info: BlockClickInfo) => void;
onRangeSelected?: (info: RangeSelectedInfo) => void;
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
}) {
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE);
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
// Keep always-current refs for values used inside document event handlers
const cellWidthRef = useRef(cellWidth);
cellWidthRef.current = cellWidth;
// Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll
const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({ x: 0, y: 0, decided: false });
const onBlockClickRef = useRef(onBlockClick);
onBlockClickRef.current = onBlockClick;
const onAllocationMovedRef = useRef(onAllocationMoved);
onAllocationMovedRef.current = onAllocationMoved;
const utils = trpc.useUtils();
// Project-shift preview
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
{
projectId: dragState.projectId ?? "",
newStartDate: dragState.currentStartDate ?? new Date(),
newEndDate: dragState.currentEndDate ?? new Date(),
},
{
enabled:
dragState.isDragging &&
dragState.projectId !== null &&
dragState.daysDelta !== 0 &&
dragState.currentStartDate !== null,
staleTime: 0,
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({
onSuccess: (data: { project: { id: string } }) => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
void utils.project.list.invalidate();
onShiftApplied?.(data.project.id);
},
}) as { isPending: boolean; mutate: (...args: unknown[]) => void; mutateAsync: (...args: unknown[]) => Promise<unknown> };
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
const snap = pendingSnapshotRef.current;
if (snap) {
onAllocationMovedRef.current?.(snap);
pendingSnapshotRef.current = null;
}
},
});
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
const onProjectBarMouseDown = useCallback(
(e: React.MouseEvent, opts: {
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
}) => {
e.preventDefault();
e.stopPropagation();
const state: DragState = {
isDragging: true,
projectId: opts.projectId,
projectName: opts.projectName,
allocationId: null,
originalStartDate: opts.startDate,
originalEndDate: opts.endDate,
currentStartDate: opts.startDate,
currentEndDate: opts.endDate,
startMouseX: e.clientX,
originalLeft: 0,
blockWidth: 0,
daysDelta: 0,
};
dragStateRef.current = state;
setDragState(state);
},
[],
);
// Legacy — kept for backward compat (triggers project shift from allocation block)
const onBlockMouseDown = useCallback(
(e: React.MouseEvent, opts: {
projectId: string;
projectName: string;
allocationId?: string;
startDate: Date;
endDate: Date;
blockLeft: number;
blockWidth: number;
}) => {
e.preventDefault();
e.stopPropagation();
const state: DragState = {
isDragging: true,
projectId: opts.projectId,
projectName: opts.projectName,
allocationId: opts.allocationId ?? null,
originalStartDate: opts.startDate,
originalEndDate: opts.endDate,
currentStartDate: opts.startDate,
currentEndDate: opts.endDate,
startMouseX: e.clientX,
originalLeft: opts.blockLeft,
blockWidth: opts.blockWidth,
daysDelta: 0,
};
dragStateRef.current = state;
setDragState(state);
},
[],
);
// ── Per-allocation drag — document-level listeners ─────────────────────────
//
// Uses document.addEventListener instead of React canvas events so the drag
// works reliably even when the cursor leaves the canvas boundary (e.g. while
// moving quickly or scrolling into the sticky header area).
const onAllocMouseDown = useCallback(
(e: React.MouseEvent, opts: {
mode: AllocDragMode;
allocationId: string;
mutationAllocationId?: string;
projectId: string;
projectName: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
}) => {
e.preventDefault();
e.stopPropagation();
const initial: AllocDragState = {
isActive: true,
mode: opts.mode,
allocationId: opts.allocationId,
mutationAllocationId: opts.mutationAllocationId ?? opts.allocationId,
projectId: opts.projectId,
projectName: opts.projectName,
resourceId: opts.resourceId,
originalStartDate: opts.startDate,
originalEndDate: opts.endDate,
currentStartDate: opts.startDate,
currentEndDate: opts.endDate,
startMouseX: e.clientX,
daysDelta: 0,
};
allocDragRef.current = initial;
setAllocDragState(initial);
// ── document handlers ────────────────────────────────────────────────
function handleMove(ev: MouseEvent) {
const alloc = allocDragRef.current;
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;
const deltaX = ev.clientX - alloc.startMouseX;
const daysDelta = Math.round(deltaX / cellWidthRef.current);
if (daysDelta === alloc.daysDelta) return;
const newStart = new Date(alloc.originalStartDate);
const newEnd = new Date(alloc.originalEndDate);
if (alloc.mode === "move") {
newStart.setDate(newStart.getDate() + daysDelta);
newEnd.setDate(newEnd.getDate() + daysDelta);
} else if (alloc.mode === "resize-start") {
newStart.setDate(newStart.getDate() + daysDelta);
if (newStart >= newEnd) newStart.setTime(newEnd.getTime() - 86400000);
} else {
// resize-end
newEnd.setDate(newEnd.getDate() + daysDelta);
if (newEnd <= newStart) newEnd.setTime(newStart.getTime() + 86400000);
}
const updated: AllocDragState = {
...alloc,
currentStartDate: newStart,
currentEndDate: newEnd,
daysDelta,
};
allocDragRef.current = updated;
setAllocDragState(updated);
}
function handleUp() {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
const alloc = allocDragRef.current;
if (!alloc.isActive) return;
if (alloc.daysDelta === 0 && alloc.allocationId) {
// No movement → treat as click, open alloc popover
onBlockClickRef.current?.({
allocationId: alloc.allocationId,
projectId: alloc.projectId ?? "",
projectName: alloc.projectName ?? "",
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
} else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
pendingSnapshotRef.current = {
allocationId: alloc.allocationId,
mutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId,
projectName: alloc.projectName ?? "",
before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! },
after: { startDate: alloc.currentStartDate, endDate: alloc.currentEndDate },
};
updateAllocMutation.mutate({
allocationId: alloc.mutationAllocationId ?? alloc.allocationId,
startDate: alloc.currentStartDate,
endDate: alloc.currentEndDate,
});
}
allocDragRef.current = INITIAL_ALLOC_DRAG;
setAllocDragState(INITIAL_ALLOC_DRAG);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleUp);
},
[updateAllocMutation.mutate], // mutate is stable across renders (React Query guarantee)
);
// ── Range-select ────────────────────────────────────────────────────────────
const onRowMouseDown = useCallback(
(e: React.MouseEvent, opts: {
resourceId: string;
startDate: Date;
suggestedProjectId?: string;
}) => {
if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return;
e.preventDefault();
const state: RangeState = {
isSelecting: true,
resourceId: opts.resourceId,
startDate: opts.startDate,
currentDate: opts.startDate,
suggestedProjectId: opts.suggestedProjectId ?? null,
startClientX: e.clientX,
};
rangeStateRef.current = state;
setRangeState(state);
},
[],
);
// ── Canvas-level handlers (project shift + range select only) ──────────────
const onCanvasMouseMove = useCallback(
(e: React.MouseEvent) => {
// Project shift
const drag = dragStateRef.current;
if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
const deltaX = e.clientX - drag.startMouseX;
const daysDelta = Math.round(deltaX / cellWidth);
if (daysDelta !== drag.daysDelta) {
const newStart = new Date(drag.originalStartDate);
newStart.setDate(newStart.getDate() + daysDelta);
const newEnd = new Date(drag.originalEndDate);
newEnd.setDate(newEnd.getDate() + daysDelta);
const updated: DragState = { ...drag, currentStartDate: newStart, currentEndDate: newEnd, daysDelta };
dragStateRef.current = updated;
setDragState(updated);
}
return;
}
// Range select
const range = rangeStateRef.current;
if (range.isSelecting && range.startDate) {
const deltaX = e.clientX - range.startClientX;
const daysDelta = Math.round(deltaX / cellWidth);
const currentDate = new Date(range.startDate);
currentDate.setDate(currentDate.getDate() + daysDelta);
const prevDelta = range.currentDate
? Math.round((range.currentDate.getTime() - range.startDate.getTime()) / 86400000)
: 0;
if (daysDelta === prevDelta) return;
const updated: RangeState = { ...range, currentDate };
rangeStateRef.current = updated;
setRangeState(updated);
}
},
[cellWidth],
);
const onCanvasMouseUp = useCallback(
async (e: React.MouseEvent) => {
// Project shift
const drag = dragStateRef.current;
if (drag.isDragging) {
if (drag.daysDelta === 0) {
if (drag.projectId && drag.originalStartDate && drag.originalEndDate) {
onBlockClick?.({
allocationId: drag.allocationId ?? "",
projectId: drag.projectId,
projectName: drag.projectName ?? "",
startDate: drag.originalStartDate,
endDate: drag.originalEndDate,
});
}
} else if (drag.projectId && drag.currentStartDate && drag.currentEndDate) {
try {
await applyShiftMutation.mutateAsync({
projectId: drag.projectId,
newStartDate: drag.currentStartDate,
newEndDate: drag.currentEndDate,
});
} catch {
// Validation error — revert visually
}
}
dragStateRef.current = INITIAL_DRAG_STATE;
setDragState(INITIAL_DRAG_STATE);
return;
}
// Range select
const range = rangeStateRef.current;
if (range.isSelecting && range.resourceId && range.startDate) {
const endDate = range.currentDate ?? range.startDate;
const [startDate, finalEnd] =
range.startDate <= endDate
? [range.startDate, endDate]
: [endDate, range.startDate];
onRangeSelected?.({
resourceId: range.resourceId,
startDate,
endDate: finalEnd,
suggestedProjectId: range.suggestedProjectId,
anchorX: e.clientX,
anchorY: e.clientY,
});
rangeStateRef.current = INITIAL_RANGE_STATE;
setRangeState(INITIAL_RANGE_STATE);
}
},
[applyShiftMutation, onBlockClick, onRangeSelected],
);
const onCanvasMouseLeave = useCallback(() => {
// Only cancel project-shift and range-select on canvas leave.
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
if (dragStateRef.current.isDragging) {
dragStateRef.current = INITIAL_DRAG_STATE;
setDragState(INITIAL_DRAG_STATE);
}
if (rangeStateRef.current.isSelecting) {
rangeStateRef.current = INITIAL_RANGE_STATE;
setRangeState(INITIAL_RANGE_STATE);
}
}, []);
// ── Touch support ───────────────────────────────────────────────────────────
// Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback)
function toClientX(e: React.TouchEvent): number {
return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0;
}
const onProjectBarTouchStart = useCallback(
(e: React.TouchEvent, opts: {
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
}) => {
e.preventDefault();
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
onProjectBarMouseDown(
{ clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {} } as unknown as React.MouseEvent,
opts,
);
},
[onProjectBarMouseDown],
);
const onAllocTouchStart = useCallback(
(e: React.TouchEvent, opts: {
mode: AllocDragMode;
allocationId: string;
mutationAllocationId?: string;
projectId: string;
projectName: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
}) => {
e.preventDefault();
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
onAllocMouseDown(
{ clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {} } as unknown as React.MouseEvent,
opts,
);
},
[onAllocMouseDown],
);
const onRowTouchStart = useCallback(
(e: React.TouchEvent, opts: {
resourceId: string;
startDate: Date;
suggestedProjectId?: string;
}) => {
e.preventDefault();
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: false };
onRowMouseDown(
{ clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {} } as unknown as React.MouseEvent,
opts,
);
},
[onRowMouseDown],
);
const onCanvasTouchMove = useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
// Scroll vs drag disambiguation: once decided, stick with the decision
if (!touchStartRef.current.decided) {
const dx = Math.abs(touch.clientX - touchStartRef.current.x);
const dy = Math.abs(touch.clientY - touchStartRef.current.y);
if (dx > 8 || dy > 8) {
touchStartRef.current.decided = true;
if (dy > dx) return; // vertical scroll wins — don't intercept
} else {
return; // haven't moved enough to decide yet
}
}
onCanvasMouseMove({ clientX: touch.clientX } as React.MouseEvent);
},
[onCanvasMouseMove],
);
const onCanvasTouchEnd = useCallback(
async (e: React.TouchEvent) => {
const clientX = e.changedTouches[0]?.clientX ?? 0;
const clientY = e.changedTouches[0]?.clientY ?? 0;
await onCanvasMouseUp({ clientX, clientY } as React.MouseEvent);
},
[onCanvasMouseUp],
);
// ── Derived ─────────────────────────────────────────────────────────────────
const shiftPreview: ShiftPreviewData | null =
dragState.isDragging && dragState.daysDelta !== 0 && previewData
? {
valid: previewData.valid,
deltaCents: previewData.costImpact.deltaCents,
wouldExceedBudget: previewData.costImpact.wouldExceedBudget,
budgetUtilizationAfter: previewData.costImpact.budgetUtilizationAfter,
conflictCount: previewData.conflictDetails.length,
errors: previewData.errors.map((e) => e.message),
warnings: previewData.warnings.map((w) => w.message),
}
: null;
return {
dragState,
allocDragState,
rangeState,
shiftPreview,
isPreviewLoading,
isApplying: applyShiftMutation.isPending,
isAllocSaving: updateAllocMutation.isPending,
onProjectBarMouseDown,
onBlockMouseDown,
onAllocMouseDown,
onRowMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
// Touch equivalents
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
onCanvasTouchMove,
onCanvasTouchEnd,
};
}