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
@@ -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>