fix(web): stabilize timeline hover date matching

This commit is contained in:
2026-04-01 09:15:24 +02:00
parent 71c4e61735
commit 403d59ad73
2 changed files with 111 additions and 8 deletions
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import {
buildDemandHoverData,
findVacationHit,
scheduleVacationHoverUpdate,
} from "./timelineHover.js";
describe("timelineHover", () => {
it("matches vacation hits inclusively across differing time components", () => {
const hit = findVacationHit(
[
{
id: "vacation_1",
startDate: "2026-04-10T15:00:00.000Z",
endDate: "2026-04-12T01:00:00.000Z",
},
],
new Date("2026-04-12T23:59:59.000Z"),
);
expect(hit?.id).toBe("vacation_1");
});
it("does not schedule a second hover frame while one is already pending", () => {
const frameRef = { current: 42 };
const hoveredKeyRef = { current: null };
const onHoverChange = vi.fn();
const requestAnimationFrameMock = vi.fn();
vi.stubGlobal("requestAnimationFrame", requestAnimationFrameMock);
scheduleVacationHoverUpdate({
frameRef,
hoveredKeyRef,
resourceId: "resource_1",
clientX: 160,
rect: {} as DOMRect,
xToDate: () => new Date("2026-04-10T00:00:00.000Z"),
vacations: [{ id: "vacation_1", startDate: "2026-04-10", endDate: "2026-04-12" }],
onHoverChange,
});
expect(requestAnimationFrameMock).not.toHaveBeenCalled();
expect(onHoverChange).not.toHaveBeenCalled();
});
it("suppresses duplicate hover notifications when the hovered vacation does not change", () => {
const scheduledFrames: Array<(timestamp: number) => void> = [];
vi.stubGlobal("requestAnimationFrame", vi.fn((callback: (timestamp: number) => void) => {
scheduledFrames.push(callback);
return 7;
}));
const frameRef = { current: null as number | null };
const hoveredKeyRef = { current: "resource_1:vacation_1" };
const onHoverChange = vi.fn();
scheduleVacationHoverUpdate({
frameRef,
hoveredKeyRef,
resourceId: "resource_1",
clientX: 160,
rect: {} as DOMRect,
xToDate: () => new Date("2026-04-11T12:00:00.000Z"),
vacations: [{ id: "vacation_1", startDate: "2026-04-10", endDate: "2026-04-12" }],
onHoverChange,
});
expect(frameRef.current).toBe(7);
expect(scheduledFrames).toHaveLength(1);
scheduledFrames[0]!(0);
expect(frameRef.current).toBeNull();
expect(onHoverChange).not.toHaveBeenCalled();
expect(hoveredKeyRef.current).toBe("resource_1:vacation_1");
});
it("omits cost data and clamps total hours to at least one day for inverted demand ranges", () => {
const hoverData = buildDemandHoverData({
roleEntity: null,
role: null,
project: {
name: "Project Atlas",
shortCode: "ATL",
},
requestedHeadcount: 2,
unfilledHeadcount: 1,
startDate: "2026-04-14",
endDate: "2026-04-13",
hoursPerDay: 6,
percentage: 50,
status: "open",
dailyCostCents: 0,
} as never);
expect(hoverData.roleName).toBe("Open demand");
expect(hoverData.totalHours).toBe(6);
expect(hoverData).not.toHaveProperty("dailyCostCents");
expect(hoverData).not.toHaveProperty("totalCostCents");
});
});
@@ -7,6 +7,11 @@ export type TooltipPosition = {
top: number; top: number;
}; };
function toUtcDayTimestamp(value: Date | string): number {
const date = new Date(value);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
}
export function updateTooltipPosition( export function updateTooltipPosition(
positionRef: MutableRefObject<TooltipPosition>, positionRef: MutableRefObject<TooltipPosition>,
tooltipRef: RefObject<HTMLDivElement | null>, tooltipRef: RefObject<HTMLDivElement | null>,
@@ -32,17 +37,13 @@ export function findVacationHit<T extends { startDate: Date | string; endDate: D
vacations: T[], vacations: T[],
date: Date, date: Date,
): T | null { ): T | null {
const time = new Date(date); const target = toUtcDayTimestamp(date);
time.setHours(0, 0, 0, 0);
const target = time.getTime();
return ( return (
vacations.find((vacation) => { vacations.find((vacation) => {
const start = new Date(vacation.startDate); const start = toUtcDayTimestamp(vacation.startDate);
start.setHours(0, 0, 0, 0); const end = toUtcDayTimestamp(vacation.endDate);
const end = new Date(vacation.endDate); return target >= start && target <= end;
end.setHours(0, 0, 0, 0);
return target >= start.getTime() && target <= end.getTime();
}) ?? null }) ?? null
); );
} }