zod was previously only a transitive dependency; declared explicitly so
it cannot vanish under a dependency bump. tsx rather than Node's native
type stripping because the metrics modules import via the @/* alias,
which Node does not resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No behaviour change — deletes the duplicate group/sort/pairwise walk now
that computeFuelLogStatuses does it. computeFuelVariance now has exactly
one caller.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overview built its own fuel-variance walk that never read
attention_dismissed_at, so a vehicle dismissed on /dashboard/fuel stayed
on the overview forever. It also fetched fixed_costs and fuel_logs with
getList(1, 500), silently undercounting past 500 rows and skewing the
monthly cost tile.
Now uses the shared computeVehicleAttention/groupAttentionBySite and
fetches both collections in full.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One per-log variance pass plus a per-vehicle aggregate, replacing the
duplicated walks in the overview and fuel pages. Reports acknowledged
state via the existing isAlertAcknowledged rather than filtering, so
callers keep control.
Also declares attention_dismissed_at on VehicleRecord. The field is
written by PATCH /api/vehicles/[id]/attention-dismiss and read by two
pages, but was undeclared — those reads only compiled because an
untyped getFullList returns loosely-typed records.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detail and list now resolve start date identically, so projected total
and projected profit agree between the two surfaces. Timesheets are
filtered to this site before feeding the start-date calculation, so
another site's timesheet can no longer set this one's start date.
Deletes eight now-dead local helpers (parseDurationMonths, pickStartDate,
isoWeekKey, monthKey, expenseDate and the three series builders) along
with the duplicate RATE_PER_LITRE and WORKING_DAYS_PER_MONTH consts.
SiteStatus on the detail view gains 'upcoming', which the shared
projection can now return; without the matching STATUS_COPY entry the
header badge would dereference undefined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the inline per-site roll-up with computeSiteSpend /
resolveSiteStartDate / computeSiteProjection, and drops the redundant
second employees fetch now that the first one selects the site field.
Two correctness fixes in the same wiring:
- fuel_logs was fetched with fields 'id,site,litres', so every row fed
to the start-date resolver carried an undefined date_logged and was
silently skipped. Now selects date_logged.
- The resolver was only given fixed costs and fuel logs, while the
detail page gives it all four sources. For a site with no
job_start_date whose earliest timesheet or income claim predates its
earliest cost, the two pages would still have disagreed. Timesheets
are now bucketed by site via the employee join, and income_claims is
fetched, so both surfaces see identical candidates.
Also drops fixed/payroll/material/fuel from the returned summary — they
are not declared on SiteSummary and no component reads them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Single import surface for the dashboard pages, and later the MCP server
and chat agent tool layer, so every caller gets identical numbers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Weekly and monthly income-vs-expense buckets plus the 30-day fuel cost
series, lifted from the site detail page. The two near-identical bucket
walkers are unified behind one accrueAll helper, and 'now' is injectable
so the series are deterministic under test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run-rate extrapolation, remaining budget and the under/on-track/over
ladder. The 'upcoming' status previously existed only on the list page
and now applies to both surfaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Payroll, fuel, material and P&G aggregation as a pure function. Knows
nothing about time or PocketBase, so the projection and the agent tool
layer can both build on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sites list and site detail pages resolved a site's start date
differently, producing different projected profit for the same site:
list job_start_date -> LATEST fixed-cost date -> now
detail EARLIEST activity date, job_start_date ignored entirely
Different start date gives different elapsedMonths, which feeds
projectedTotal and projectedProfit directly. The detail page also read
from a global, unfiltered timesheet list, so another site's timesheet
could set this site's start date.
One implementation now: explicit job_start_date, else the earliest
site-filtered activity date, else now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parseDurationMonths was duplicated verbatim in the sites list and site
detail pages; isoWeekKey and monthKey were private to the detail page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RATE_PER_LITRE was hardcoded in four separate files, so a pump-price
change meant finding all four. It now lives here only, alongside the
working-day, weekly-capacity and month-length constants the site
financial roll-up needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Updated the vehicles API to allow PATCH requests with an empty unit_type. This change ensures that existing records with unit_type='' can be updated without validation errors, while maintaining the default value of 'fuel' for new inserts. This adjustment resolves issues with updating pre-existing vehicle records that lack a unit_type field.
53 vehicles predate the unit_type field and have unit_type=''. PB
validates the WHOLE record on any PATCH, so as long as unit_type was
required, no record with an empty value could be updated (Dismiss,
inline-edit, anything calling .update()). Making the field optional
unblocks all 53 immediately.
The default='fuel' still applies to new inserts.
Note: the live PB schema lost its 'default' value during the PATCH
(see review below) — only 'required' was actually changed in the
stored schema. Fresh records will be inserted with unit_type='' (or
null) until the application sets it explicitly. The bootstrap
definition still has default='fuel' so fresh environments get it.
Idempotent migration: scripts/migrations/2026-08-28-vehicles-unit-type-optional.py
applied successfully; re-run is a no-op.
formatPbError walked err.data but PB's ClientResponseError wraps the
field-level error object at err.data.data. The outer err.data has
just three keys (data, message, status), all 'invalid' on a failed
insert — useless to operators. Read err.data.data (and
err.data.data.fieldErrors if present) so the operator sees WHICH field
rejected the row.
The live PB fixed_costs collection never received the is_recurring field
added in 87bb179 — the bootstrap script is collection-create-only and
errors out on already-existing collections, and no migration added the
field. Smoke-test rows sent is_recurring=false and got it silently
dropped (PB ignores unknown fields on write), then the partition helper
treated the missing value as monthly (its !=== false fallback).
Two changes:
1. Drop 'default: true' from the bootstrap schema definition. A bool
default only applies when the field is absent from the write payload,
so this is belt-and-braces — but it removes any doubt that a default
could ever silently override an explicit false. The partition helper
still treats absent values as monthly, and the backfill migration
patches pre-existing rows to true.
2. Make the backfill migration self-contained: if the field is missing
from the collection, add it (bool, required=false, no default) before
patching rows. Idempotent — no-op if already present.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds two new sections below the existing equipment table:
- Attention required: rows where the row's vehicle unit_type doesn't
match vehicle_unit_type_at_import. Operator can delete the row from
here.
- Weekly plant hours log: per-week entries with hours, budget,
cost/hr, value, and over-budget badge.
The topbar gets two new buttons (Download template, Import plant
hours) sitting next to the existing EditModeToggle.
Task 8's implementer dropped warnings[] in favour of a mismatched
count, but the design spec mandates warnings[]. Task 11's
ImportPlantHoursButton consumes warnings[] and crashes at runtime
when the response is parsed. Restore warnings[] (one entry per
mismatched-unit-type row, plus any schema-level warnings from
validatePlantHoursRow) while keeping the useful mismatched count.
Mirrors ImportFuelLogsButton for the plant-hours weekly log upload
flow. Supports drag/drop, click-to-browse, shows import summary
(total / imported / mismatched / skipped), and links to the
exception-report CSV when rows are skipped.
Lets the inline-edit flow on /dashboard/plant set the vehicle's
unit_type (fuel|plant) so the plant-hours import route's type-mismatch
warnings can be resolved by reclassifying the vehicle.
Adds a route.test.ts mirroring the attention-dismiss precedent: 11
cases covering happy paths for unit_type='plant'/'fuel', invalid
unit_type dropped (with/without other valid fields), existing
description+project_code behaviour preserved, JSON parse errors,
empty-payload errors, and PB write failures.
Pure orchestration (importPlantHours) takes parsed source rows +
vehicle/site lookups and produces prepared plant_hours_log records
+ per-row errors. The route handles multipart upload, batch creation,
PB lookups, and insert-with-error-capture. Mismatched unit_type
vehicles are imported as audit-only records; PB dup-key failures
are recorded as 'duplicate' errors instead of failing the batch.
- Normalise date diff keys via parseDate()/slice() so imported rows
aren't all dumped as exceptions (date format mismatch silently
mis-classified every row as not_imported).
- Branch CSV vs XLSX when re-parsing the source file (type:'buffer'
doesn't work for CSV uploads).
- Validate batchId against /^[A-Za-z0-9]{15}$/ before interpolating
into the PB filter to close a filter-injection sink.
- Lift headers.indexOf out of the row loop, gate console.log, convert
dynamic imports to static, fix variable shadow.
buildPlantHoursExceptionCsv() emits a 15-column CSV with the 13
template columns + reason + operator_note, RFC-4180-quoted. The
GET /api/plant-hours/exception-report/[batchId] route re-parses the
source file from data_import_batches.original_file, diffs against
the imported plant_hours_log rows for that batch, and emits the CSV
of rows that did NOT import.
Emits the canonical xlsx with a sample row derived from the first
vehicle in PocketBase. Falls back to a hardcoded sample if the
vehicles collection is empty.
Two follow-up fixes from the Task 5 code-quality review:
- scripts/generate-plant-hours-template.ts uses
fileURLToPath(import.meta.url) instead of __dirname. The
project's tsconfig has module: esnext, so the moment the
package flips to ESM the original would throw
ReferenceError: __dirname is not defined.
- src/lib/plant-hours-template.ts documents that the frozen
header row is a best-effort request — SheetJS Community
Edition silently drops the !freeze property (only Pro emits
frozen-pane XML). The function still produces the canonical
13-column workbook, but the doc comment no longer claims a
behaviour that doesn't happen.
Threaded attentionDismissedAt from the page mapper into the view.
When set, the header shows a pill with the timestamp and a Restore
button that DELETEs /api/vehicles/{id}/attention-dismiss and
refreshes the page. Errors render inline below the pill.
Slug-to-prose map for restore failures mirrors the dismiss-side
pattern from commit c633681 — RESTORE_ERROR_COPY at module scope,
friendly copy in window-less inline error slot.
- Disable all dismiss buttons while any is in flight so rapid
clicks on a second vehicle don't silently no-op.
- Map API error slugs (bad_id, not_found, update_failed) to
user-facing copy so window.alert shows prose, not machine tags.
- Add aria-busy to the dismiss buttons.
- Drop redundant e.stopPropagation(); preventDefault() is
sufficient to suppress the wrapping <Link>.
buildPlantHoursTemplateWorkbook() emits the canonical 13-column xlsx
with a frozen header row, typed cells (date/time/number), a sample
row using the supplied vehicle, and a Notes column widened for
readability. Used by both /api/plant-hours/template and the seed
script that writes RAW_DOCS/Plant_Hours_Template_2.xlsx.
Adds two tests that lock in behaviour the previous test file relied on
implicitly:
- validatePlantHoursRow rejects when the supplied vehicle's qr_code_id
doesn't match row.vehicle_qr (the guard the schema extends beyond
the original spec)
- computePlantHoursFields rounds value to 2 decimal places (a
fractional-input case the integer-only assertion didn't exercise)
Pure validator covering all the error/warning cases from the spec:
unknown QR, bad date range, end_hours < start_hours, bad time format,
missing cost (cell + vehicle), duplicate week, and the type-mismatch
warning. computePlantHoursFields() derives hours/value/over_budget/
hours_mismatch and the vehicle_unit_type_at_import snapshot.
Click on an AlertCard or AlertListRow to hide the vehicle from
'Attention required' instantly. POST /api/vehicles/{id}/attention-dismiss
stamps the field; optimistic state mirrors that locally so the row
disappears before the network roundtrip. Error path reverts the
overlay and shows window.alert (same pattern as inline-edit).