Commit graph

177 commits

Author SHA1 Message Date
DEls
dee027aeb9 build(mcp): add MCP SDK, zod and tsx for the agent tool layer
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>
2026-08-29 20:25:39 +02:00
DEls
e82754ee38 docs(plan): agent tool layer + read-only MCP server (phase 2B)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:13:39 +02:00
DEls
2a548e9ebc refactor(fuel): build rows from the shared variance pass
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>
2026-08-29 20:04:51 +02:00
DEls
f966a7315a fix(dashboard): honour alert dismissals and stop truncating at 500 rows
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>
2026-08-29 20:04:51 +02:00
DEls
f255761491 feat(metrics): extract fuel attention roll-up
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>
2026-08-29 20:04:51 +02:00
DEls
15206fa650 docs(plan): fuel attention extraction plan (phase 2A)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:04:51 +02:00
DEls
a1263b39e6 refactor(fuel): use the shared RATE_PER_LITRE constant
Removes the last two of the four hardcoded 25.65 declarations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:47:30 +02:00
DEls
da8d19ab55 refactor(sites): use shared metrics on the site detail page
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>
2026-08-29 19:47:30 +02:00
DEls
14d62eb34c refactor(sites): use shared metrics for the sites list roll-up
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>
2026-08-29 19:47:22 +02:00
DEls
5dc049ecf4 feat(metrics): add barrel export
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>
2026-08-29 19:47:14 +02:00
DEls
00168c0439 feat(metrics): extract income-vs-expenses and daily fuel series
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>
2026-08-29 19:47:14 +02:00
DEls
7bd6a7659f feat(metrics): extract site projection and burn status
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>
2026-08-29 19:47:09 +02:00
DEls
e0419f9ae9 feat(metrics): extract site spend roll-up
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>
2026-08-29 19:47:09 +02:00
DEls
d8b6d887e8 fix(metrics): unify site start date across list and detail pages
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>
2026-08-29 19:47:09 +02:00
DEls
74defc6d95 feat(metrics): extract duration and date-bucket helpers
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>
2026-08-29 19:47:00 +02:00
DEls
19032667dc feat(metrics): add shared constants module
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>
2026-08-29 19:47:00 +02:00
DEls
2d36e9b1cd fix(vehicles): handle empty unit_type during updates
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.
2026-08-29 19:17:14 +02:00
DEls
9ea0a1f65e fix(pb): make vehicles.unit_type optional
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.
2026-08-28 15:26:33 +02:00
DEls
0ea0257edb fix(plant-hours): surface real PB field-level error in import response
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.
2026-08-28 14:43:29 +02:00
DEls
0290ced51a fix(schema): ensure is_recurring=false writes are preserved
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>
2026-08-28 13:43:38 +02:00
DEls
ad12a3c01c feat(plant): weekly hours log + attention required on /dashboard/plant
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.
2026-08-28 13:40:30 +02:00
DEls
a214012d99 fix(plant-hours): restore spec-mandated warnings[] in import response
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.
2026-08-28 13:36:46 +02:00
DEls
394a5c0033 feat(plant-hours): ImportPlantHoursButton component
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.
2026-08-28 13:31:30 +02:00
DEls
c9789d89f4 feat(vehicles): PATCH /api/vehicles/[id] accepts unit_type
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.
2026-08-28 13:27:22 +02:00
DEls
b22badb4f0 feat(sites-list): include one-off overheads in per-site spent total 2026-08-28 13:24:46 +02:00
DEls
1909c5c3d3 feat(plant-hours): DELETE /api/plant-hours/[id] route
Removes one plant_hours_log row. Used by the Attention Required panel
and the weekly log table's row menu.
2026-08-28 13:23:58 +02:00
DEls
512c68f7a2 refactor(site-detail): extract PnGSection, add one-off to outflows panel, reuse PnGItemLike 2026-08-28 13:22:13 +02:00
DEls
da21518f42 refactor(fixed-cost-dialog): drop defaultType prop, use field-message lookup, local-date default 2026-08-28 13:22:07 +02:00
DEls
84a7d5bd74 feat(plant-hours): POST /api/plant-hours/import route
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.
2026-08-28 13:19:06 +02:00
DEls
28c4bc3eeb fix(site-detail): make is_recurring optional on PnGItem per spec 2026-08-28 13:17:24 +02:00
DEls
b59380455c feat(site-detail): split P&G card into monthly + one-off sections 2026-08-28 13:14:15 +02:00
DEls
014954b670 fix(plant-hours): exception-report correctness + safety
- 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.
2026-08-28 13:13:27 +02:00
DEls
bd47d3fc70 feat(site-detail): partition fixed costs into monthly + one-off 2026-08-28 13:08:12 +02:00
DEls
5636730f5e feat(plant-hours): exception-report CSV builder + route
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.
2026-08-28 13:06:17 +02:00
DEls
73ad9fdb5a feat(types): extend PnGItem + SiteDetailData for one-off partition 2026-08-28 13:04:48 +02:00
DEls
11658bce2d feat(api): POST /api/sites/[id]/fixed-costs 2026-08-28 13:01:58 +02:00
DEls
9f2c10851c test(fixed-costs): add failing API route tests 2026-08-28 12:57:49 +02:00
DEls
7b8fe71db3 feat(plant-hours): GET /api/plant-hours/template route
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.
2026-08-28 12:57:29 +02:00
DEls
c36eaeed51 fix(plant-hours-template): ESM-safe __dirname + freeze note
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.
2026-08-28 12:55:08 +02:00
DEls
70de32cd62 feat(fuel): Dismissed pill + Restore on vehicle detail page
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.
2026-08-28 12:54:34 +02:00
DEls
71eb1c00cc docs(plan): fix byDateDesc to return 0 on equal dates 2026-08-28 12:54:02 +02:00
DEls
e0e46b1c46 feat(lib): add partitionFixedCosts helper 2026-08-28 12:53:38 +02:00
DEls
c633681789 fix(fuel): review fixes for dismiss button
- 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>.
2026-08-28 12:52:29 +02:00
DEls
1e212a7339 feat(plant-hours): template workbook generator + seed file
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.
2026-08-28 12:51:14 +02:00
DEls
f629e4b369 test(partition-fixed-costs): add failing tests 2026-08-28 12:50:36 +02:00
DEls
3c379de5c4 test(plant-hours): cover unknown-QR deviation + value rounding
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)
2026-08-28 12:48:42 +02:00
DEls
4b5c93da37 feat(types): add is_recurring to FixedCostRecord 2026-08-28 12:47:40 +02:00
DEls
dc966086bd docs(plan): mark backfill migration as required after schema deploy 2026-08-28 12:47:02 +02:00
DEls
840c18cf6e feat(plant-hours): row validator + derived field helpers
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.
2026-08-28 12:46:13 +02:00
DEls
29524b362d feat(fuel): Dismiss button on attention rows + optimistic overlay
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).
2026-08-28 12:45:59 +02:00