Add PocketBase login, first-sign-in password changes, account settings,
and user activation, reactivation and deletion with self-removal guards.
Protect application pages and APIs and keep private pages out of PWA
caches.
Resolve plant locations from site assignments and improve map controls,
profit chart views, navigation and mobile table layouts.
Validation: production build and TypeScript passed; 28 unit tests passed.
Backend access integration tests and mobile verification are documented.
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.
The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.
A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.
Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The container lines (MPLANT04/MPLANT05) have no matching plant item, so they
sat unbooked forever with no way to account for them. They can now be booked
to a SITE instead.
`sites` is a new PocketBase collection derived from the invoices — each
distinct invoice site becomes a site, created as invoices are imported. They
are never hand-typed, and never deleted: a site can carry notes and hold line
bookings, so removing one would orphan that history.
/sites lists every site with its revenue ex VAT, its VAT, the total billed,
how many invoices and machines it carries, and how many lines are still
outstanding. Each row links to a detail page listing the site's invoices and
every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a
group with Overview and Sites.
Booking a line to a site posts NO income — there is no machine to carry it —
it only marks the line accounted for, so it stops showing as unbooked. Exactly
one of booked_asset / booked_site is ever set, and a line with neither is what
surfaces as unbooked everywhere.
One container per site, as specified: once a machine-less line has taken a
site, that site is withheld from the next one's picker and shown disabled
("already has one") rather than hidden. Free sites sort to the top. The rule is
enforced server-side too, so it does not depend on the UI being the only way in —
re-posting a taken site is refused with 409. Verified: booking The Hills, then
trying again, then taking West Park, and the remaining line sees 8 free and 2
taken. Clearing a booking frees its site.
The site picker only appears on lines that need it — no machine, or one already
booked to a site — so fully-booked rows keep a single control.
Also fixed the Sidebar's group-active check, which derived its prefix from the
first child. For a group whose first child is "/" that yields "/" and matched
every route, so Dashboard would have been highlighted on every page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A screenshot at phone width showed an "INVOICE | LINES | TOTAL | STATUS"
header row while the data under LINES was money. The header and its data cell
had drifted apart: the earlier pass hid the Lines <td> but not the matching
<th>, because the loop that added the hide classes only matched text-left
headers and Lines is text-right. So the Total values rendered under a heading
that named a column which no longer existed.
The same screenshot showed the totals clipped — "41,710.50" reading as
"710,50". With only three columns left, the 520px min-width was still forcing
a sideways scroll, and the opaque sticky first column then sat on top of the
left of the money values. The mobile floor is gone, so the three remaining
columns size to their content, and the sticky and status gutters are trimmed
at mobile widths to let them fit 375px.
Below sm the table is now exactly Invoice, Total, Status — and every header
matches a visible cell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audited the invoice UI against the Round 4 checklist and fixed four things.
The summary table carried eight columns at a fixed 820px min-width, which at
375px is a long sideways scroll through three figures before reaching the
status badge — the one thing that needs acting on. Below sm it now keeps only
Invoice, Site, Total and Status, and drops to a 520px min-width, so the useful
columns are readable without scrolling. Period, line count and the ex-VAT/VAT
split are one tap away in the expanded detail, and the full eight columns
return from sm up where there is room.
The summary table also had no right-edge fade. Rule 4 asks for a visible
affordance on any wide table and it was simply missing, so scrolling sideways
looked like the table ended. Same for the month grid on /income, which was also
missing -webkit-overflow-scrolling. Both now share components/ScrollFade.
Two 10px labels in the app header ("Overheads 2026", "FY") were invisible at
375px but visible from 640/768px — and 768x1024 is one of the two sizes the
checklist tests, so they were a rule-2 violation at tablet width. Bumped to
12px. PlantRegisterTable's 10px filter count was checked and left alone: it
sits inside hidden lg:flex, so it never renders on mobile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an Invoices sub-menu under /income with a summary table (one row per
invoice, click to expand the full detail) and the data model behind it.
Data:
- invoices + invoice_lines PocketBase collections, created idempotently by
scripts/setup-invoice-collections.mjs
- line items transcribed from PHI 166/167/171 with per-invoice arithmetic
validation in the seed; a bad invoice is refused without blocking the rest
- invoice-*.json in data/source are auto-imported, so adding an invoice is
one file
Booking:
- manual book-in per line (Booked to), grouped by category
- POST /api/invoice-lines/:id/book moves the income row rather than adding a
second one, so a booking can be changed freely
- scripts/book-invoice.mjs is idempotent: rows are stamped with
monthly_income.invoice_line and replaced on re-run
- booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat),
read by both the app and the CLI so they cannot disagree
Money:
- the R24,205.13 column on these invoices is PLUS VAT, not escalation. The
seed now rejects VAT that disagrees with the stated rate.
- booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the
VAT stays visible rather than hidden.
Uploads:
- drag-and-drop PDF attached to invoices.source_pdf
- GET /api/invoices/:id/document proxies it — the collection is rule-locked,
so a direct PocketBase /api/files link 404s in a browser
Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into
months[i], so a machine with two income rows in one month (one per invoice,
which is normal multi-site use) showed only the last. SS01 September read
R20,700 instead of R35,305 while its own YTD included both. Months now
accumulate, per-month profit is computed once after accumulation, and
lib/rows.test.ts pins it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- public/brand/logo.png: 117×115 transparent PNG (source)
- public/icons/icon-192.png, icon-512.png, icon-maskable-512.png: PWA icons derived from logo
- public/icons/favicon.png (64), favicon-32.png (32): favicon sizes
- icon-maskable-512: logo on #003D5B indigo background, 80% safe zone
- layout.tsx: header logo + mobile drawer logo both use the new image
- Sidebar.tsx: drawer header logo uses the new image
- metadata.icons updated to include favicon sizes
- <link rel='icon'> added for 32×32 and 64×64 favicons
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed