From a1a7729a0edea01748333f82dacc40f135ebe731 Mon Sep 17 00:00:00 2001 From: DJP Date: Sat, 16 May 2026 13:52:56 -0400 Subject: [PATCH] frontend: contain chart crashes with ErrorBoundary + null-safe Project Load aggregate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/components/ErrorBoundary.tsx | 44 +++++++++++++++++++ .../charts/ProjectLoadPerPerson.tsx | 21 ++++++--- frontend/src/pages/Department.tsx | 13 ++++-- frontend/src/pages/Resourcing.tsx | 13 ++++-- 4 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary.tsx diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..ea12882 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( +
+
+ +
+
+ {this.props.label ? `${this.props.label} failed to render` : 'Something went wrong'} +
+
{this.state.error.message}
+ +
+
+
+ ); + } +} diff --git a/frontend/src/components/charts/ProjectLoadPerPerson.tsx b/frontend/src/components/charts/ProjectLoadPerPerson.tsx index 2a7e6dc..ef4dd88 100644 --- a/frontend/src/components/charts/ProjectLoadPerPerson.tsx +++ b/frontend/src/components/charts/ProjectLoadPerPerson.tsx @@ -25,13 +25,22 @@ function aggregate(bookings: Booking[]): { data: Row[]; projects: string[] } { const projects = new Set(); const map = new Map(); 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) { diff --git a/frontend/src/pages/Department.tsx b/frontend/src/pages/Department.tsx index 9e35a5e..a2952b2 100644 --- a/frontend/src/pages/Department.tsx +++ b/frontend/src/pages/Department.tsx @@ -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 && ( <> - - - + + + + + + + + + )} diff --git a/frontend/src/pages/Resourcing.tsx b/frontend/src/pages/Resourcing.tsx index 311b351..b40cc15 100644 --- a/frontend/src/pages/Resourcing.tsx +++ b/frontend/src/pages/Resourcing.tsx @@ -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 && ( <> - - - + + + + + + + + + )}