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:
parent
8e28464bdf
commit
a1a7729a0e
4 changed files with 79 additions and 12 deletions
44
frontend/src/components/ErrorBoundary.tsx
Normal file
44
frontend/src/components/ErrorBoundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue