Rebuilding a reporting page one step at a time
The Starting Point
A client asked me to rework the layout of a reporting page on a Laravel app — two views, roughly a "project card" and a "programme card" (a parent that rolls up several projects). Both render on the same URL, branch on report type, share a lot of UI. It had grown organically and was starting to hurt: boxes floating in the middle of columns, risks table collapsing to zero rows when empty, cost summary stretched too wide, timeline only spanning 80% of the space for no good reason.
The client's style is step-by-step: they describe one change, I implement and rebuild, they test, then the next change. They'll bounce a vague plan and add more detail rather than let anything woolly ship. We worked through five "steps" on the project card, then five more on the programme card, over one long session.
Local dev is MAMP, not Docker. Vite for assets. The repo is case-sensitive in git but the filesystem isn't. That last detail bit me later.
What Actually Happened
The timeline dance
First ask: timeline at the top, full width, with a bit of breathing room. Easy CSS change — drop the 10% side padding, zero the margin, add padding-top: 5px. Except the fill width was computed against the old 80% span, so I also had to flip a multiplier in a Blade template:
$fillPercent = $positionInUsableWidth * 80; // was
$fillPercent = $positionInUsableWidth * 100; // now
Then we iterated on side padding: "try 5px each side… now 10px… now 15px". Three round-trips. UX is not something you can guess from a spec.
The clever bit: shared line budget
Summary and Activities are two separate editable lists in one column. The client wanted a "use whatever space is available" model — if Summary has 3 long items, Activities has less room, and the user should see how close they are to the limit while typing.
So I built a dynamic line-budget counter: when the edit panel opens, I measure the real rendered column on the card (behind the panel, still visible), subtract the two header heights, divide by the actual <li> line-height. For each item I inject a hidden <li> with matching width, set its text, and read offsetHeight.
function countItemLines(text) {
if (!text || !String(text).trim()) return 1;
const li = ensureBudgetMeasurer();
li.parentElement.style.width = budgetState.itemWidth + 'px';
li.textContent = String(text);
return Math.max(1, Math.round(li.offsetHeight / budgetState.lineHeightPx));
}
The counter says "Summary + Activities: 9 of 16 lines used". Over budget turns red and shows a soft warning. I repeated the pattern for Key Issues and (later, on the programme view) the single programme summary textarea whose budget shrinks as the projects table below it grows.
The backend bug I didn't cause but spent an hour on
Mid-session: "I'm editing existing summaries but the changes aren't saving after reload." Classic. I assumed I'd broken something. Turns out the controller had been silently dropping edits to existing summaries forever:
foreach ($request->summaries as $sum) {
if ($sum['id']) {
continue; // ← skips existing summaries, never updates them
}
$createIfNotEmpty($report->summaries(), [...]);
}
Same bug for activities. The issues/risks/milestones blocks right below did it correctly (where('id')->update(...)) — someone had just never finished the job. Two-line fix to mirror the correct pattern and suddenly edits persisted.
The risks table that ate itself
I switched the risks table to table-layout: fixed to get consistent row heights and padded empty rows. Then the user added a realistic risk with a long description, took a screenshot, and said "you've broken it". What happened: table-layout: fixed with no column widths makes all columns equal. Seven columns → each ~140px → long Description wraps to seven visual lines → row becomes 250px tall → pushes the dark-blue header bar out of the container's max-height: 25% → user sees a naked row with no header.
Fix: <colgroup> with proportional widths (32/7/7/28/10/8/8), plus CSS line-clamp at 2 on the Description and Mitigation cells so rows stay predictable:
.risks-table tbody td:first-child,
.risks-table tbody td:nth-child(4) {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
The "combined" misunderstanding
Programme card had separate Risks and Issues tables. The client's message: "The current risks and issues needs to be combined into one where the user can select one or the other using the same logic it currently has."
I read that as: a toggle that swaps which type is shown each period. Built it. New DB column, resource field, UI radio, visibility flipping JS. Shipped. The client pasted a screenshot of a reference layout and said "it shouldn't be an either/or". They meant intermixed in one table — risks AND issues as rows side-by-side, not one OR the other.
Ripped the toggle out. Rebuilt as one combined collection merged from both sources:
$combinedKeyItems = collect($reportJson['risks'] ?? [])
->map(fn($risk) => [
'type' => 'risk',
'description' => $risk['description'] ?? '',
// ...
])
->merge(
collect($reportJson['issues'] ?? [])->map(fn($issue) => [/* ... */])
)
->values();
The DB column I'd added for the toggle (key_items_type) is now dead weight. Left it in. A future me can drop it if it still matters.
The RAG colour that lied
Combined table renders a coloured RAG cell. I reused an existing riskColor() helper:
function riskColor($value) {
$char = strtolower(substr($value ?? '', 0, 1));
return $char === 'l' ? 'green' : ($char === 'm' ? 'amber' : 'red');
}
That works for risk severity — "Low"/"Medium"/"High" → first letter L/M/H → green/amber/red. But issue status uses "Green"/"Amber"/"Red" directly. An issue with status "Amber" → first letter 'a' → not 'l', not 'm' → fell through to 'red'. The client reported "I added an Amber issue and it's showing red".
Fix: a local helper in the blade that maps both variants to a single colour:
$ragColor = function ($val) {
if (empty($val)) return '';
$char = strtolower(substr($val, 0, 1));
return match($char) {
'l', 'g' => 'green',
'm', 'a' => 'amber',
'h', 'r' => 'red',
default => '',
};
};
The variance colours that are wrong on purpose
On milestones, positive variance days are shown in red, negative in green. That sounds backwards. It's not: in this domain positive days = behind schedule = bad; negative days = ahead of schedule = good. I almost "fixed" this when I first saw it. Glad I asked before touching it.
The case-sensitivity trap
Near the end, I went to commit. git status showed six migration files as untracked under Modules/ProjectReporting/Database/Migrations/ (capital D, capital M). But I'd been writing them to database/migrations/ (lowercase) — because that's what the plan said and I never verified.
macOS filesystem is case-insensitive by default, so writing to database/ just hit the existing Database/ directory. But git tracks paths case-sensitively. My git add Modules/ProjectReporting/database/migrations/... commands matched nothing. I had to re-add with the tracked capitalisation:
git add Modules/ProjectReporting/Database/Migrations/2026_...
Took me a moment to realise what was happening. Lesson: git ls-files | grep ... to see the tracked case when something looks off.
What Worked
Step-by-step rhythm. One thing at a time. The plan-first-then-reject loop is slower than batching everything up front, but the cost of a wrong implementation is also smaller. Easier to walk back when wrong.
Dynamic DOM-measured budgets. Hidden <li> injected into the real column, styled to match, its offsetHeight / lineHeight gives the actual display-line count. More accurate than character-count heuristics, works across font-size tweaks, recomputes on resize.
<colgroup> + table-layout: fixed. When you need predictable columns across a varying dataset, explicit column widths beat auto. Combined with -webkit-line-clamp on free-text cells, the table stays bounded.
Migrations as small additive units. Six tiny migrations in one session, each --path=ed independently. No risk of a global migrate triggering a pre-existing unrelated conflict, easy to script for a lead to run on their environment:
php artisan migrate --path=Modules/.../2026_04_23_000001_add_safety_incidents_...
php artisan migrate --path=Modules/.../2026_04_23_000002_add_tag_flags_...
# etc
What I Learned
- Always check the case git has on record, not what the filesystem shows.
git ls-files | grep <dir>is quicker than noticing after a failedgit add. table-layout: fixedwithout column widths is a trap — it distributes equally and ignores content. The fix is a<colgroup>.- Helpers are named after what they were built for, not what they do.
riskColor()was a severity mapper. Don't reuse it for anything with a different first-letter vocabulary. continueinside an update/create loop is a red flag. If the "update existing" branch is missing, items with anidget silently skipped forever. Check other iterations of the same pattern in the file; they usually have the right shape.- "Combined" is ambiguous. Always ask: one row per item with type mixed in, or a switcher between two sets? I guessed wrong, shipped the wrong thing, had to rebuild. Would have saved 20 minutes to ask.
- Measuring real DOM for UX budgets beats heuristics. Once you've got a hidden measurer in the layout, you can cover every variant for free.
- Percentage padding on grid items resolves against the grid track, not the parent grid. That's why my Cost Summary initially didn't line up with Summary above it —
2%of a 50%-wide grid track ≠2%of a 100%-wide wrapper. Redo the maths when moving things between layout contexts.
Tech Notes
Stack:
- Laravel 10, modular code layout, Blade views
- jQuery (legacy) + a chunk of vanilla JS in one long
app.js - Vite for asset bundling
- MAMP MySQL locally
- macOS filesystem (case-insensitive), git (case-sensitive)
Commands worth remembering:
# Run a single migration when the global one is blocked by an unrelated conflict
php artisan migrate --path=Modules/<Module>/Database/Migrations/<file>.php
# Clear Blade cache after view edits
php artisan view:clear
# See the case git actually tracks for a path
git ls-files | grep -i <fragment>
# Auth gh once, use everywhere
gh auth login # pick SSH + browser
gh pr create --base UAT --head <branch> --title "..." --body "..."
Migrations added this session:
| Table | Columns |
|---|---|
project_reports |
safety_incidents, is_subsidy, is_performance, is_safety, programme_summary, key_items_type |
report_milestones |
project_id |
report_issues |
owner, date |
One file took most of the churn: the main card partial. Hundreds of lines rewritten as layout shifted. Next session, first job is to split it into sub-partials per section before it grows further.
Zero Focus Dev
Breaking things, learning fast, sharing it all.