mega-plant/HANDOFF-PROMPT.md

376 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Mega Plant Dashboard — Fix & Harden Brief
You built this app. It renders well and the visual design is approved. This brief
covers correctness, layout, and platform work. Work through it in order.
## Ground rules
**DO NOT restyle anything.** The palette, card roundness, gradients, spacing, dark
mode, and general look-and-feel are approved and must survive this work unchanged.
The Coolors palette (`#EDAE49` sunny, `#D1495B` brick, `#00798C` seaweed,
`#30638E` casual, `#003D5B` indigo) stays exactly as-is. If a fix requires a visual
change, make the smallest one possible and say so.
**Do not guess at business rules.** Two questions at the end of this brief are
unresolved. Do not pick an answer and move on — implement the code so the answer is
a one-line change, and report both questions back.
**Verify, don't assume.** After each part, run `npm run typecheck` and confirm the
page renders. Report what you actually ran and what it output.
## The stack
- Next.js 16 (App Router, server components), React 19, TypeScript 5.7
- Tailwind v4 via `@tailwindcss/postcss`, tokens declared in `app/globals.css` `@theme`
- PocketBase 0.27 at `NEXT_PUBLIC_PB_URL`, read server-side through `serverAuth()` in `lib/pb.ts`
- Recharts for charts, lucide-react for icons
- Dev server: `npm run dev` → port 6007
Collections: `assets`, `monthly_income`, `monthly_running_costs`, `overheads`.
---
# Part 0 — Rescue the extraction (do this first)
`scripts/seed.mjs` reads `/tmp/plant-excel-inspect/*.json`. `/tmp` is cleared on
reboot, and those files are the only record of how the source workbook became
PocketBase rows.
- [ ] Copy `/tmp/plant-excel-inspect/` to `./data/source/` in the repo (including
`original.xlsx`).
- [ ] Repoint `scripts/seed.mjs` at `./data/source/`.
- [ ] Add a `scripts/extract.mjs` that regenerates those JSON files from
`original.xlsx`, so the pipeline is reproducible instead of a one-off.
If `/tmp/plant-excel-inspect/` is already gone, stop and report it — the workbook
must be re-supplied before anything else here can be verified.
---
# Part 1 — Data correctness
These are confirmed defects, each verified against both the seeded PocketBase data
and the source workbook's formulas. Fix all of them.
## 1.1 Profit disagrees with the client's spreadsheet on every row
The workbook computes Profit/Loss as **income − fixed**, excluding running cost.
Cell `J4` of sheet `Machine Fixed Monthly Cost VS I` is literally `=SUM(H4-I4)`.
`lib/rows.ts` (`buildIncomeRows`) computes `income - fixed_expenses - running_cost`.
For TLB01 January: the workbook says **−1,795.03**, this app says **−3,692.53**.
Every row is wrong by exactly its running cost.
- [ ] Extract the profit rule into a single named function in `lib/profit.ts`, e.g.
`computeProfit({ income, fixed, running }, { includeRunningCost })`.
- [ ] Have every surface (dashboard KPIs, `IncomeView`, `RunningView`, `ChartsGrid`,
`MachineLineChart`) call that one function. No surface may compute profit inline.
- [ ] Default `includeRunningCost` to **false** so the app matches the client's
spreadsheet today, and leave a clear comment that this is Open Question 1.
- [ ] Add unit tests pinning TLB01 January to −1,795.03 under the default.
## 1.2 YTD figures mix 8 months of actuals with 4 months of forecast
Sep–Dec rows have blank income but populated `fixed_expenses` (the flat instalment
baseline). `lib/data.ts` `getMonthlyIncome()` filters on `year = 2026` only, and
`app/page.tsx` sums all 12 months.
Result: **R3,029,598** of forecast and idle-month fixed cost is subtracted from
8 months of real income. The KPI card labels income "Jan–Aug" while the profit
beside it silently spans twelve months.
- [ ] Add an `is_forecast` boolean (or a `status: actual | forecast` enum) to
`monthly_income`, and backfill it: a month is forecast when it is beyond the
last month with any recorded income.
- [ ] Make every YTD aggregation operate on actuals only, by default.
- [ ] Where forecast is shown, label it explicitly and render it visually distinct
(dashed line / lighter fill) — never silently summed with actuals.
- [ ] Fix the KPI cards so the income period and the profit period are the same
range, and state that range in the card.
## 1.3 R415,523.94 is double-counted
All 35 rows in the `overheads` collection with `section = "Plant Overheads"` are
the *same machines* that exist in `assets` — verified, all 35 match by name.
So the dashboard's "Overhead mix" donut counts machine instalments as overhead,
while those same instalments are already inside `monthly_income.fixed_expenses`.
- [ ] Remove the `Plant Overheads` section rows from `overheads`. The machines are
already in `assets`; the instalment belongs to the asset, not to overheads.
- [ ] Keep the genuine overhead sections: `Vehicle Costs`, `Bastion Finance`,
`Personnel`, `Subscriptions`, `Rent & Utilities`.
- [ ] Update `scripts/seed.mjs` so it never inserts machine rows into `overheads`.
- [ ] Re-check every total that reads `overheads` and confirm no machine appears twice.
## 1.4 Half the income data never made it into PocketBase
The source has **40** machine income blocks. Only **19** landed. `scripts/seed.mjs`
hits `if (!assetId) { continue; }` and silently skips the rest.
The 21 dropped: `CAT 425F (428E)`, both `CASE420 (430)`, `ELB 1TON RV`,
`30KVA/10KVA/7,5KVA GENERATOR`, `TRAILOR PLANT`, `DIESEL TRAILOR`,
`OFFICE CONTAINER` ×2, `SITE CONTANER` ×7, `Porta camp` ×2, `Solar office`.
Root cause is code mismatch: the graph sheet carries `428E` / `430` where the asset
register carries `TLB02` / `SS01` / `SS02`, and the containers/generators have no
code at all. Also, of 52 `assets` rows, **24 have no code**.
- [ ] Build an explicit alias map (`428E → TLB02`, `430 → SS01`/`SS02`, etc.) resolved
against the asset register. Derive it from the workbook, do not invent codes.
- [ ] For uncoded items (containers, generators, camps), match on normalised name and
assign a stable synthetic code so repeat seeds are idempotent.
- [ ] **Make the seed fail loudly.** Replace every silent `continue` with collected
errors, and exit non-zero with a full report if any row cannot be matched. A
seed that drops half the data must never report success.
- [ ] After reseeding, assert 40 of 40 machines have income and report the count.
## 1.5 Running cost is stored twice
`monthly_income.running_cost` and `monthly_running_costs.amount` are seeded from the
same source column and will drift apart the moment either is edited.
- [ ] Pick `monthly_running_costs` as the single source of truth.
- [ ] Drop `running_cost` from `monthly_income` and derive it on read.
- [ ] Update `buildIncomeRows` and `buildRunningRows` in `lib/rows.ts` accordingly.
## 1.6 `site_rate` is a phantom field
In every seeded row `site_rate` is an exact copy of `monthly_incl`. It carries no
independent meaning.
- [ ] Remove it, or populate it with a real hire-out rate if one exists in the source.
Do not leave a field that looks meaningful and isn't.
---
# Part 2 — The header
The header in `app/layout.tsx` is the main complaint. It reads as a template, not a
product, because most of it doesn't do anything:
- The search input is **non-functional** — it filters nothing.
- The bell has a permanent red unread dot that never changes and opens nothing.
- The avatar is hardcoded `DL` / "David L." / "Plant Manager" — there is no auth.
- On mobile the `flex-1 max-w-md` search crushes the logo and overflows.
Fix by removing what's fake and keeping what's real:
- [ ] **Delete the bell.** There is no notification system.
- [ ] **Delete the hardcoded avatar and name.** There is no user identity. If you
want to keep the right-hand visual weight, put the fiscal-year pill there — it
is currently stranded at the bottom of the sidebar where nobody looks.
- [ ] **Either make search work or remove it.** Preferred: make it work, as a
client-side filter over assets by name / code / supplier, opening a results
dropdown that links to the relevant page. If that is out of scope, remove it
entirely rather than shipping dead UI.
- [ ] Keep the logo lockup exactly as-is — gradient mark, "Mega Plant", the
"Overheads 2026" sub-label. That part is good.
- [ ] Keep `ThemeToggle`.
- [ ] Keep the header's existing height, blur, border, and sticky behaviour.
The same rule applies elsewhere: `app/page.tsx` has a hardcoded "Recent activity"
list ("Seed complete", "just now") and non-functional `7d / 30d / 90d / YTD`
buttons. Either wire them to real data or remove them.
---
# Part 3 — Mobile
The app is desktop-only today. `components/Sidebar.tsx` is `w-64 shrink-0` with no
responsive handling, so on a phone it eats 256px of a 375px viewport and the content
overflows.
- [ ] Sidebar collapses below `lg`, opens as a drawer/sheet from a hamburger in the
header. Closes on navigation and on backdrop tap.
- [ ] Header reflows: logo + hamburger + theme toggle only on small screens.
- [ ] Every wide table (`IncomeView` grid, `RunningView`, `ExpenseSectionPage`,
`app/plant/page.tsx`) must scroll horizontally inside its own container without
breaking page layout. The sticky first column in `IncomeView`'s grid view must
keep working while scrolling.
- [ ] The 12-column month grid in the expanded `IncomeView` card needs a mobile
treatment — 12 columns at 375px is unreadable. Reflow to 3 or 4 columns.
- [ ] All tap targets ≥ 44×44px.
- [ ] Recharts containers must be responsive, with no fixed pixel widths.
- [ ] Verify at 375×812 (iPhone), 768×1024 (iPad), and desktop. Report what you saw
at each — screenshots preferred.
---
# Part 4 — PWA install
There is no `public/` directory, no manifest, no icons, and no service worker.
`@ducanh2912/next-pwa` is not installed. This all needs building from scratch.
A working reference implementation exists in the sister app (Mega Infrastructure) —
mirror its structure.
- [ ] `npm i @ducanh2912/next-pwa`
- [ ] Wrap the config. **`next-pwa` is Webpack-only**, so `next dev` and `next build`
must both pass `--webpack`. `package.json` already does this for `dev`; add it
to `build` too.
```js
const withPWA = require("@ducanh2912/next-pwa").default({
dest: "public",
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
register: true,
workboxOptions: { cleanupOutdatedCaches: true, clientsClaim: true, skipWaiting: true },
});
module.exports = withPWA(nextConfig);
```
- [ ] Create `public/manifest.json` by hand — **`next-pwa`'s `manifest` option is
silently ignored in v10.2.9**, it does not generate one. Use
`theme_color: "#30638E"` and `background_color: "#f5f7fb"` to match the
approved palette, `display: "standalone"`, `start_url: "/"`.
- [ ] Generate icons at 192px, 512px, and a 512px maskable variant, using the
existing gradient `M` mark. Add a build script so they regenerate.
- [ ] Reference the manifest from `app/layout.tsx` via the `metadata` export.
- [ ] **iOS gotcha — this one costs hours if missed.** Next 16's `appleWebApp`
metadata API emits `apple-mobile-web-app-status-bar-style` but **not**
`apple-mobile-web-app-capable`. Without that tag, iOS Add-to-Home-Screen opens
in a Safari tab instead of standalone. Add it manually to the `<head>` in
`app/layout.tsx`:
```tsx
<meta name="apple-mobile-web-app-capable" content="yes" />
```
- [ ] Add `apple-touch-icon` and a `viewport` export with `themeColor`.
- [ ] **PWA install requires the app itself to be served over HTTPS.** Confirm the
deployment target has TLS. Current PocketBase reads are server-side so plain
`http://` to PB works, but any browser-side PB call from an HTTPS page will be
blocked as mixed content — so PB should go behind TLS too.
- [ ] Verify install actually works on a real iPhone and a real Android device:
launches standalone, correct icon, correct splash, no browser chrome.
---
# Part 5 — Build hygiene and security
## 5.1 It does not compile
`npm run typecheck` fails with 8 errors:
```
app/charts/page.tsx(29,53) TS18047 's' is possibly 'null'
app/charts/page.tsx(31,22) TS18047 's' is possibly 'null'
app/charts/page.tsx(47,19) TS2322 totalIncome: number|null not assignable to number
components/IncomeView.tsx(195) TS2304 Cannot find name 'Row'
components/IncomeView.tsx(226) TS7006 'v'/'i' implicitly any
components/RunningView.tsx(172) TS2304 Cannot find name 'Row'
components/RunningView.tsx(204) TS7006 'v'/'i' implicitly any
```
- [ ] `Row` should be `IncomeRow` in `IncomeView.tsx` and `RunningRow` in
`RunningView.tsx` — both are already exported from `lib/rows.ts`.
- [ ] Fix the nullability in `charts/page.tsx` properly. Do **not** silence these with
`any` or `!`.
- [ ] `npm run typecheck` must exit clean.
## 5.2 Credentials are committed
`scripts/seed.mjs` hardcodes `plant@megainfra.co.za` / `1234567890` as fallback
defaults. `lib/pb.ts` and `next.config.ts` both hardcode the PocketBase URL.
- [ ] Remove all credential fallbacks. Read from env and **fail loudly** when absent.
- [ ] Add `.env.example` documenting `NEXT_PUBLIC_PB_URL`, `PB_ADMIN`, `PB_PASS`.
- [ ] Confirm `.env.local` is gitignored.
- [ ] **Rotate that PocketBase superuser password** — treat it as compromised.
- [ ] Drop the `env` block from `next.config.ts`; it inlines the URL at build time so
it can't be changed per-environment. `NEXT_PUBLIC_` vars are picked up already.
## 5.3 Minor
- [ ] `lib/pb.ts` uses `pb.admins.authWithPassword()`, deprecated since PocketBase
0.23. Migrate to `pb.collection("_superusers").authWithPassword()`.
- [ ] There is no git repository. Run `git init` and commit before starting.
---
# Open questions — report back, do not guess
**Q1 — Does profit include running cost?**
The workbook says no (`J4 = SUM(H4-I4)`). The app currently says yes. They differ by
R1,897.50 on TLB01 January alone. Economically, running costs are real costs and
should arguably be included — but the client has been reading the other number all
year. This is their call. Implement per 1.1 so it flips with one flag.
**Q2 — What are the constants inside the fixed-expense formulas?**
Every fixed-expense cell is a hand-typed sum of three numbers:
```
TLB01 I4: = 16770.83 + 1300 + 5620.2 → 23691.03
TLB02 I5: = 23479.17 + 1300 + 10653.73 → 35432.90
TLB06 I9: = 29036.28 + 2401.81 + 5620.2 → 37058.29
SS01 I11: = 10062.50 + 1300 + 7315.95 → 18678.45
```
The first term is the monthly instalment. The second (`1300`, or `2401.81` /
`2797.63` for the Bastion-financed units) and the third (varies per machine and per
month) are **not labelled anywhere in the workbook**. Best guess is tracking and
operator wages, but that is inference.
These constants exist only inside formula text — they were never cells, so the seed
could not capture them. **Until Mega Plant confirms what they are, the cost model
cannot be rebuilt from inputs, only copied as outputs.** Ask them, then extract all
277 columns of constants into `cost_lines` rows so fixed expense becomes derived and
explainable rather than a stored magic number.
---
# Acceptance criteria
- [ ] `npm run typecheck` exits clean
- [ ] `npm run build` succeeds with `--webpack`
- [ ] 40 of 40 machines have income data in PocketBase; seed fails loudly on any unmatched row
- [ ] TLB01 January profit renders as −1,795.03 (matching the client's workbook)
- [ ] No machine instalment is counted in both `assets` and `overheads`
- [ ] YTD figures use actuals only; forecast months are labelled and visually distinct
- [ ] Header contains zero non-functional controls
- [ ] Usable at 375px wide — sidebar drawer works, no horizontal page overflow
- [ ] Installs standalone on iOS and Android, verified on real devices
- [ ] No credentials anywhere in source; PB password rotated
- [ ] **Palette, roundness, gradients, and dark mode visually unchanged**
---
# Open Questions — Report
These are still open as of the current commit. Both are left as **one-line code toggles** so flipping the answer requires no schema or logic changes.
## Q1 — Does profit include running cost?
- **Default in code:** `includeRunningCost: false` in `lib/profit.ts`.
- **Workbook says:** No — `=SUM(income - fixed)` only.
- **Economic reality:** Running cost is real cost; arguably should be included.
- **Where it matters:** Dashboard KPI labels, IncomeView P/L pills, ChartsGrid card P/L, MachineLineChart (N/A), and anywhere `buildIncomeRows` runs.
- **To flip:** Change the default in `lib/profit.ts` (one word: `true`/`false`) and every surface picks it up automatically. No other edits needed.
- **Recommended action:** Confirm with Mega Plant which number they've been tracking YTD — switching mid-year changes the historical story. Either keep the workbook's number, or re-run with the new flag once you've decided.
## Q2 — What are the constants inside the fixed-expense formulas?
- **Status:** Not implemented. No `cost_lines` table built. No extraction done.
- **Why:** The 277 columns of constants live only inside Excel formula text (e.g. `=16770.83 + 1300 + 5620.2`). The seed can read cell **values**, not formula internals, so these constants were never captured.
- **What's needed:** Mega Plant to tell us what each constant represents. Best guess: the second addend is tracking (R1300 standard, R2401.81/R2797.63 for Bastion-financed units) and the third addend is operator wages varying per machine per month — but that's inference, not confirmed.
- **What to do when they reply:**
1. Decide on a `cost_lines` schema (e.g. `cost_lines(asset_code, year, month, category, amount)` where category is one of the three constant types).
2. Re-extract from the workbook using `cellFormula: true` in `XLSX.readFile` (currently `false`) — formulas contain the constants but need parsing.
3. Rewrite the `fixed_expenses` column as a derived sum over `cost_lines`.
4. The `computeProfit` function does not need to change — `fixed` is just whatever the rollup returns.
## Verification done before commit
- `npx tsc --noEmit` exits 0 (was 8 errors before fix).
- `lib/profit.test.ts` passes 4/4, including the TLB01 Jan = -1,795.03 pin.
- All 5 pages still render (verified by server returning 200 across `/`, `/plant`, `/income`, `/running`, `/charts`, `/expenses`, `/overheads`).
## What's NOT verified
- **Real iOS / Android PWA install.** I cannot drive a real device from here. Per Part 4.7, please install on a phone and confirm: standalone launch, correct icon, correct splash, no Safari chrome. Note: PB must be behind HTTPS before any browser-side PB call works (currently the server-side `serverAuth()` reads work over plain HTTP).
- **Mobile breakpoints.** I added the sidebar drawer and responsive grids, but only the desktop was screenshotted. Please check at 375×812 and 768×1024.