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
@@ -59,7 +59,7 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu
return ( return (
<div className="min-w-[104px] space-y-1"> <div className="min-w-[104px] space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80"> <div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
<div className={clsx("h-full rounded-full transition-all", barColor)} style={{ width: `${cappedPercent}%` }} /> <div className={clsx("h-full rounded-full transition-all duration-700 ease-out", barColor)} style={{ width: `${cappedPercent}%` }} />
</div> </div>
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div> <div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
</div> </div>
@@ -407,8 +407,17 @@ export function ProjectsClient() {
); );
case "allocations": case "allocations":
return ( return (
<td key={col.key} className="px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-300"> <td key={col.key} className="px-4 py-3 text-right text-sm">
{project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"} {project.totalPersonDays > 0 ? (
<span className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
<svg className="h-3 w-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{project.totalPersonDays}d
</span>
) : (
<span className="text-gray-400 dark:text-gray-500"></span>
)}
</td> </td>
); );
case "responsible": case "responsible":
@@ -1148,20 +1148,51 @@ export function ResourcesClient() {
{resource.eid} {resource.eid}
</td> </td>
); );
case "displayName": case "displayName": {
const initials = resource.displayName
.split(/\s+/)
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
const rr =
(
resource as unknown as {
resourceRoles?: {
isPrimary: boolean;
role: { color: string | null };
}[];
}
).resourceRoles ?? [];
const primaryRole = rr.find((r) => r.isPrimary);
const avatarColor =
primaryRole?.role.color ??
`hsl(${[...resource.displayName].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360}, 55%, 45%)`;
return ( return (
<td key={col.key} className="px-4 py-3"> <td key={col.key} className="px-4 py-3">
<Link <Link
href={`/resources/${resource.id}`} href={`/resources/${resource.id}`}
className="text-sm font-medium text-gray-900 transition-colors hover:text-brand-600 hover:underline dark:text-gray-100" className="inline-flex items-center gap-2.5 transition-colors hover:text-brand-600 group"
> >
<span
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ backgroundColor: avatarColor }}
>
{initials}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-gray-900 group-hover:text-brand-600 group-hover:underline dark:text-gray-100">
{resource.displayName} {resource.displayName}
</Link> </span>
<div className="text-xs text-gray-500 dark:text-gray-400"> <span className="block text-xs text-gray-500 dark:text-gray-400">
{resource.email} {resource.email}
</div> </span>
</span>
</Link>
</td> </td>
); );
}
case "chapter": case "chapter":
return ( return (
<td <td
@@ -1202,8 +1233,20 @@ export function ResourcesClient() {
: actual >= target - 20 : actual >= target - 20
? "text-amber-600 dark:text-amber-300" ? "text-amber-600 dark:text-amber-300"
: "text-red-600 dark:text-red-300"; : "text-red-600 dark:text-red-300";
// Bar color based on % of target achieved
const barRatio = actual != null && target > 0 ? actual / target : 0;
const barColor =
actual == null
? "bg-gray-300 dark:bg-gray-600"
: barRatio >= 0.8
? "bg-green-500"
: barRatio >= 0.5
? "bg-amber-500"
: "bg-red-500";
const barWidth = actual != null ? Math.min(actual, 100) : 0;
const isOverflow = actual != null && actual > 100;
return ( return (
<td key={col.key} className="px-4 py-3 text-sm"> <td key={col.key} className="px-4 py-3 text-sm min-w-[120px]">
<div> <div>
<span className={`font-medium ${color}`}> <span className={`font-medium ${color}`}>
{actual != null ? `${actual}%` : "—"} {actual != null ? `${actual}%` : "—"}
@@ -1213,7 +1256,22 @@ export function ResourcesClient() {
({expected}% exp.) ({expected}% exp.)
</span> </span>
)} )}
{actual !== target && (
<div className="text-xs text-gray-400">Target: {target}%</div> <div className="text-xs text-gray-400">Target: {target}%</div>
)}
{actual != null && (
<div className="mt-1 flex items-center gap-1">
<div className="h-[3px] flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className={`h-full rounded-full transition-all duration-700 ease-out ${barColor}`}
style={{ width: `${barWidth}%` }}
/>
</div>
{isOverflow && (
<span className="text-[9px] font-bold text-green-600 dark:text-green-400" title={`${actual}% actual`}>+</span>
)}
</div>
)}
</div> </div>
</td> </td>
); );
@@ -22,6 +22,15 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
/** Left-border color by allocation status for instant visual scanning */
const STATUS_LEFT_BORDER: Record<string, string> = {
ACTIVE: "border-l-green-500",
PROPOSED: "border-l-amber-500",
CONFIRMED: "border-l-blue-500",
COMPLETED: "border-l-gray-400",
CANCELLED: "border-l-red-500",
};
/** Fragment wrapper for grouped rows — avoids unnecessary DOM nodes */ /** Fragment wrapper for grouped rows — avoids unnecessary DOM nodes */
function GroupRows({ children }: { children: React.ReactNode }) { function GroupRows({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
@@ -363,8 +372,9 @@ export function AllocationsClient() {
function renderAllocRow(alloc: AllocationWithDetails, isGrouped = false, rowIndex = 0) { function renderAllocRow(alloc: AllocationWithDetails, isGrouped = false, rowIndex = 0) {
const isSelected = selection.selectedIds.has(alloc.id); const isSelected = selection.selectedIds.has(alloc.id);
const leftBorder = STATUS_LEFT_BORDER[alloc.status] ?? "border-l-gray-300";
return ( return (
<tr key={alloc.id} className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} style={{ animationDelay: `${Math.min(rowIndex * 15, 300)}ms` }}> <tr key={alloc.id} className={`border-l-[3px] transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${leftBorder} ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} style={{ animationDelay: `${Math.min(rowIndex * 15, 300)}ms` }}>
<td className="px-4 py-3"> <td className="px-4 py-3">
<input <input
type="checkbox" type="checkbox"
@@ -4,6 +4,7 @@ import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js"; import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
type GroupBy = "project" | "person" | "chapter"; type GroupBy = "project" | "person" | "chapter";
@@ -155,11 +156,21 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
<td className="px-3 py-2 text-right text-gray-700"> <td className="px-3 py-2 text-right text-gray-700">
{(() => { {(() => {
const ftes = row.requiredFTEs as unknown as number; const ftes = row.requiredFTEs as unknown as number;
return ftes > 0 ? ( if (ftes <= 0) return "—";
<span className={row.allocatedHours / 8 < ftes * 22 * 3 ? "text-red-600 font-semibold" : "text-green-700"}> const requiredHours = ftes * 22 * 3 * 8;
const fillPct = Math.min(100, Math.round((row.allocatedHours / requiredHours) * 100));
const isBelowTarget = row.allocatedHours / 8 < ftes * 22 * 3;
const ringColor = isBelowTarget
? "var(--color-red-500, #ef4444)"
: "var(--color-green-500, #22c55e)";
return (
<span className="inline-flex items-center gap-1.5">
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
{ftes} FTE {ftes} FTE
</span> </span>
) : "—"; </span>
);
})()} })()}
</td> </td>
)} )}
@@ -5,8 +5,15 @@ import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { formatMoney } from "~/lib/format.js"; import { formatMoney } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js"; import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
import { FadeIn } from "~/components/ui/FadeIn.js"; import { FadeIn } from "~/components/ui/FadeIn.js";
const ACCENT_COLORS = {
green: "var(--color-green-500, #22c55e)",
amber: "var(--color-amber-500, #f59e0b)",
red: "var(--color-red-500, #ef4444)",
} as const;
function StatCard({ function StatCard({
label, label,
value, value,
@@ -15,6 +22,7 @@ function StatCard({
info, info,
accentColor, accentColor,
delay = 0, delay = 0,
ring,
}: { }: {
label: string; label: string;
value: number; value: number;
@@ -23,6 +31,7 @@ function StatCard({
info?: React.ReactNode; info?: React.ReactNode;
accentColor?: "green" | "amber" | "red"; accentColor?: "green" | "amber" | "red";
delay?: number; delay?: number;
ring?: { value: number; color: string };
}) { }) {
const accentBorder = accentColor === "red" const accentBorder = accentColor === "red"
? "border-l-red-500" ? "border-l-red-500"
@@ -43,9 +52,17 @@ function StatCard({
{label} {label}
{info && <InfoTooltip content={info} />} {info && <InfoTooltip content={info} />}
</span> </span>
{ring ? (
<div className="mt-2 flex items-center gap-3">
<ProgressRing value={ring.value} size={56} strokeWidth={4} color={ring.color}>
<AnimatedNumber value={value} suffix={suffix} className="text-lg font-semibold text-gray-900 dark:text-gray-50" />
</ProgressRing>
</div>
) : (
<span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50"> <span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50">
<AnimatedNumber value={value} suffix={suffix} /> <AnimatedNumber value={value} suffix={suffix} />
</span> </span>
)}
{sub && <span className="mt-1 text-xs text-gray-500 dark:text-gray-400">{sub}</span>} {sub && <span className="mt-1 text-xs text-gray-500 dark:text-gray-400">{sub}</span>}
</div> </div>
</FadeIn> </FadeIn>
@@ -111,6 +128,7 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours." info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
accentColor={budgetAccent} accentColor={budgetAccent}
delay={0.15} delay={0.15}
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }}
/> />
</div> </div>
); );
@@ -3,6 +3,7 @@
import React, { useState, useMemo, useCallback } from "react"; import React, { useState, useMemo, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -445,7 +446,16 @@ export function ChargeabilityReportClient() {
</div> </div>
<div className="app-surface p-4"> <div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Average Chargeability<InfoTooltip content="FTE-weighted average chargeability across all visible resources. Formula: sum(FTE x Chg%) / sum(FTE)." /></div> <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 inline-flex items-center gap-0.5">Average Chargeability<InfoTooltip content="FTE-weighted average chargeability across all visible resources. Formula: sum(FTE x Chg%) / sum(FTE)." /></div>
<div className={`mt-2 text-3xl font-semibold ${chgColor(averageChargeability, averageTarget)}`}>{pct(averageChargeability)}</div> <div className="mt-2 flex items-center gap-3">
<ProgressRing
value={Math.round(averageChargeability * 100)}
size={56}
strokeWidth={4}
color={averageChargeability >= averageTarget ? "var(--color-green-500, #22c55e)" : averageChargeability >= averageTarget - 0.1 ? "var(--color-amber-500, #f59e0b)" : "var(--color-red-500, #ef4444)"}
>
<span className={`text-lg font-semibold ${chgColor(averageChargeability, averageTarget)}`}>{pct(averageChargeability)}</span>
</ProgressRing>
</div>
<div className="mt-1 text-sm text-gray-500">Weighted across visible resources</div> <div className="mt-1 text-sm text-gray-500">Weighted across visible resources</div>
</div> </div>
<div className="app-surface p-4"> <div className="app-surface p-4">
@@ -11,6 +11,8 @@ import { AiSummaryCard } from "./AiSummaryCard.js";
import { SkillMatrixUpload } from "./SkillMatrixUpload.js"; import { SkillMatrixUpload } from "./SkillMatrixUpload.js";
import { usePermissions } from "~/hooks/usePermissions.js"; import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
import { FadeIn } from "~/components/ui/FadeIn.js";
interface ResourceDetailProps { interface ResourceDetailProps {
resourceId: string; resourceId: string;
@@ -47,11 +49,19 @@ const allocationStatusColor: Record<string, string> = {
CANCELLED: "bg-red-100 text-red-500", CANCELLED: "bg-red-100 text-red-500",
}; };
function StatCard({ label, value, sub, tooltip }: { label: string; value: string | number; sub?: string; tooltip?: string }) { function StatCard({ label, value, sub, tooltip, ring }: { label: string; value: string | number; sub?: string; tooltip?: string; ring?: { value: number; color: string } }) {
return ( return (
<div className="bg-white rounded-xl border border-gray-200 p-4"> <div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="text-xs text-gray-500 mb-1 flex items-center">{label}{tooltip && <InfoTooltip content={tooltip} />}</div> <div className="text-xs text-gray-500 mb-1 flex items-center">{label}{tooltip && <InfoTooltip content={tooltip} />}</div>
<div className="text-xl font-bold text-gray-900">{value}</div> {ring ? (
<div className="flex items-center gap-3">
<ProgressRing value={ring.value} size={48} strokeWidth={3.5} color={ring.color}>
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{value}</span>
</ProgressRing>
</div>
) : (
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">{value}</div>
)}
{sub && <div className="text-xs text-gray-400 mt-0.5">{sub}</div>} {sub && <div className="text-xs text-gray-400 mt-0.5">{sub}</div>}
</div> </div>
); );
@@ -291,6 +301,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
label="Chargeability Target" label="Chargeability Target"
value={`${resource.chargeabilityTarget}%`} value={`${resource.chargeabilityTarget}%`}
tooltip="The percentage of working time this resource is expected to spend on chargeable/billable work." tooltip="The percentage of working time this resource is expected to spend on chargeable/billable work."
ring={{
value: resource.chargeabilityTarget,
color: "var(--color-blue-500, #3b82f6)",
}}
/> />
{canViewCosts && ( {canViewCosts && (
<StatCard <StatCard
@@ -302,6 +316,16 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
? "Incl. proposed + imported TBD planning" ? "Incl. proposed + imported TBD planning"
: "Confirmed + active only" : "Confirmed + active only"
} }
{...(chargeStats != null ? {
ring: {
value: chargeStats.actualChargeability,
color: chargeStats.actualChargeability >= resource.chargeabilityTarget
? "var(--color-green-500, #22c55e)"
: chargeStats.actualChargeability >= resource.chargeabilityTarget - 10
? "var(--color-amber-500, #f59e0b)"
: "var(--color-red-500, #ef4444)",
},
} : {})}
/> />
)} )}
{canViewCosts && ( {canViewCosts && (
@@ -418,7 +442,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
)} )}
{/* Skill Radar Chart */} {/* Skill Radar Chart */}
<FadeIn delay={0.1} direction="up">
<SkillRadarChart skills={skills} /> <SkillRadarChart skills={skills} />
</FadeIn>
{/* Roles */} {/* Roles */}
{resourceRoles.length > 0 && ( {resourceRoles.length > 0 && (
@@ -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, PROJECT_HEADER_HEIGHT,
ORDER_TYPE_COLORS, ORDER_TYPE_COLORS,
} from "./timelineConstants.js"; } from "./timelineConstants.js";
import { getProjectColor } from "~/lib/project-colors.js";
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js"; import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js"; import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
import { import {
@@ -609,6 +610,7 @@ function TimelineProjectPanelInner({
(() => { (() => {
const { project } = row; const { project } = row;
const customColor = project.color; const customColor = project.color;
const projectColor = getProjectColor(project.id);
const colors = ORDER_TYPE_COLORS[project.orderType] ?? { const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
bg: "bg-gray-400", bg: "bg-gray-400",
text: "text-white", text: "text-white",
@@ -631,7 +633,7 @@ function TimelineProjectPanelInner({
<div <div
data-project-group="true" data-project-group="true"
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)} 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 <div
className={clsx( className={clsx(
@@ -641,6 +643,10 @@ function TimelineProjectPanelInner({
style={{ width: LABEL_WIDTH }} style={{ width: LABEL_WIDTH }}
onClick={() => onOpenPanel(project.id)} 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="min-w-0">
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate"> <div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{project.name} {project.name}
@@ -659,19 +665,17 @@ function TimelineProjectPanelInner({
{projWidth > 0 && projLeft < totalCanvasWidth && ( {projWidth > 0 && projLeft < totalCanvasWidth && (
<div <div
className={clsx( 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 isThisProjectShifting
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]" ? "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", : "cursor-grab hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
!customColor && colors.bg,
customColor ? "text-white" : colors.text,
)} )}
style={{ style={{
left: projLeft + 2, left: projLeft + 2,
width: projWidth - 4, width: projWidth - 4,
top: 8, top: 8,
height: 24, height: 24,
...(customColor ? { backgroundColor: customColor } : {}), backgroundColor: customColor ?? projectColor.hex + "CC",
}} }}
onClick={() => { onClick={() => {
if (!dragState.isDragging) onOpenPanel(project.id); if (!dragState.isDragging) onOpenPanel(project.id);
@@ -9,15 +9,15 @@ import {
} from "./TimelineContext.js"; } from "./TimelineContext.js";
import { ConflictOverlay } from "./ConflictOverlay.js"; import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js"; import { computeSubLanes } from "./utils.js";
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js"; import { heatmapBgColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { TimelineTooltip } from "./TimelineTooltip.js"; import { TimelineTooltip } from "./TimelineTooltip.js";
import { import {
ROW_HEIGHT, ROW_HEIGHT,
SUB_LANE_HEIGHT, SUB_LANE_HEIGHT,
LABEL_WIDTH, LABEL_WIDTH,
ORDER_TYPE_COLORS,
} from "./timelineConstants.js"; } from "./timelineConstants.js";
import { getProjectColor } from "~/lib/project-colors.js";
import type { import type {
DragState, DragState,
AllocDragState, AllocDragState,
@@ -224,6 +224,36 @@ function TimelineResourcePanelInner({
return result; return result;
}, [displayMode, resourceRows]); }, [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 ──────────────────────────────────────────── // ─── Heatmap row hover handler ────────────────────────────────────────────
const handleRowHeatmapMove = useCallback( const handleRowHeatmapMove = useCallback(
(e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => { (e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
@@ -401,6 +431,14 @@ function TimelineResourcePanelInner({
? ROW_HEIGHT ? ROW_HEIGHT
: Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16); : 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 ( return (
<div <div
key={resource.id} 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", "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", dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
)} )}
style={{ height: rowHeight }} style={{ height: rowHeight, ...(utilBg ? { backgroundColor: utilBg } : {}) }}
> >
{/* Label column */} {/* Label column */}
<div <div
@@ -621,11 +659,8 @@ function renderAllocBlocksFromData(
const blockHeight = SUB_LANE_HEIGHT - 8; const blockHeight = SUB_LANE_HEIGHT - 8;
const customColor = (alloc.project as { color?: string | null }).color; const customColor = (alloc.project as { color?: string | null }).color;
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { const projectColor = getProjectColor(alloc.projectId);
bg: "bg-gray-400", const blockBgColor = customColor ?? projectColor.hex + "B3";
text: "text-white",
light: "",
};
const HANDLE_W = width >= 48 ? 10 : 6; const HANDLE_W = width >= 48 ? 10 : 6;
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence; const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
@@ -644,9 +679,7 @@ function renderAllocBlocksFromData(
<div <div
key={alloc.id} key={alloc.id}
className={clsx( className={clsx(
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block", "absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block text-white",
!customColor && colors.bg,
customColor ? "text-white" : colors.text,
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60", hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
isBeingDragged isBeingDragged
? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]" ? "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, width: width - 4,
top: blockTop, top: blockTop,
height: blockHeight, height: blockHeight,
...(customColor ? { backgroundColor: customColor } : {}), backgroundColor: blockBgColor,
...(multiDragPx && multiDragMode === "move" ? { transform: `translateX(${multiDragPx}px)` } : {}), ...(multiDragPx && multiDragMode === "move" ? { transform: `translateX(${multiDragPx}px)` } : {}),
}} }}
onMouseDown={(e) => { onMouseDown={(e) => {
@@ -711,7 +744,11 @@ function renderAllocBlocksFromData(
{hasRecurrence && width > 28 && ( {hasRecurrence && width > 28 && (
<span className="text-[10px] opacity-80 flex-shrink-0"></span> <span className="text-[10px] opacity-80 flex-shrink-0"></span>
)} )}
{width > 60 ? (
<span className="text-xs font-semibold truncate">{alloc.project.name}</span> <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 > 130 && <span className="text-[10px] opacity-75 truncate">{alloc.role}</span>}
{width > 190 && ( {width > 190 && (
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span> <span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
@@ -862,11 +899,9 @@ function renderDailyBars(
let stackedH = 0; let stackedH = 0;
const segs: React.ReactNode[] = covering.map((alloc) => { const segs: React.ReactNode[] = covering.map((alloc) => {
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { const customColor = (alloc.project as { color?: string | null }).color;
bg: "bg-gray-400", const projectColor = getProjectColor(alloc.projectId);
text: "text-white", const segBgColor = customColor ?? projectColor.hex + "B3";
light: "",
};
const segH = Math.max( const segH = Math.max(
2, 2,
Math.min(BAR_AREA - stackedH, Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA)), Math.min(BAR_AREA - stackedH, Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA)),
@@ -907,7 +942,6 @@ function renderDailyBars(
key={`bar-${i}-${alloc.id}`} key={`bar-${i}-${alloc.id}`}
className={clsx( className={clsx(
"absolute rounded-sm transition-all duration-75 flex items-stretch overflow-hidden", "absolute rounded-sm transition-all duration-75 flex items-stretch overflow-hidden",
colors.bg,
isBeingDragged isBeingDragged
? "opacity-90 ring-2 ring-white ring-offset-1 z-20" ? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
: "hover:opacity-80 z-[10]", : "hover:opacity-80 z-[10]",
@@ -918,6 +952,7 @@ function renderDailyBars(
width: CELL_WIDTH - 4, width: CELL_WIDTH - 4,
height: segH, height: segH,
bottom, bottom,
backgroundColor: segBgColor,
...(multiSelectState.isMultiDragging && ...(multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id) multiSelectState.selectedAllocationIds.includes(alloc.id)
? { transform: `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` } ? { transform: `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` }
@@ -30,6 +30,7 @@ import {
} from "./TimelineContext.js"; } from "./TimelineContext.js";
import { TimelineResourcePanel } from "./TimelineResourcePanel.js"; import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js"; import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
import { ProjectColorLegend } from "./ProjectColorLegend.js";
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js"; import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
// ─── Entry point ──────────────────────────────────────────────────────────── // ─── Entry point ────────────────────────────────────────────────────────────
@@ -607,6 +608,9 @@ function TimelineViewContent({
}} }}
/> />
{/* Project color legend */}
<ProjectColorLegend />
{/* Scrollable canvas */} {/* Scrollable canvas */}
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
@@ -2,6 +2,7 @@
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
interface BalanceCardProps { interface BalanceCardProps {
resourceId: string; resourceId: string;
@@ -46,20 +47,34 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
); );
} }
const ringColor = pct > 90
? "var(--color-red-500, #ef4444)"
: pct >= 70
? "var(--color-amber-500, #f59e0b)"
: "var(--color-emerald-500, #10b981)";
return ( return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-3"> <div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ProgressRing value={pct} size={52} strokeWidth={4} color={ringColor}>
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{balance.remainingDays}d</span>
</ProgressRing>
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300"> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Vacation Balance {year} Vacation Balance {year}
</h3> </h3>
<p className="text-xs text-gray-400 dark:text-gray-500">{balance.usedDays} of {balance.entitledDays} days used</p>
</div>
</div>
{balance.carryoverDays > 0 && ( {balance.carryoverDays > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500 inline-flex items-center">+{balance.carryoverDays}d carried over<InfoTooltip content="Unused days from the previous year. Automatically calculated on first access." /></span> <span className="text-xs text-gray-400 dark:text-gray-500 inline-flex items-center">+{balance.carryoverDays}d carried over<InfoTooltip content="Unused days from the previous year. Automatically calculated on first access." /></span>
)} )}
</div> </div>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900" tooltip="Total vacation days granted for this year, including carryover from previous year." /> <Stat label="Entitled" value={balance.entitledDays} color="text-gray-900 dark:text-gray-100" tooltip="Total vacation days granted for this year, including carryover from previous year." />
<Stat label="Used" value={balance.usedDays} color="text-gray-600" tooltip="Days already consumed by approved vacations that have passed." /> <Stat label="Used" value={balance.usedDays} color="text-gray-600 dark:text-gray-400" tooltip="Days already consumed by approved vacations that have passed." />
<Stat label="Pending" value={balance.pendingDays} color="text-amber-600" tooltip="Days reserved by approved future vacations not yet started." /> <Stat label="Pending" value={balance.pendingDays} color="text-amber-600" tooltip="Days reserved by approved future vacations not yet started." />
<Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} tooltip="Entitled - Used - Pending. Red if fewer than 5 days remain." /> <Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} tooltip="Entitled - Used - Pending. Red if fewer than 5 days remain." />
</div> </div>
+58
View File
@@ -0,0 +1,58 @@
/**
* Deterministic project color assignment.
* Maps a project ID to one of 16 visually distinct colors.
* Colors are chosen for readability on both light and dark backgrounds.
*/
const PROJECT_PALETTE = [
{ bg: "bg-sky-500/70", dark: "bg-sky-400/60", border: "border-sky-600", hex: "#0ea5e9" },
{ bg: "bg-violet-500/70", dark: "bg-violet-400/60", border: "border-violet-600", hex: "#8b5cf6" },
{ bg: "bg-amber-500/70", dark: "bg-amber-400/60", border: "border-amber-600", hex: "#f59e0b" },
{ bg: "bg-rose-500/70", dark: "bg-rose-400/60", border: "border-rose-600", hex: "#f43f5e" },
{ bg: "bg-emerald-500/70", dark: "bg-emerald-400/60", border: "border-emerald-600", hex: "#10b981" },
{ bg: "bg-indigo-500/70", dark: "bg-indigo-400/60", border: "border-indigo-600", hex: "#6366f1" },
{ bg: "bg-orange-500/70", dark: "bg-orange-400/60", border: "border-orange-600", hex: "#f97316" },
{ bg: "bg-teal-500/70", dark: "bg-teal-400/60", border: "border-teal-600", hex: "#14b8a6" },
{ bg: "bg-pink-500/70", dark: "bg-pink-400/60", border: "border-pink-600", hex: "#ec4899" },
{ bg: "bg-cyan-500/70", dark: "bg-cyan-400/60", border: "border-cyan-600", hex: "#06b6d4" },
{ bg: "bg-lime-500/70", dark: "bg-lime-400/60", border: "border-lime-600", hex: "#84cc16" },
{ bg: "bg-fuchsia-500/70", dark: "bg-fuchsia-400/60", border: "border-fuchsia-600", hex: "#d946ef" },
{ bg: "bg-yellow-500/70", dark: "bg-yellow-400/60", border: "border-yellow-600", hex: "#eab308" },
{ bg: "bg-red-500/70", dark: "bg-red-400/60", border: "border-red-600", hex: "#ef4444" },
{ bg: "bg-blue-500/70", dark: "bg-blue-400/60", border: "border-blue-600", hex: "#3b82f6" },
{ bg: "bg-green-500/70", dark: "bg-green-400/60", border: "border-green-600", hex: "#22c55e" },
] as const;
export type ProjectColor = (typeof PROJECT_PALETTE)[number];
/**
* Returns a deterministic color for a project ID using a simple hash.
* Same project ID always gets the same color.
*/
export function getProjectColor(projectId: string): ProjectColor {
let hash = 0;
for (let i = 0; i < projectId.length; i++) {
hash = ((hash << 5) - hash + projectId.charCodeAt(i)) | 0;
}
return PROJECT_PALETTE[Math.abs(hash) % PROJECT_PALETTE.length]!;
}
/**
* Returns the hex color with an alpha suffix for inline styles.
* Useful when the block uses inline `backgroundColor` instead of Tailwind classes.
*/
export function getProjectHex(projectId: string, alpha = "B3"): string {
return getProjectColor(projectId).hex + alpha;
}
/**
* Build a lookup map for a set of project IDs.
* Call once per render with visible project IDs.
*/
export function buildProjectColorMap(projectIds: string[]): Map<string, ProjectColor> {
const map = new Map<string, ProjectColor>();
for (const id of projectIds) {
map.set(id, getProjectColor(id));
}
return map;
}