Files
CapaKraken/apps/web/src/components/timeline/TimelineTooltip.tsx
T
Hartmut 3c0befb7db feat: enhanced timeline hover tooltip with role, dates, status
The tooltip shown when hovering over project strips in the timeline
now includes additional information:
- Role name (e.g. "3D Artist", "Project Manager")
- Assignment date range (2026-03-01 → 2026-06-30)
- Status badge when not CONFIRMED (shows PROPOSED, DRAFT, etc.)
- Lead person and order type on the same line

Data comes from already-loaded timeline entries — no extra API calls.
Safe change: tooltip is pointer-events-none and read-only.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-23 07:30:08 +01:00

216 lines
8.0 KiB
TypeScript

"use client";
import { formatDateLong } from "~/lib/format.js";
export type HeatmapHoverData = {
date: Date;
totalH: number;
pct: number;
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
role?: string | null;
status?: string;
startDate?: string;
endDate?: string;
}[];
};
export type VacationHoverData = {
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
};
interface TimelineTooltipProps {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: HeatmapHoverData | null;
vacationHover: VacationHoverData | null;
}
export function TimelineTooltip({
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: TimelineTooltipProps) {
// When both are active, render a single merged tooltip using the heatmap position
if (heatmapHover && vacationHover) {
return (
<div
ref={(el) => {
// Wire both refs to the same element so position updates work from either handler
(heatmapTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.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"
>
{/* Date + hours header */}
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
{/* Project breakdown */}
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{[
entry.role,
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
entry.orderType,
].filter(Boolean).join(" · ")}
</div>
{entry.startDate && entry.endDate && (
<div className="text-[10px] text-gray-500">
{entry.startDate} {entry.endDate}
{entry.status && entry.status !== "CONFIRMED" && (
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
)}
</div>
)}
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
{/* Vacation section — merged below */}
<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>
</div>
<div className="mt-0.5 text-[11px] text-amber-200/80">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
) : null}
</div>
</div>
);
}
// Heatmap only
if (heatmapHover) {
return (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.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-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{[
entry.role,
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
entry.orderType,
].filter(Boolean).join(" · ")}
</div>
{entry.startDate && entry.endDate && (
<div className="text-[10px] text-gray-500">
{entry.startDate} {entry.endDate}
{entry.status && entry.status !== "CONFIRMED" && (
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
)}
</div>
)}
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
);
}
// Vacation only
if (vacationHover) {
return (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
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="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
);
}
return null;
}