Backend (33/33 tests, +5 new):
- Split Zoho parser's canonical "billable" into "billable" (bool column)
and "billingType" (string column with values like "Client Related" /
"Leave Hours" / "Idle Time"). Each parsed row now carries both, and
billable is cross-filled from billingType when only the latter is
present.
- Merge service computes leaveHours separately from non_billable_h: any
row with billingType "leave hours"/"leave" lands in the leave bucket
and is no longer double-counted as non-billable.
- UtilisationSummaryRow gains leaveHours: float; TimelogRow gains
billingType: str | None.
- /api/airtable/bookings accepts ?department=&name= (comma-separated
multi-value), folded into the filterByFormula alongside the date
overlap. Apostrophes in names are escaped. Cache key now includes
the filter values so different selections don't collide.
- /api/airtable/meta computes departments + employmentTypes from a
live fetch_resources call (sorted distinct), falls back to the
hardcoded lists on any exception. billingTypes/bookingStatuses
stay static.
- Logout cookie now mirrors the login cookie's HttpOnly / Secure /
SameSite / Path attributes with max_age=0 and empty value, for
consistency.
Frontend (typecheck/lint/build clean):
- types.ts: UtilisationSummaryRow.leaveHours: number.
- BillabilityBreakdown uses r.leaveHours directly; idle becomes
max(0, available - billable - nonBillable - leave). Capped to top
20 employees by (available + billable) with "Other (N)" rollup;
Legend replaced with compact inline swatches.
- BookingVsActual and FTEvsFreelancer: same top-20 + Other treatment
to prevent the ProjectLoad-style x-axis explosion at scale.
- Defensive sweep on WeeklyUtilisation, MonthlyUtilisation,
BookingVsActual, FTEvsFreelancer: null-coerce sort keys, Number()-
guard arithmetic, skip rows with no usable period/employee.
- getBookings signature gains department + name; Resourcing passes
them through. Client-side visibleBookings filter retained as
belt-and-braces since linked-lookup filterByFormula on Airtable
can be flaky.
- Tutorial steps.ts restructured to cover the new chart and CSV
export tags; existing TutorialOverlay defensive selector check
preserved.
- ErrorBoundary: removed dead eslint-disable directive flagged by
--report-unused-disable-directives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A full-year Zoho time-log export can run 30-60 MB and was getting
rejected with {"detail":"Payload too large"} on the Department tab
upload. 20 MB was an under-cautious default; 100 MB matches what
Apache will pass through without a LimitRequestBody override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
The collision check filtered self-matches by /opt/${SLUG}/, which only
works when the on-disk directory matches the URL slug. When the repo is
cloned to a different directory (e.g. /opt/loreal-utilisation-dept/ to
match the Bitbucket repo name while keeping URL slug "utilisation-dept"),
the script flagged its own apache-*.conf as a foreign collision and
refused to redeploy. Filter against \$REPO_ROOT instead, which holds the
actual on-disk path and matches what grep -l emits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Resource Name`, `Project Number (from Master)`, `Project Name (from
Master)`, `Department (from Resource Name)` on Booking Resource are
linked-record / lookup fields — Airtable always returns them as lists,
even when cardinality is 1. The previous booking normaliser only
handled one specific list case (`Resource Name (from Resource)`) and
let the other field aliases pass through as raw lists. The merge
service then crashed with `AttributeError: 'list' object has no
attribute 'strip'` on first call to /api/utilisation/summary in prod.
Introduce a `_flatten()` helper and apply it to every linked/lookup
field in `normalise_booking`. Also make `_name_key` in the merge
service defensive against raw lists so a missed flatten elsewhere
degrades gracefully instead of 500ing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The repo can be cloned to any directory (e.g. /opt/loreal-utilisation-dept
to match the Bitbucket repo name) — the slug we use for the URL path and
compose project is independent of the on-disk path. The previously printed
Apache Include line assumed `/opt/<slug>/`, which broke when the clone
location didn't match the slug. Use $APACHE_CONF (already absolute and
correct, derived from $SCRIPT_DIR) instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move `git pull --ff-only origin main` to the very top of the script so
every deploy reflects the latest committed code. Pull failure is now
fatal (was warn-and-continue). After the pull, hash-compare deploy.sh
against its pre-pull copy; if it changed, exec the new version with a
DEPLOY_RECURSE_GUARD env var to prevent any chance of infinite re-exec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fail fast if another app on optical-dev already claims /utilisation-dept/
in the shared Apache vhost or any sibling /opt/*/deploy/apache-*.conf,
and surface which 8200-8299 ports are already taken by sibling apps so
the port picker's choice is visible. Silently skips on dev laptops where
the vhost file isn't present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces a static SPA that shipped an Airtable PAT in the JS bundle.
The new architecture holds all secrets server-side, fronts the app
behind Apache on optical-dev with the shared-vhost split-build pattern,
and is designed for a later Azure AD/MSAL swap-in.
- backend/ FastAPI + uvicorn, local auth (Azure AD stub), Airtable
proxy with TTL cache, Zoho .xlsx/.csv parser, merge
service for utilisation summaries. 28 pytest tests.
- frontend/ React + Vite + TS + Tailwind + Recharts SPA. Login entry
chunk 12.83 KB gzipped; Recharts lazy-loaded. No tokens
or Airtable URLs in the built bundle.
- deploy/ Idempotent deploy.sh (port auto-pick 8200-8299,
.env-persisted) + split-build Apache include template.
- docker-compose.yml pins name: utilisation-dept and binds 127.0.0.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>