mega-plant/HANDOFF-PROMPT.md

19 KiB
Raw Permalink Blame 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.
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:

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