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;
|
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user