frontend: cap Project Load chart to top 10 projects, drop legend, honour filters

Real Airtable bookings have 100+ unique project names in a single
month. The previous chart rendered one <Bar>+legend entry per project,
which flooded the legend off-screen and broke the page layout entirely
(screenshot 2026-05-16).

- Aggregate sorts projects by total hours and shows top 10 only; the
  remainder are collapsed into a single greyed "Other (N)" stack so
  the totals still add up but the visual stays sane.
- Remove the <Legend> entirely. With long L'Oréal-style project names
  even 10 entries dominate. The tooltip (capped to 360px wide, with
  filterNull) handles per-segment lookup on hover.
- Wire FilterBar's department/name selections through to the chart on
  the Resourcing page. The backend's /api/airtable/bookings endpoint
  doesn't accept those params yet, so filter client-side for v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-16 13:57:11 -04:00
parent a1a7729a0e
commit c485757dc3
2 changed files with 60 additions and 16 deletions

View file

@ -2,7 +2,6 @@ import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
@ -14,51 +13,84 @@ interface Props {
bookings: Booking[];
}
const PALETTE = ['#2563eb', '#10b981', '#f59e0b', '#a855f7', '#ef4444', '#0ea5e9', '#84cc16', '#f97316'];
// Stacked bar with N projects = N legend entries. With real Airtable data
// there are easily 100+ unique project names in a single month, which
// floods the viewport. Cap to TOP_N by total hours and roll the rest into
// "Other"; drop the legend entirely and rely on the tooltip.
const TOP_N = 10;
const PALETTE = [
'#2563eb', '#10b981', '#f59e0b', '#a855f7', '#ef4444',
'#0ea5e9', '#84cc16', '#f97316', '#ec4899', '#14b8a6',
];
const OTHER_FILL = '#94a3b8';
interface Row {
employee: string;
[project: string]: number | string;
}
function aggregate(bookings: Booking[]): { data: Row[]; projects: string[] } {
const projects = new Set<string>();
function aggregate(bookings: Booking[]): { data: Row[]; projects: string[]; otherLabel: string | null } {
// Pass 1: project totals to choose the top N.
const totals = new Map<string, number>();
for (const bk of bookings) {
const project = (bk.projectName || bk.projectNumber || 'Unknown').toString();
totals.set(project, (totals.get(project) ?? 0) + (Number(bk.totalHoursBooked) || 0));
}
const sorted = [...totals.entries()].sort((a, b) => b[1] - a[1]);
const top = new Set(sorted.slice(0, TOP_N).map(([k]) => k));
const otherCount = sorted.length - top.size;
const otherLabel = otherCount > 0 ? `Other (${otherCount})` : null;
const projects = [...top, ...(otherLabel ? [otherLabel] : [])];
// Pass 2: build employee rows, collapsing non-top projects into "Other".
const map = new Map<string, Row>();
for (const bk of bookings) {
// Airtable lookups can be null at runtime even though TS says string;
// placeholder bookings in particular often have no resourceName.
const employee = (bk.resourceName ?? '').toString().trim() || 'Placeholder';
const projectKey = (bk.projectName || bk.projectNumber || 'Unknown').toString();
const raw = (bk.projectName || bk.projectNumber || 'Unknown').toString();
const key = top.has(raw) ? raw : (otherLabel ?? raw);
const hours = Number(bk.totalHoursBooked) || 0;
projects.add(projectKey);
const row = map.get(employee) ?? { employee };
row[projectKey] = ((row[projectKey] as number) ?? 0) + hours;
row[key] = ((row[key] as number) ?? 0) + hours;
map.set(employee, row);
}
return {
data: [...map.values()].sort((a, b) =>
String(a.employee).localeCompare(String(b.employee)),
),
projects: [...projects],
projects,
otherLabel,
};
}
export default function ProjectLoadPerPerson({ bookings }: Props) {
const { data, projects } = aggregate(bookings);
const { data, projects, otherLabel } = aggregate(bookings);
return (
<div className="card" data-tutorial-id="chart-project-load">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Project Load per Person</h3>
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">Project Load per Person</h3>
<span className="text-xs text-slate-500">Top {TOP_N} projects by hours · hover bars for details</span>
</div>
<div className="h-96 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="employee" tick={{ fontSize: 11 }} angle={-25} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Tooltip
wrapperStyle={{ maxWidth: 360 }}
itemStyle={{ fontSize: 12 }}
labelStyle={{ fontSize: 12, fontWeight: 600 }}
// Hide zero-value series in the tooltip to keep it readable.
filterNull
/>
{projects.map((p, i) => (
<Bar key={p} dataKey={p} stackId="proj" fill={PALETTE[i % PALETTE.length]} />
<Bar
key={p}
dataKey={p}
stackId="proj"
fill={p === otherLabel ? OTHER_FILL : PALETTE[i % PALETTE.length]}
/>
))}
</BarChart>
</ResponsiveContainer>

View file

@ -28,6 +28,18 @@ export default function Resourcing() {
[airtable.resources],
);
// Backend's /api/airtable/bookings doesn't yet accept department/name params,
// so apply those client-side. This lets the chart respect the FilterBar.
const visibleBookings = useMemo(() => {
const deptSet = new Set(filters.departments);
const nameSet = new Set(filters.names);
return bookings.filter((b) => {
if (deptSet.size > 0 && !deptSet.has(b.department ?? '')) return false;
if (nameSet.size > 0 && !nameSet.has(b.resourceName ?? '')) return false;
return true;
});
}, [bookings, filters.departments, filters.names]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
@ -104,7 +116,7 @@ export default function Resourcing() {
<WeeklyUtilisation rows={summary} onPeriodClick={setSelectedPeriod} />
</ErrorBoundary>
<ErrorBoundary label="Project Load per Person">
<ProjectLoadPerPerson bookings={bookings} />
<ProjectLoadPerPerson bookings={visibleBookings} />
</ErrorBoundary>
<ErrorBoundary label="FTE vs Freelancer">
<FTEvsFreelancer rows={summary} />