mega-plant/HANDOFF-PROMPT.md

377 lines
19 KiB
Markdown
Raw Normal View History

# 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.