feat: Sprint 2 — data storytelling and visual richness

Timeline project color system:
- 16-color deterministic palette (same project = same color always)
- Resource panel: allocation blocks colored by project instead of uniform green
- Project panel: colored left border + dot on project headers
- ProjectColorLegend: floating strip showing color-to-project mapping
- Utilization intensity tint: subtle background gradient on resource rows

Table visual enhancements:
- Resources: inline 3px utilization bar below chargeability percentage
- Resources: 32px avatar circles with initials + role-derived colors
- Projects: animated budget bars, styled resource count badges
- Allocations: 3px left border colored by status (green/amber/blue/gray/red)

KPI progress rings:
- Budget utilization: ProgressRing wrapping AnimatedNumber on dashboard
- Chargeability report: ring on average chargeability summary card
- Resource detail: rings on chargeability target + actual metrics
- Vacation balance: ring showing remaining days with color thresholds
- Demand widget: mini rings on FTE fill rate per project
- Resource detail: FadeIn on SkillRadarChart

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 00:58:06 +01:00
parent ae92923c28
commit a97597093f
13 changed files with 399 additions and 53 deletions
@@ -0,0 +1,88 @@
"use client";
import { clsx } from "clsx";
import { memo, useMemo, useState } from "react";
import { useTimelineContext } from "./TimelineContext.js";
import { getProjectColor } from "~/lib/project-colors.js";
import { FadeIn } from "~/components/ui/FadeIn.js";
function ProjectColorLegendInner() {
const { visibleAssignments, viewMode, projectGroups } = useTimelineContext();
const [dismissed, setDismissed] = useState(false);
// Collect unique visible projects with their colors
const legendItems = useMemo(() => {
const seen = new Map<string, { shortCode: string; name: string; hex: string }>();
if (viewMode === "project") {
for (const group of projectGroups) {
if (seen.has(group.id)) continue;
const customColor = group.color;
const projectColor = getProjectColor(group.id);
seen.set(group.id, {
shortCode: group.shortCode,
name: group.name,
hex: customColor ?? projectColor.hex,
});
}
} else {
for (const entry of visibleAssignments) {
if (seen.has(entry.projectId)) continue;
const customColor = (entry.project as { color?: string | null }).color;
const projectColor = getProjectColor(entry.projectId);
seen.set(entry.projectId, {
shortCode: entry.project.shortCode,
name: entry.project.name,
hex: customColor ?? projectColor.hex,
});
}
}
return [...seen.values()].sort((a, b) => a.shortCode.localeCompare(b.shortCode));
}, [visibleAssignments, viewMode, projectGroups]);
if (dismissed || legendItems.length === 0) return null;
return (
<FadeIn>
<div className="flex items-center gap-1 px-3 py-1.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm overflow-x-auto scrollbar-thin">
<span className="text-[10px] font-medium text-gray-400 dark:text-gray-500 flex-shrink-0 mr-1">
Projects
</span>
<div className="flex items-center gap-2.5 min-w-0">
{legendItems.map((item) => (
<div
key={item.shortCode}
className="flex items-center gap-1 flex-shrink-0"
title={item.name}
>
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: item.hex }}
/>
<span className="text-[10px] font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap">
{item.shortCode}
</span>
</div>
))}
</div>
<button
type="button"
onClick={() => setDismissed(true)}
className={clsx(
"ml-2 flex-shrink-0 w-4 h-4 flex items-center justify-center rounded",
"text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300",
"hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors",
)}
aria-label="Dismiss color legend"
>
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M2 2l8 8M10 2l-8 8" />
</svg>
</button>
</div>
</FadeIn>
);
}
export const ProjectColorLegend = memo(ProjectColorLegendInner);
@@ -20,6 +20,7 @@ import {
PROJECT_HEADER_HEIGHT,
ORDER_TYPE_COLORS,
} from "./timelineConstants.js";
import { getProjectColor } from "~/lib/project-colors.js";
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
import {
@@ -609,6 +610,7 @@ function TimelineProjectPanelInner({
(() => {
const { project } = row;
const customColor = project.color;
const projectColor = getProjectColor(project.id);
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
bg: "bg-gray-400",
text: "text-white",
@@ -631,7 +633,7 @@ function TimelineProjectPanelInner({
<div
data-project-group="true"
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
style={{ height: PROJECT_HEADER_HEIGHT }}
style={{ height: PROJECT_HEADER_HEIGHT, borderLeft: `4px solid ${customColor ?? projectColor.hex}` }}
>
<div
className={clsx(
@@ -641,6 +643,10 @@ function TimelineProjectPanelInner({
style={{ width: LABEL_WIDTH }}
onClick={() => onOpenPanel(project.id)}
>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: customColor ?? projectColor.hex }}
/>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{project.name}
@@ -659,19 +665,17 @@ function TimelineProjectPanelInner({
{projWidth > 0 && projLeft < totalCanvasWidth && (
<div
className={clsx(
"absolute rounded flex items-center px-2 gap-1.5 transition-all duration-75",
"absolute rounded flex items-center px-2 gap-1.5 transition-all duration-75 text-white",
isThisProjectShifting
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]"
: "cursor-grab hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
!customColor && colors.bg,
customColor ? "text-white" : colors.text,
)}
style={{
left: projLeft + 2,
width: projWidth - 4,
top: 8,
height: 24,
...(customColor ? { backgroundColor: customColor } : {}),
backgroundColor: customColor ?? projectColor.hex + "CC",
}}
onClick={() => {
if (!dragState.isDragging) onOpenPanel(project.id);
@@ -9,15 +9,15 @@ import {
} from "./TimelineContext.js";
import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js";
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
import { heatmapBgColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { TimelineTooltip } from "./TimelineTooltip.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
LABEL_WIDTH,
ORDER_TYPE_COLORS,
} from "./timelineConstants.js";
import { getProjectColor } from "~/lib/project-colors.js";
import type {
DragState,
AllocDragState,
@@ -224,6 +224,36 @@ function TimelineResourcePanelInner({
return result;
}, [displayMode, resourceRows]);
// ─── Memo 4: utilization per resource for row background tint ───────────
const utilizationByResource = useMemo(() => {
const REF_H = 8;
const result = new Map<string, number>(); // resourceId -> avg utilization pct
for (const { resource, allocs } of resourceRows) {
if (allocs.length === 0) continue;
let totalHours = 0;
let dayCount = 0;
for (const date of dates) {
const t = date.getTime();
let dayH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) dayH += a.hoursPerDay;
}
if (dayH > 0) {
totalHours += dayH;
dayCount++;
}
}
if (dayCount > 0) {
result.set(resource.id, (totalHours / dayCount / REF_H) * 100);
}
}
return result;
}, [resourceRows, dates]);
// ─── Heatmap row hover handler ────────────────────────────────────────────
const handleRowHeatmapMove = useCallback(
(e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
@@ -401,6 +431,14 @@ function TimelineResourcePanelInner({
? ROW_HEIGHT
: Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
// Utilization background tint
const utilPct = utilizationByResource.get(resource.id) ?? 0;
const utilBg = utilPct > 100
? "rgba(254,202,202,0.18)" // red tint for over-utilized
: utilPct >= 50
? `rgba(59,130,246,${Math.min(0.06 + (utilPct - 50) * 0.0014, 0.12)})` // faint blue tint scaling 50-100%
: undefined;
return (
<div
key={resource.id}
@@ -419,7 +457,7 @@ function TimelineResourcePanelInner({
"flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group transition-colors",
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
)}
style={{ height: rowHeight }}
style={{ height: rowHeight, ...(utilBg ? { backgroundColor: utilBg } : {}) }}
>
{/* Label column */}
<div
@@ -621,11 +659,8 @@ function renderAllocBlocksFromData(
const blockHeight = SUB_LANE_HEIGHT - 8;
const customColor = (alloc.project as { color?: string | null }).color;
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? {
bg: "bg-gray-400",
text: "text-white",
light: "",
};
const projectColor = getProjectColor(alloc.projectId);
const blockBgColor = customColor ?? projectColor.hex + "B3";
const HANDLE_W = width >= 48 ? 10 : 6;
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
@@ -644,9 +679,7 @@ function renderAllocBlocksFromData(
<div
key={alloc.id}
className={clsx(
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block",
!customColor && colors.bg,
customColor ? "text-white" : colors.text,
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block text-white",
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
isBeingDragged
? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]"
@@ -660,7 +693,7 @@ function renderAllocBlocksFromData(
width: width - 4,
top: blockTop,
height: blockHeight,
...(customColor ? { backgroundColor: customColor } : {}),
backgroundColor: blockBgColor,
...(multiDragPx && multiDragMode === "move" ? { transform: `translateX(${multiDragPx}px)` } : {}),
}}
onMouseDown={(e) => {
@@ -711,7 +744,11 @@ function renderAllocBlocksFromData(
{hasRecurrence && width > 28 && (
<span className="text-[10px] opacity-80 flex-shrink-0"></span>
)}
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
{width > 60 ? (
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
) : (
<span className="text-[9px] font-bold truncate opacity-90">{alloc.project.shortCode}</span>
)}
{width > 130 && <span className="text-[10px] opacity-75 truncate">{alloc.role}</span>}
{width > 190 && (
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
@@ -862,11 +899,9 @@ function renderDailyBars(
let stackedH = 0;
const segs: React.ReactNode[] = covering.map((alloc) => {
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? {
bg: "bg-gray-400",
text: "text-white",
light: "",
};
const customColor = (alloc.project as { color?: string | null }).color;
const projectColor = getProjectColor(alloc.projectId);
const segBgColor = customColor ?? projectColor.hex + "B3";
const segH = Math.max(
2,
Math.min(BAR_AREA - stackedH, Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA)),
@@ -907,7 +942,6 @@ function renderDailyBars(
key={`bar-${i}-${alloc.id}`}
className={clsx(
"absolute rounded-sm transition-all duration-75 flex items-stretch overflow-hidden",
colors.bg,
isBeingDragged
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
: "hover:opacity-80 z-[10]",
@@ -918,6 +952,7 @@ function renderDailyBars(
width: CELL_WIDTH - 4,
height: segH,
bottom,
backgroundColor: segBgColor,
...(multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id)
? { transform: `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` }
@@ -30,6 +30,7 @@ import {
} from "./TimelineContext.js";
import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
import { ProjectColorLegend } from "./ProjectColorLegend.js";
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
// ─── Entry point ────────────────────────────────────────────────────────────
@@ -607,6 +608,9 @@ function TimelineViewContent({
}}
/>
{/* Project color legend */}
<ProjectColorLegend />
{/* Scrollable canvas */}
<div
ref={scrollContainerRef}