From 848797b4d23c674a9d0b2b56c146a34fedf11841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 09:51:18 +0200 Subject: [PATCH] refactor(web): extract timeline range selection helpers --- .../src/hooks/timelineRangeSelection.test.ts | 85 +++++++++++++++++++ apps/web/src/hooks/timelineRangeSelection.ts | 64 ++++++++++++++ apps/web/src/hooks/useTimelineDrag.ts | 31 ++----- 3 files changed, 155 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/hooks/timelineRangeSelection.test.ts create mode 100644 apps/web/src/hooks/timelineRangeSelection.ts diff --git a/apps/web/src/hooks/timelineRangeSelection.test.ts b/apps/web/src/hooks/timelineRangeSelection.test.ts new file mode 100644 index 0000000..56315d4 --- /dev/null +++ b/apps/web/src/hooks/timelineRangeSelection.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; + +type TestRangeState = { + isSelecting: boolean; + resourceId: string | null; + startDate: Date | null; + currentDate: Date | null; + suggestedProjectId: string | null; + startClientX: number; +}; + +describe("timelineRangeSelection", () => { + it("ignores updates when no full day boundary was crossed", () => { + const state: TestRangeState = { + isSelecting: true, + resourceId: "res-1", + startDate: new Date("2025-01-15T12:00:00.000Z"), + currentDate: new Date("2025-01-15T12:00:00.000Z"), + suggestedProjectId: "proj-1", + startClientX: 100, + }; + + expect(updateRangeSelectionDraft(state, 115, 32)).toBeNull(); + }); + + it("updates the preview date when drag distance crosses into another day", () => { + const state: TestRangeState = { + isSelecting: true, + resourceId: "res-1", + startDate: new Date("2025-01-15T12:00:00.000Z"), + currentDate: new Date("2025-01-15T12:00:00.000Z"), + suggestedProjectId: "proj-1", + startClientX: 100, + }; + + const updated = updateRangeSelectionDraft(state, 168, 32); + + expect(updated).toMatchObject({ + ...state, + currentDate: new Date("2025-01-17T12:00:00.000Z"), + }); + }); + + it("returns null when finalizing an incomplete range selection", () => { + expect( + finalizeRangeSelection( + { + isSelecting: true, + resourceId: null, + startDate: new Date("2025-01-15T12:00:00.000Z"), + currentDate: null, + suggestedProjectId: null, + startClientX: 100, + }, + 200, + 300, + ), + ).toBeNull(); + }); + + it("orders the final range chronologically even when the user dragged backwards", () => { + expect( + finalizeRangeSelection( + { + isSelecting: true, + resourceId: "res-1", + startDate: new Date("2025-01-15T12:00:00.000Z"), + currentDate: new Date("2025-01-12T12:00:00.000Z"), + suggestedProjectId: "proj-1", + startClientX: 100, + }, + 200, + 300, + ), + ).toEqual({ + resourceId: "res-1", + startDate: new Date("2025-01-12T12:00:00.000Z"), + endDate: new Date("2025-01-15T12:00:00.000Z"), + suggestedProjectId: "proj-1", + anchorX: 200, + anchorY: 300, + }); + }); +}); diff --git a/apps/web/src/hooks/timelineRangeSelection.ts b/apps/web/src/hooks/timelineRangeSelection.ts new file mode 100644 index 0000000..2eedf0b --- /dev/null +++ b/apps/web/src/hooks/timelineRangeSelection.ts @@ -0,0 +1,64 @@ +import { pixelsToDays } from "~/components/timeline/dragMath.js"; + +type RangeStateLike = { + isSelecting: boolean; + resourceId: string | null; + startDate: Date | null; + currentDate: Date | null; + suggestedProjectId: string | null; + startClientX: number; +}; + +export type RangeSelectionResult = { + resourceId: string; + startDate: Date; + endDate: Date; + suggestedProjectId: string | null; + anchorX: number; + anchorY: number; +}; + +export function updateRangeSelectionDraft( + state: TState, + currentClientX: number, + cellWidth: number, +): TState | null { + if (!state.isSelecting || !state.startDate) return null; + + const daysDelta = pixelsToDays(currentClientX - state.startClientX, cellWidth); + const currentDate = new Date(state.startDate); + currentDate.setDate(currentDate.getDate() + daysDelta); + + const prevDelta = state.currentDate + ? Math.round((state.currentDate.getTime() - state.startDate.getTime()) / 86400000) + : 0; + if (daysDelta === prevDelta) return null; + + return { + ...state, + currentDate, + }; +} + +export function finalizeRangeSelection( + state: TState, + anchorX: number, + anchorY: number, +): RangeSelectionResult | null { + if (!state.isSelecting || !state.resourceId || !state.startDate) { + return null; + } + + const endDate = state.currentDate ?? state.startDate; + const [startDate, finalEnd] = + state.startDate <= endDate ? [state.startDate, endDate] : [endDate, state.startDate]; + + return { + resourceId: state.resourceId, + startDate, + endDate: finalEnd, + suggestedProjectId: state.suggestedProjectId, + anchorX, + anchorY, + }; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 0ff5c4d..a8936ec 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -17,6 +17,7 @@ import { finalizeMultiSelectDraft, updateMultiSelectDraft, } from "./timelineMultiSelect.js"; +import { finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js"; import { getTouchPoint, resolveTouchDragDecision } from "./timelineTouch.js"; const DRAG_CLICK_THRESHOLD_PX = 5; @@ -893,18 +894,8 @@ export function useTimelineDrag({ // Range select const range = rangeStateRef.current; - if (range.isSelecting && range.startDate) { - const deltaX = e.clientX - range.startClientX; - const daysDelta = pixelsToDays(deltaX, cellWidthRef.current); - 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 }; + const updated = updateRangeSelectionDraft(range, e.clientX, cellWidthRef.current); + if (updated) { rangeStateRef.current = updated; setRangeState(updated); } @@ -927,19 +918,9 @@ export function useTimelineDrag({ // 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, - }); + const selection = finalizeRangeSelection(range, e.clientX, e.clientY); + if (selection) { + onRangeSelected?.(selection); rangeStateRef.current = INITIAL_RANGE_STATE; setRangeState(INITIAL_RANGE_STATE);