refactor(web): extract timeline range selection helpers

This commit is contained in:
2026-04-01 09:51:18 +02:00
parent 43f04d66c8
commit 848797b4d2
3 changed files with 155 additions and 25 deletions
@@ -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,
});
});
});
@@ -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<TState extends RangeStateLike>(
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<TState extends RangeStateLike>(
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,
};
}
+6 -25
View File
@@ -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);