diff --git a/apps/web/src/components/timeline/timelineHover.test.ts b/apps/web/src/components/timeline/timelineHover.test.ts new file mode 100644 index 0000000..7bb7896 --- /dev/null +++ b/apps/web/src/components/timeline/timelineHover.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/components/timeline/timelineHover.ts b/apps/web/src/components/timeline/timelineHover.ts index 4868da3..b85c51a 100644 --- a/apps/web/src/components/timeline/timelineHover.ts +++ b/apps/web/src/components/timeline/timelineHover.ts @@ -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, tooltipRef: RefObject, @@ -32,17 +37,13 @@ export function findVacationHit { - 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 ); }