From c485757dc3bf731f9e6cdbfebb3192a8c9ed7350 Mon Sep 17 00:00:00 2001 From: DJP Date: Sat, 16 May 2026 13:57:11 -0400 Subject: [PATCH] frontend: cap Project Load chart to top 10 projects, drop legend, honour filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real Airtable bookings have 100+ unique project names in a single month. The previous chart rendered one +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 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) --- .../charts/ProjectLoadPerPerson.tsx | 62 ++++++++++++++----- frontend/src/pages/Resourcing.tsx | 14 ++++- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/charts/ProjectLoadPerPerson.tsx b/frontend/src/components/charts/ProjectLoadPerPerson.tsx index ef4dd88..6e1ae2a 100644 --- a/frontend/src/components/charts/ProjectLoadPerPerson.tsx +++ b/frontend/src/components/charts/ProjectLoadPerPerson.tsx @@ -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(); +function aggregate(bookings: Booking[]): { data: Row[]; projects: string[]; otherLabel: string | null } { + // Pass 1: project totals to choose the top N. + const totals = new Map(); + 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(); 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 (
-

Project Load per Person

+
+

Project Load per Person

+ Top {TOP_N} projects by hours · hover bars for details +
- - + {projects.map((p, i) => ( - + ))} diff --git a/frontend/src/pages/Resourcing.tsx b/frontend/src/pages/Resourcing.tsx index b40cc15..f47f569 100644 --- a/frontend/src/pages/Resourcing.tsx +++ b/frontend/src/pages/Resourcing.tsx @@ -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() { - +