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:
@@ -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)` }
|
||||
|
||||
Reference in New Issue
Block a user