fix(web): stabilize timeline hover date matching
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
function toUtcDayTimestamp(value: Date | string): number {
|
||||
const date = new Date(value);
|
||||
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
}
|
||||
|
||||
export function updateTooltipPosition(
|
||||
positionRef: MutableRefObject<TooltipPosition>,
|
||||
tooltipRef: RefObject<HTMLDivElement | null>,
|
||||
@@ -32,17 +37,13 @@ export function findVacationHit<T extends { startDate: Date | string; endDate: D
|
||||
vacations: T[],
|
||||
date: Date,
|
||||
): T | null {
|
||||
const time = new Date(date);
|
||||
time.setHours(0, 0, 0, 0);
|
||||
const target = time.getTime();
|
||||
const target = toUtcDayTimestamp(date);
|
||||
|
||||
return (
|
||||
vacations.find((vacation) => {
|
||||
const start = new Date(vacation.startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(vacation.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
return target >= start.getTime() && target <= end.getTime();
|
||||
const start = toUtcDayTimestamp(vacation.startDate);
|
||||
const end = toUtcDayTimestamp(vacation.endDate);
|
||||
return target >= start && target <= end;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user