frontend: contain chart crashes with ErrorBoundary + null-safe Project Load aggregate

Resourcing page went blank in prod the first time a booking arrived
with a null resourceName (Airtable placeholder rows, or any booking
whose resource lookup didn't resolve). The Project Load per Person
chart's aggregate then crashed on `null.localeCompare(...)` during
sort, and with no error boundary in the tree, React unmounted the
entire route — visible to the user as "loads, then goes blank".

- ProjectLoadPerPerson.aggregate: coerce nullable lookup values to
  scalars before keying/sorting; treat empty resourceName as
  "Placeholder", missing project name as "Unknown", and NaN totals
  as 0.
- New ErrorBoundary component (class — React still requires class
  for boundaries) that renders a card with the error message instead
  of unmounting the parent.
- Wrap each chart on both Resourcing and Department in its own
  ErrorBoundary so a future chart-specific bug only blanks that one
  card, not the whole page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-16 13:52:56 -04:00
parent 8e28464bdf
commit a1a7729a0e
4 changed files with 79 additions and 12 deletions

View file

@ -0,0 +1,44 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
interface Props {
label?: string;
children: ReactNode;
}
interface State {
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// eslint-disable-next-line no-console
console.error('[ErrorBoundary]', this.props.label ?? 'unknown', error, info.componentStack);
}
reset = () => this.setState({ error: null });
render() {
if (!this.state.error) return this.props.children;
return (
<div className="card border-red-300 bg-red-50 text-red-800">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-none" aria-hidden />
<div className="flex-1">
<div className="font-semibold">
{this.props.label ? `${this.props.label} failed to render` : 'Something went wrong'}
</div>
<div className="mt-1 text-sm">{this.state.error.message}</div>
<button onClick={this.reset} className="mt-2 text-sm underline">Try again</button>
</div>
</div>
</div>
);
}
}

View file

@ -25,13 +25,22 @@ function aggregate(bookings: Booking[]): { data: Row[]; projects: string[] } {
const projects = new Set<string>();
const map = new Map<string, Row>();
for (const bk of bookings) {
projects.add(bk.projectName || bk.projectNumber || 'Unknown');
const projectKey = bk.projectName || bk.projectNumber || 'Unknown';
const row = map.get(bk.resourceName) ?? { employee: bk.resourceName };
row[projectKey] = ((row[projectKey] as number) ?? 0) + bk.totalHoursBooked;
map.set(bk.resourceName, row);
// 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 hours = Number(bk.totalHoursBooked) || 0;
projects.add(projectKey);
const row = map.get(employee) ?? { employee };
row[projectKey] = ((row[projectKey] as number) ?? 0) + hours;
map.set(employee, row);
}
return { data: [...map.values()].sort((a, b) => a.employee.localeCompare(b.employee)), projects: [...projects] };
return {
data: [...map.values()].sort((a, b) =>
String(a.employee).localeCompare(String(b.employee)),
),
projects: [...projects],
};
}
export default function ProjectLoadPerPerson({ bookings }: Props) {

View file

@ -7,6 +7,7 @@ import BookingVsActual from '../components/charts/BookingVsActual';
import BillabilityBreakdown from '../components/charts/BillabilityBreakdown';
import Loading from '../components/Loading';
import ErrorBox from '../components/ErrorBox';
import ErrorBoundary from '../components/ErrorBoundary';
import { useAirtableData } from '../hooks/useAirtableData';
import { useTimelog } from '../hooks/useTimelog';
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
@ -113,9 +114,15 @@ export default function Department() {
{!summaryError && summary.length > 0 && (
<>
<MonthlyUtilisation rows={summary} showForecast={filters.showForecast} />
<BookingVsActual rows={summary} />
<BillabilityBreakdown rows={summary} />
<ErrorBoundary label="Monthly Utilisation">
<MonthlyUtilisation rows={summary} showForecast={filters.showForecast} />
</ErrorBoundary>
<ErrorBoundary label="Booking vs Actual">
<BookingVsActual rows={summary} />
</ErrorBoundary>
<ErrorBoundary label="Billability Breakdown">
<BillabilityBreakdown rows={summary} />
</ErrorBoundary>
</>
)}
</div>

View file

@ -6,6 +6,7 @@ import ProjectLoadPerPerson from '../components/charts/ProjectLoadPerPerson';
import FTEvsFreelancer from '../components/charts/FTEvsFreelancer';
import Loading from '../components/Loading';
import ErrorBox from '../components/ErrorBox';
import ErrorBoundary from '../components/ErrorBoundary';
import { useAirtableData } from '../hooks/useAirtableData';
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
import { downloadCsv, rowsToCsv } from '../lib/csv';
@ -99,9 +100,15 @@ export default function Resourcing() {
{!error && (
<>
<WeeklyUtilisation rows={summary} onPeriodClick={setSelectedPeriod} />
<ProjectLoadPerPerson bookings={bookings} />
<FTEvsFreelancer rows={summary} />
<ErrorBoundary label="Weekly Utilisation">
<WeeklyUtilisation rows={summary} onPeriodClick={setSelectedPeriod} />
</ErrorBoundary>
<ErrorBoundary label="Project Load per Person">
<ProjectLoadPerPerson bookings={bookings} />
</ErrorBoundary>
<ErrorBoundary label="FTE vs Freelancer">
<FTEvsFreelancer rows={summary} />
</ErrorBoundary>
</>
)}
</div>