From 299c20ae57e493723d4b9e0b652cf997e82c7052 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 3 Oct 2026 16:45:23 +0200 Subject: [PATCH] Mobile: the top tiles overflowed on /income, hugged text on /sites, and repeated on invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate layout faults in the header rows, all reported from a phone. /income scrolled sideways by 158px. Three causes stacked, none of them responsive: the wrapper was p-8 (32px each side at 375px), the header row was `flex items-end justify-between` with no breakpoint, and the KPI row was `flex items-center gap-3` with no wrap, so the two tiles physically could not stack. Measured: main sat at x=36 w=339 at 375px, and the YTD Profit tile began at x=355 in a 375px viewport — about 20px of its 178px was on screen. - Wrapper is p-4 sm:p-8, and the header row stacks until lg, not sm. This one matters: at 768px a side-by-side row leaves the tile grid only 317px of the 704px available (the title block takes 371px), so two columns of 152px are too narrow for text-2xl money and the figures spilled out over their own tiles. Note the DOM check said pageHScroll:false at 768 — the page did not report overflow even though the text was visibly broken, and only the screenshot caught it. The tiles may not go 2-up until the title is above them. - The KPI row is grid grid-cols-1 sm:grid-cols-2, so the two tiles fill the row instead of being sized by their content. The machine card's P/L pill hung 32px outside its card (pill ended at x=374, the card at x=342), jammed against the viewport edge. IncomeView's mobile row 2 is sparkline + YTD + pill, and both ends are shrink-0 with nothing allowed to give: Sparkline's root is a fixed 120px box and the pill is shrink-0. /sites already passed width={72} for exactly this reason (see the note in Sparkline.tsx); /income never did. It does now, with min-w-0 on the YTD block. The pill now sits 16px inside the card. /sites was not broken — it never overflowed — it just looked wrong. `flex flex-wrap items-stretch` wraps, so nothing broke, but flex-wrap sizes tiles by CONTENT: "Revenue (ex VAT)" measured 197px inside a 343px row and the second tile wrapped to a line narrower still, both left-aligned, leaving a ragged edge and dead space. That is the same anti-pattern AGENTS.md already records as fixed on /income/invoices, never carried over here. It is a grid now. /income/invoices showed "Booked to income" and "Billed (incl VAT)" as two tiles with the same figure, which read as a duplicate. Not a bug: one sums booked lines, the other sums every invoice header, and all 10 invoices are currently fully booked (0 unbooked, problem inbox empty), so they are legitimately equal. They separate the moment a line is left unbooked — which is the whole point of the screen. Merged into one tile that says what the pair actually means, "R 780 003,61 of R 780 003,61 billed", with the VAT breakdown kept underneath because AGENTS.md requires VAT to stay visible. The gradient moved onto the merged tile so the money figure keeps its emphasis. Verified in the browser at 375, 768 and 1280: no page overflow, no element past the viewport edge outside the table's own scroller, nothing clipped inside the merged tile, and both desktop layouts unchanged. Typecheck and build green. --- app/income/page.tsx | 15 ++++++++++++--- app/sites/page.tsx | 11 +++++++++-- components/IncomeView.tsx | 9 +++++++-- components/InvoiceList.tsx | 37 ++++++++++++++++++++++++------------- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/app/income/page.tsx b/app/income/page.tsx index 6dbd564..453c7a9 100644 --- a/app/income/page.tsx +++ b/app/income/page.tsx @@ -25,8 +25,13 @@ export default async function IncomePage() { const totalProfit = rows.reduce((s, m) => s + m.ytdProfit, 0); return ( -
-
+
+ {/* Stacks until lg, not sm. As a side-by-side row the title block took 371px + of the 704px available at 768px, leaving the tile grid 317px — two 152px + columns, too narrow for text-2xl money, so the figures spilled over their + own tiles. At 375px the same row could not even fit, and the page scrolled + sideways by 158px. The tiles may only go 2-up once the title is above them. */} +
@@ -36,7 +41,11 @@ export default async function IncomePage() {

Project income per machine per month · 2026

-
+ {/* grid, not flex-wrap: a wrapping flex row sizes tiles by CONTENT, which + is the ragged-left-edge trap AGENTS.md records for /income/invoices. + Full-width stacked on mobile, two-up from sm — same shape the dashboard + cards already use there. */} +
YTD Income
{ZAR(totalIncome)}
diff --git a/app/sites/page.tsx b/app/sites/page.tsx index 129d6ff..e7a0d17 100644 --- a/app/sites/page.tsx +++ b/app/sites/page.tsx @@ -28,7 +28,10 @@ export default async function SitesPage() { return (
-
+ {/* Stacks until lg, not sm: at 768px a side-by-side row leaves the tile grid + only 317px (the title block takes 371px of 704px), so two columns of 152px + are too narrow for text-2xl money and the figures spill over their tiles. */} +
@@ -41,7 +44,11 @@ export default async function SitesPage() { {invoices.length === 1 ? "" : "s"} · {year}

-
+ {/* grid, not `flex flex-wrap items-stretch`: flex-wrap sizes tiles by + CONTENT, so these two hugged their text — 197px inside a 343px row — + leaving a ragged left-aligned pair with dead space beside each. Grid + items stretch by default, which is what items-stretch was doing here. */} +
Billed (incl VAT)
diff --git a/components/IncomeView.tsx b/components/IncomeView.tsx index 44e2aa8..9f9a9a7 100644 --- a/components/IncomeView.tsx +++ b/components/IncomeView.tsx @@ -183,8 +183,13 @@ function CardsView({
{/* Row 2 (mobile only): sparkline + YTD + P/L pill */}
- -
+ {/* 72/28, not the 120/36 default: Sparkline's root is a fixed-width + shrink-0 box, and the pill beside it is shrink-0 too, so with + nothing allowed to give the pill was pushed ~32px outside the + card and sat on the viewport edge. /sites already passes 72 here + for exactly this reason. */} + +
YTD income
{ZAR2(r.income)}
diff --git a/components/InvoiceList.tsx b/components/InvoiceList.tsx index 2779324..f4a209f 100644 --- a/components/InvoiceList.tsx +++ b/components/InvoiceList.tsx @@ -65,27 +65,38 @@ export function InvoiceList({ return ( <> - {/* A grid, not flex-wrap. The middle tile is far wider than the other two - because of its ex-VAT/VAT sub-line, so flex sized them by CONTENT and pushed - the third one onto a second row the moment the viewport dipped under ~644px — - which is squarely tablet territory. Equal columns keep all three on one row - from 640px up, and below that they stack as one clean column. */} -
+ {/* TWO tiles, not three, and a grid rather than flex-wrap. Both reasons are + the same lesson: "Booked" (summed over booked lines) and "Billed" (summed + over every invoice header) are the SAME figure whenever every line is + booked — which is the goal state — so two tiles showing one number read as + a duplicate rather than as a reconciliation. The reading that carries + information is "booked OF billed", so the single tile now says that, and + the two figures separate on their own the moment a line is left unbooked. + + Equal columns, not content sizing: this tile is far wider than the invoice + count beside it because of the "of … billed" line and the VAT breakdown, and + flex would size by content and shove the second tile onto its own row well + above the 640px where a tablet has plenty of room. */} +
- {/* What actually got booked against the machines, per the shared policy. */} -
-
{BOOKED_LONG_LABEL}
-
+ {/* What actually got booked against the machines, per the shared policy, + set against what the debtors have actually been invoiced. */} +
+
{BOOKED_LONG_LABEL}
+
{ZAR2(bookedAmount(bookedExVat, bookedVat))} + + {" "}of {ZAR2(totals.incl)} billed +
-
+ {/* VAT stays visible — the ex-VAT/incl-VAT split is the one figure a reader + cannot reconstruct from the headline above. */} +
{BOOK_INCLUSIVE_VAT ? `${ZAR2(bookedExVat)} ex VAT + ${ZAR2(bookedVat)} VAT` : `${ZAR2(bookedVat)} VAT not counted as income`}
- {/* What the debtors are actually invoiced. */} -