Returns 404 unless ENABLE_CHAT=true. No authentication yet — must stay
disabled in production until phase 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each adapter imports exactly one SDK. index.test.ts pins the env gate as
fail-closed: only the exact string 'true' enables chat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every tool figure is computed by src/lib/metrics and matches the
dashboard. A number the model derives itself would look authoritative
and reconcile with nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runs locally as your own process over stdio, so it opens no network
listener and adds no attack surface to the deployed app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every numeric field comes from @/lib/metrics. Results carry a display
block of preformatted strings so the model never formats or rounds
money itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The only module in the agent layer that touches the network. Kept
separate from formatting so tool output is unit-testable offline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Humans say 'the Hills' or 'C26/09', not record ids. Ambiguity is
returned explicitly so a tool can ask rather than silently pick the
wrong contract.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.