feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
@@ -28,9 +29,14 @@ export function AllocationPopover({
anchorX,
anchorY,
}: AllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX, y: anchorY },
width: 300,
estimatedHeight: 360,
onClose,
});
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId },
@@ -63,17 +69,6 @@ export function AllocationPopover({
},
});
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
function toDateInput(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -93,18 +88,9 @@ export function AllocationPopover({
});
}
// Position popover so it stays on screen
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(anchorX, window.innerWidth - 320),
top: Math.min(anchorY + 8, window.innerHeight - 360),
zIndex: 50,
width: 300,
};
if (isLoading || !allocation) {
return (
<div ref={ref} style={popoverStyle} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
Loading...
</div>
);
@@ -115,7 +101,7 @@ export function AllocationPopover({
return (
<div
ref={ref}
style={popoverStyle}
style={style}
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef } from "react";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatCents, formatDateLong } from "~/lib/format.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
interface DemandPopoverProps {
demand: TimelineDemandEntry;
@@ -21,17 +21,12 @@ export function DemandPopover({
anchorX,
anchorY,
}: DemandPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX, y: anchorY },
width: 300,
estimatedHeight: 340,
onClose,
});
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
@@ -41,18 +36,10 @@ export function DemandPopover({
const totalHours = demand.hoursPerDay * days;
const budgetCents = demand.dailyCostCents * days;
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(anchorX, window.innerWidth - 320),
top: Math.min(anchorY + 8, window.innerHeight - 340),
zIndex: 50,
width: 300,
};
return (
<div
ref={ref}
style={popoverStyle}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { DateInput } from "~/components/ui/DateInput.js";
interface NewAllocationPopoverProps {
@@ -36,7 +37,12 @@ export function NewAllocationPopover({
onClose,
onCreated,
}: NewAllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX - 10, y: anchorY },
width: 320,
estimatedHeight: 440,
onClose,
});
const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState("");
@@ -67,17 +73,6 @@ export function NewAllocationPopover({
},
});
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
function handleCreate() {
if (!selectedProjectId) return;
createMutation.mutate({
@@ -93,13 +88,10 @@ export function NewAllocationPopover({
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
return (
<div
ref={ref}
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
>
{/* Header */}
@@ -1,9 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { formatCents } from "~/lib/format.js";
import type { SkillEntry } from "@capakraken/shared";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
interface ResourceHoverCardProps {
resourceId: string;
@@ -12,34 +12,20 @@ interface ResourceHoverCardProps {
}
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: 0, top: 0 });
const { ref, style } = useViewportPopover({
anchor: { kind: "element", element: anchorEl },
width: 280,
estimatedHeight: 320,
onClose,
side: "right",
ignoreElements: [anchorEl],
});
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
{ id: resourceId },
{ staleTime: 60_000 },
);
// Position relative to anchor element
useEffect(() => {
const rect = anchorEl.getBoundingClientRect();
setPos({
left: rect.right + 8,
top: Math.min(rect.top, window.innerHeight - 320),
});
}, [anchorEl]);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose, anchorEl]);
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
const mainSkills = skills.filter((s) => s.isMainSkill);
const topSkills = skills
@@ -47,19 +33,11 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
.sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 6);
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(pos.left, window.innerWidth - 300),
top: pos.top,
zIndex: 50,
width: 280,
};
return (
<div
ref={ref}
data-resource-hover-card="true"
style={popoverStyle}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
onMouseLeave={onClose}
>
@@ -113,6 +113,16 @@ export type VacationEntry = {
halfDayPart?: string | null;
};
export type HolidayOverlayEntry = {
id: string;
resourceId: string;
type: string;
status: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
};
// ─── Context shape ──────────────────────────────────────────────────────────
export interface TimelineContextValue {
@@ -314,9 +324,43 @@ export function TimelineProvider({
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery(
{
startDate: viewStart,
endDate: viewEnd,
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
},
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const vacationsByResource = useMemo(() => {
const map = new Map<string, VacationEntry[]>();
for (const vacation of vacationEntries as VacationEntry[]) {
const mergedEntries = [...(vacationEntries as VacationEntry[])];
const existingKeys = new Set(
mergedEntries.map((vacation) => {
const start = new Date(vacation.startDate).toISOString().slice(0, 10);
const end = new Date(vacation.endDate).toISOString().slice(0, 10);
return `${vacation.resourceId}:${vacation.type}:${start}:${end}`;
}),
);
for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) {
const start = new Date(holiday.startDate).toISOString().slice(0, 10);
const end = new Date(holiday.endDate).toISOString().slice(0, 10);
const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`;
if (existingKeys.has(key)) {
continue;
}
existingKeys.add(key);
mergedEntries.push(holiday as VacationEntry);
}
for (const vacation of mergedEntries) {
const existing = map.get(vacation.resourceId);
if (existing) {
existing.push(vacation);
@@ -325,7 +369,7 @@ export function TimelineProvider({
}
}
return map;
}, [vacationEntries]);
}, [holidayOverlayEntries, vacationEntries]);
// When EID filter is active, explicitly fetch those resources.
const { data: eidFilterData } = trpc.resource.list.useQuery(
@@ -2,7 +2,8 @@
import { clsx } from "clsx";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { useRef, useState, type RefObject } from "react";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js";
export interface TimelineFilters {
@@ -159,55 +160,12 @@ export function TimelineFilter({
isOpen,
onClose,
}: TimelineFilterProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 });
const updatePanelPosition = useCallback(() => {
const trigger = anchorRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? 320;
const viewportPadding = 16;
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
setPanelPosition({
top: rect.bottom + 8,
left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)),
});
}, [anchorRef]);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
onClose();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [anchorRef, isOpen, onClose, updatePanelPosition]);
const { panelRef, position } = useAnchoredOverlay<HTMLDivElement>({
open: isOpen,
onClose,
align: "end",
triggerRef: anchorRef,
});
if (!isOpen) return null;
@@ -221,7 +179,7 @@ export function TimelineFilter({
return createPortal(
<div
ref={panelRef}
style={{ position: "fixed", top: panelPosition.top, left: panelPosition.left }}
style={{ position: "fixed", top: position.top, left: position.left }}
className="z-[9998] w-80 rounded-2xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-4 flex items-center justify-between">
@@ -188,8 +188,10 @@ function TimelineProjectPanelInner({
} | null>(null);
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
const demandTooltipRef = useRef<HTMLDivElement | null>(null);
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
const [heatmapHover, setHeatmapHover] = useState<{
date: Date;
@@ -206,6 +208,22 @@ function TimelineProjectPanelInner({
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
}>(null);
const [demandHover, setDemandHover] = useState<null | {
roleName: string;
roleColor: string;
projectName: string;
projectShortCode?: string | null;
requestedHeadcount: number;
unfilledHeadcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
totalHours: number;
percentage?: number;
status?: string;
totalCostCents?: number;
dailyCostCents?: number;
}>(null);
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
const dateIndexByTime = new Map<number, number>();
@@ -472,6 +490,7 @@ function TimelineProjectPanelInner({
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
date.setHours(0, 0, 0, 0);
const time = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit =
@@ -507,18 +526,58 @@ function TimelineProjectPanelInner({
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
const shouldClearDemand = demandHover !== null;
lastHeatmapDayRef.current = -1;
lastHeatmapResourceRef.current = null;
hoveredVacationKeyRef.current = null;
if (shouldClearHeatmap || shouldClearVacation) {
if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) {
startTransition(() => {
if (shouldClearHeatmap) setHeatmapHover(null);
if (shouldClearVacation) setVacationHover(null);
if (shouldClearDemand) setDemandHover(null);
});
}
}, []);
}, [demandHover]);
const handleDemandHoverMove = useCallback(
(e: React.MouseEvent, demand: TimelineDemandEntry) => {
demandTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 36 };
if (demandTooltipRef.current) {
demandTooltipRef.current.style.left = `${demandTooltipPosRef.current.left}px`;
demandTooltipRef.current.style.top = `${demandTooltipPosRef.current.top}px`;
}
const startDate = new Date(demand.startDate);
const endDate = new Date(demand.endDate);
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
startTransition(() => {
setDemandHover({
roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand",
roleColor: demand.roleEntity?.color ?? "#f59e0b",
projectName: demand.project.name,
projectShortCode: demand.project.shortCode,
requestedHeadcount: demand.requestedHeadcount,
unfilledHeadcount: demand.unfilledHeadcount,
startDate: demand.startDate,
endDate: demand.endDate,
hoursPerDay: demand.hoursPerDay,
totalHours: demand.hoursPerDay * days,
percentage: demand.percentage,
status: demand.status,
...(demand.dailyCostCents > 0
? {
totalCostCents: demand.dailyCostCents * days,
dailyCostCents: demand.dailyCostCents,
}
: {}),
});
});
},
[],
);
useEffect(
() => () => {
@@ -672,6 +731,8 @@ function TimelineProjectPanelInner({
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
handleDemandHoverMove,
clearHoverTooltips,
multiSelectState,
allocDragState,
)
@@ -699,6 +760,9 @@ function TimelineProjectPanelInner({
</div>
<div
data-testid="timeline-project-resource-row-canvas"
data-project-id={row.project.id}
data-resource-id={row.resource.id}
className="relative overflow-hidden touch-none"
style={{
width: totalCanvasWidth,
@@ -792,8 +856,11 @@ function TimelineProjectPanelInner({
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
vacationTooltipPos={vacationTooltipPosRef.current}
demandTooltipRef={demandTooltipRef}
demandTooltipPos={demandTooltipPosRef.current}
heatmapHover={heatmapHover}
vacationHover={vacationHover}
demandHover={demandHover}
/>
</div>
);
@@ -852,6 +919,8 @@ function renderOpenDemandRow(
anchorX: number,
anchorY: number,
) => void,
onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
onClearHoverTooltips: () => void,
multiSelectState: MultiSelectState,
allocDragState: AllocDragState,
) {
@@ -889,6 +958,7 @@ function renderOpenDemandRow(
<div
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
style={{ width: totalCanvasWidth, height: rowHeight }}
onMouseLeave={onClearHoverTooltips}
>
{rowGridLines}
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
@@ -962,7 +1032,6 @@ function renderOpenDemandRow(
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} ${formatDateLong(allocEnd)}`}
style={{
left: left + 2,
width: width - 4,
@@ -986,6 +1055,7 @@ function renderOpenDemandRow(
e.clientY,
);
}}
onMouseMove={(e) => onDemandHoverMove(e, alloc)}
>
{/* Left resize handle */}
<div
@@ -1,8 +1,9 @@
"use client";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js";
import type { TimelineFilters } from "./TimelineFilter.js";
@@ -20,68 +21,22 @@ function TimelineFilterDropdown({
tooltipContent?: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
const updatePanelPosition = useCallback(() => {
const trigger = dropdownRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const viewportPadding = 16;
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
setPanelPosition({
top: rect.bottom + 8,
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
minWidth: rect.width,
});
}, []);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setIsOpen(false);
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("keydown", handleEscape);
};
}, [isOpen, updatePanelPosition]);
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
open: isOpen,
onClose: () => setIsOpen(false),
matchTriggerWidth: true,
});
return (
<div ref={dropdownRef} className="relative">
<div ref={triggerRef} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
onClick={() => {
const nextOpen = !isOpen;
handleOpenChange(nextOpen);
setIsOpen(nextOpen);
}}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
>
<span className="text-left">{label}</span>
@@ -95,9 +50,9 @@ function TimelineFilterDropdown({
ref={panelRef}
style={{
position: "fixed",
top: panelPosition.top,
left: panelPosition.left,
minWidth: panelPosition.minWidth,
top: position.top,
left: position.left,
minWidth: position.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>
@@ -359,6 +359,7 @@ function TimelineResourcePanelInner({
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
date.setHours(0, 0, 0, 0);
const t = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit =
@@ -494,6 +495,10 @@ function TimelineResourcePanelInner({
{/* Row canvas */}
<div
data-testid="timeline-resource-row-canvas"
data-resource-id={resource.id}
data-resource-eid={resource.eid}
data-resource-name={resource.displayName}
className="relative overflow-hidden touch-none"
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
onMouseDown={(e) => {
@@ -542,10 +547,11 @@ function TimelineResourcePanelInner({
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocks(
vacationBlocksByResource.get(resource.id) ?? [],
rowHeight,
)}
{filters.showVacations &&
renderVacationBlocks(
vacationBlocksByResource.get(resource.id) ?? [],
rowHeight,
)}
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" &&
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
@@ -1,6 +1,13 @@
"use client";
import { formatDateLong } from "~/lib/format.js";
import { formatCents, formatDateLong } from "~/lib/format.js";
function getVacationTitle(vacation: VacationHoverData): string {
if (vacation.type === "PUBLIC_HOLIDAY" && vacation.note) {
return vacation.note;
}
return vacation.type.replaceAll("_", " ");
}
export type HeatmapHoverData = {
date: Date;
@@ -30,6 +37,23 @@ export type VacationHoverData = {
approvedAt?: Date | string | null;
};
export type DemandHoverData = {
roleName: string;
roleColor: string;
projectName: string;
projectShortCode?: string | null;
requestedHeadcount: number;
unfilledHeadcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
totalHours: number;
percentage?: number;
status?: string;
totalCostCents?: number;
dailyCostCents?: number;
};
interface TimelineTooltipProps {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
@@ -37,6 +61,9 @@ interface TimelineTooltipProps {
vacationTooltipPos: { left: number; top: number };
heatmapHover: HeatmapHoverData | null;
vacationHover: VacationHoverData | null;
demandTooltipRef?: React.RefObject<HTMLDivElement | null>;
demandTooltipPos?: { left: number; top: number };
demandHover?: DemandHoverData | null;
}
export function TimelineTooltip({
@@ -46,7 +73,87 @@ export function TimelineTooltip({
vacationTooltipPos,
heatmapHover,
vacationHover,
demandTooltipRef,
demandTooltipPos,
demandHover,
}: TimelineTooltipProps) {
const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null;
if (demandHover && demandTooltipRef && demandTooltipPos) {
return (
<div
ref={demandTooltipRef}
style={{
left: demandTooltipPos.left,
top: demandTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: demandHover.roleColor }}
/>
<span className="truncate font-semibold">{demandHover.roleName}</span>
</div>
<div className="truncate text-[11px] text-gray-400">
{demandHover.projectShortCode ? `${demandHover.projectShortCode} · ` : ""}
{demandHover.projectName}
</div>
</div>
{demandHover.status ? (
<span className="text-[10px] uppercase tracking-wide text-amber-300">
{demandHover.status}
</span>
) : null}
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div>
<div className="text-gray-500">Requested</div>
<div className="font-medium text-gray-100">{demandHover.requestedHeadcount}</div>
</div>
<div>
<div className="text-gray-500">Open</div>
<div className="font-medium text-amber-300">{demandHover.unfilledHeadcount}</div>
</div>
<div>
<div className="text-gray-500">Range</div>
<div className="font-medium text-gray-100">
{formatDateLong(demandHover.startDate)} to {formatDateLong(demandHover.endDate)}
</div>
</div>
<div>
<div className="text-gray-500">Load</div>
<div className="font-medium text-gray-100">
{demandHover.hoursPerDay}h/day · {demandHover.totalHours}h
</div>
</div>
{typeof demandHover.percentage === "number" && demandHover.percentage > 0 ? (
<div>
<div className="text-gray-500">Allocation</div>
<div className="font-medium text-gray-100">{demandHover.percentage}%</div>
</div>
) : null}
{typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? (
<div>
<div className="text-gray-500">Cost</div>
<div className="font-medium text-gray-100">
{formatCents(demandHover.totalCostCents)} EUR
{typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
? ` · ${formatCents(demandHover.dailyCostCents)}/d`
: ""}
</div>
</div>
) : null}
</div>
</div>
);
}
// When both are active, render a single merged tooltip using the heatmap position
if (heatmapHover && vacationHover) {
return (
@@ -114,14 +221,12 @@ export function TimelineTooltip({
<div className="mt-2 pt-2 border-t border-amber-700/40">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
<span className="font-semibold text-amber-300">
{vacationHover.type.replaceAll("_", " ")}
</span>
<span className="font-semibold text-amber-300">{vacationTitle}</span>
</div>
<div className="mt-0.5 text-[11px] text-amber-200/80">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
) : null}
</div>
@@ -200,11 +305,11 @@ export function TimelineTooltip({
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="font-semibold">{vacationTitle}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
@@ -35,6 +35,11 @@ export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: num
return (
<div
key={`vac-${v.id}`}
data-testid="timeline-vacation-block"
data-vacation-id={v.id}
data-vacation-type={v.type}
data-vacation-status={v.status}
data-vacation-note={v.note ?? ""}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
colorClass,