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:
parent
a1a7729a0e
commit
c485757dc3
2 changed files with 60 additions and 16 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue