A date filter, three hex codes, and a test suite that hung for 30 minutes
The Starting Point
I've got a Laravel app with a homegrown reporting module. It has report pages built from a grid of widgets (KPIs, donuts, bar charts, tables), and each widget is backed by a saved report config that the engine turns into a query. One of those pages is an operations dashboard for a messaging app: how many messages went out, who read them, and who still hasn't acknowledged the high-priority ones.
The list started small:
- Add a date toggle: this week (Monday first), this month (from the 1st), last 7 days, last 30 days, and a custom from/to.
- Change the colours for the three priority levels to specific hex codes.
- Rename a second page, drop some action buttons, and give it the same date toggle.
- Make a table wrap its text instead of scrolling sideways.
- Add a card showing the average time between a message being sent, received and read.
Then push it all to main. That last bit took longer than everything else put together.
What Actually Happened
The date toggle was mostly already there
I went looking for how to build a page-level filter and found the engine already accepted runtime filter conditions from the page. Click-to-filter widgets send them as JSON in a crossFilters query param. Better still, the runner silently drops conditions on fields a data source doesn't have:
if (! in_array($condition['field'], $source->fieldSlugs(), true)) {
continue;
}
That turned out to be the key. The message source calls its date sent_date and the recipient source calls it broadcast_sent_date. So the page config lists both, the browser sends a between on each, and every widget keeps the one that applies to it. Sources with no send date (staff counts, channel sizes) just ignore it. I didn't touch the server code at all.
The page opts in with a small config blob:
'global_filters' => [
'dateRange' => [
'label' => 'Sent',
'fields' => ['sent_date', 'broadcast_sent_date'],
'default' => 'all',
],
],
The range maths lives in the browser. Two gotchas:
function ymd(d) {
// Local date, not toISOString(): that is UTC and slips a day either side of midnight.
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
// Monday-first week: getDay() is 0 for Sunday, so shift it
case 'week': return [ymd(daysAgo((today.getDay() + 6) % 7)), ymd(today)];
The end bound goes out as 2026-09-25 23:59:59 so datetime fields keep the whole last day. I also put the choice in the URL (?period=week) so a filtered view can be linked.
One small bug I caught before shipping: clicking "Custom" set the active period to custom before any dates were picked. If you then clicked another widget's filter, everything reloaded with no date window, while the toggle still said "Custom". The fix was for Custom to just open the date pickers and only change the period on Apply.
"Not seeing the change"
The colours should have been a one-liner. I changed the donut's colors array in the seeder, re-ran it, and said "done".
Then I actually opened the page: both donuts still in the default navy/red/blue. The other donut on the page, which had always had its own colours configured, was also on the default palette. That was the giveaway:
case 'pie': case 'donut': {
const c = new ApexCharts(el, { ..., colors: ['#00344c','#e9322e','#1d4ed8','#15803d','#f59e0b'] });
The page script hardcoded the palette for pies and donuts and ignored data.colors entirely. Bar and line charts used data.colors || [...]; pies never did. So every configured pie colour in the app had been decorative all along. One-line fix, but I'd called it done without actually looking at it in a browser. Lesson noted.
I also caught a second bug while I was in there. Colours were matched by position, in the order the report sorts its groups. Filter to one priority and the single remaining slice gets colour #1, which is the "critical" red. So I let colors be a map as well as a list:
private function sliceColors(mixed $colors, array $labels): ?array
{
if (! is_array($colors) || array_is_list($colors)) {
return $colors; // positional, as before
}
return array_map(fn ($label) => $colors[(string) $label] ?? '#94a3b8', $labels);
}
Renames that would have duplicated everything
The second page gets built by an idempotent seeder that matches widgets by title with updateOrCreate. Renaming "Issues Raised" to "Reports Sent" in the seeder would have left the old widget and created a new one next to it. Same for the menu entry, which had already been renamed by hand in the database to something the seeder didn't know about.
So the seeder now carries a rename map and moves old titles across before the upsert loop:
private const RENAMED_ITEMS = [
'Issues Raised' => 'Reports Sent',
'Issues Raised Over Time' => 'Reports Sent Over Time',
];
There was also a hand-made copy of each page that the seeder doesn't manage at all, so those got the same changes applied directly.
Time to read, attempt one: add a column
The data has a sent time per recipient and a read timestamp, and the engine can AVG a field. But it can't subtract two datetimes in a report config. So my first move was the one I'd seen elsewhere in the codebase: add a seconds_to_read column, have the sync job stamp it with TIMESTAMPDIFF, expose it as a field, average it.
Migration, sync statement, field, report, KPI card. I ran it and the card said "3s".
The real average was about two minutes. 3 was the count of reads, the other aggregate on the same report. Two bugs stacked on top of each other:
FieldDefinitiondefaults toaggregatable: false, and the aggregate builder quietlycontinues past non-aggregatable fields. So theAVGjust wasn't in the SQL. No error.- The KPI renderer, not finding its
valueFieldin the row, fell back to the first numeric column it could find:
} elseif ($valueField && isset($rowData[$valueField])) {
$value = $rowData[$valueField];
} else {
$value = collect($rowData)->first(fn($v) => is_numeric($v));
}
Even after fixing (1), a period with no reads would give AVG = NULL. isset() is false for null, so it would fall through and show the count of 0 as "0s". I switched it to array_key_exists so an empty value renders as a dash.
Time to read, attempt two: "we should not be adding columns"
Then I stopped and asked myself the obvious question: shouldn't this have been a computed field in the reports? As a general rule, we don't add columns for this.
It should have been. The reporting module already has formula computed fields that wrap a data source in a derived table and add SQL expressions as extra columns. They behave like real fields for filtering, grouping and aggregating. The only reason I'd skipped them was that the formula language had DAYSSINCE but nothing that took two datetimes.
The fix was to extend the language instead of the schema. I rolled back the migration, deleted it, reverted the sync, and added one function:
'SECONDSBETWEEN' => $this->secondsBetween($args[0], $args[1]),
protected function secondsBetween(string $start, string $end): string
{
if (DB::connection()->getDriverName() === 'sqlite') {
return "CAST(ROUND((julianday({$end}) - julianday({$start})) * 86400) AS INTEGER)";
}
return "TIMESTAMPDIFF(SECOND, {$start}, {$end})";
}
The SQLite branch is there because the test suite runs on SQLite and everything else in the formula compiler is MySQL-only. Without it, nothing built on a time difference could be tested end to end.
I also exposed the existing remote_created_at column as a sent_at field (a field definition, not a schema change), and the seeder creates the computed field:
'expression' => 'SECONDSBETWEEN(sent_at, complied_at)',
Same answer, 1m 59s, no migration. It's editable in the UI, and SECONDSBETWEEN is now available to anyone building reports.
Time to receive: the data doesn't really allow it
The ask was sent → received → read. I checked the upstream table's columns directly, and there's no received timestamp. Just a status and a updated_at.
So I looked at what updated_at actually meant, grouped by current status:
| Status | Gap from sent to last update |
|---|---|
| Sent (226) | 0–1s: just the row being created |
| Received (18) | 5s … 23m, then five at ~18 days |
| Read (3) | within 1s of the read time |
So updated_at is the received time, but only while a recipient is still sitting at "received". Reading overwrites it. Averaging those 18 would mean:
- leaving out everyone who went on to read (probably the fastest receivers)
- five offline phones dragging the mean to about 5 days when the typical value was a few minutes
- no median in the engine to soften that
I worked that out before building anything, and decided to build it anyway with guardrails. So it's another computed field, SECONDSBETWEEN(sent_at, status_updated_at). The report filters to "received" status and to under 24 hours, and the card is titled "Time to Receive (not yet read)". I added a small detail line to KPI cards so it says what the number is based on: "based on 13 recipients, under 24h". The real fix is upstream: the platform should store a received timestamp.
Pushing to main: the test that never finished
The working tree had a pile of someone else's uncommitted work in it (a documents feature, account changes, a Tracking tweak), so I only wanted to commit my 11 files. Before pushing I kicked off the full suite.
I gave up waiting after 10 minutes. I started a narrower run of reporting-related files, and that timed out too. A ps showed both still going, the full one at 30 minutes, with no output at all.
timeout isn't a thing on macOS (command not found: timeout), so I bisected with a poor man's version:
php artisan test $f > one.txt 2>&1 & p=$!
for i in $(seq 1 90); do kill -0 $p 2>/dev/null || break; sleep 1; done
kill -0 $p 2>/dev/null && { pkill -P $p; kill $p; echo "HUNG $f"; }
Every file passed except one, which never finished. The test was, and I promise this is real, called test_a_parent_id_cycle_does_not_hang_the_request. It hangs.
Was that me? My changes touched report page rendering, and that test visits a page. So I made a clean git worktree from HEAD, copied just my 11 files in, symlinked vendor, and ran it. It hung. Then I reverted my files in the worktree and ran it again on pristine HEAD. It hung again. Pre-existing, great.
Then the full run in the worktree came back with a Tracking test failing:
ModelNotFoundException: No query results for model [Modules\Reporting\Models\Report].
Neither Tracking file was mine. The test looked up a report called "Trail (last 6 hours)", but the error came from a command that creates "Trail (last 48 hours)". That name only existed in the uncommitted Tracking changes in my main checkout.
The penny dropped: I'd symlinked vendor. Composer's autoloader works out the base dir from __DIR__, which resolves the symlink, so every Modules\... class was being loaded from the main checkout, uncommitted work and all. Only the test files and views came from the worktree. My "clean HEAD" check hadn't been clean, and neither had the "it hangs on HEAD" conclusion.
Redo:
rm vendor
cp -Rc ../../vendor ./vendor # APFS clone: near-instant, no extra disk
composer dump-autoload
php -r 'require "vendor/autoload.php"; echo (new ReflectionClass("Modules\\Tracking\\Console\\BuildTrackingReport"))->getFileName();'
Once reflection confirmed classes were loading from the worktree, I re-ran everything. The Tracking failure was gone. The cycle test still hung on genuinely pristine HEAD, so it really is a pre-existing bug. Everything else: 221 passed, plus one failure in the Laravel boilerplate ExampleTest, which expects / to return 200 when it redirects to login.
Bonus footgun along the way: I built the file list into a variable and passed $FILES to php artisan test, and PHPUnit said the entire space-separated string was a test file that didn't exist. zsh doesn't word-split unquoted variables. ${=FILES} does.
Then commit only my paths, push. By now the push had taken far longer than the actual work.
What Worked
- A page-level period toggle that's generic: any report page opts in with
global_filters.dateRangeand a list of date fields. It relies on the engine dropping unknown-field conditions per source. - Colours by value, not position, and pie/donut charts that actually use their configured colours.
- Seeders that survive renames, with an explicit old → new map.
- Derived metrics as formula computed fields, not columns.
SECONDSBETWEENis in the formula language with a SQLite branch for tests. - KPI cards that are honest: an empty value shows "—", there's a
durationformat ("4m 1s", "2h 15m"), and an optional detail line saying what the figure was measured over. - Deploy without a shell: the page export/import carries the page, reports, computed fields,
global_filtersand menu placement, so the changes can be moved to production through the UI if the seeders can't be run there. The code has to be deployed first, or the computed fields reference functions that don't exist yet.
What I Learned
- Silent drops compound. A non-aggregatable field got skipped without a word, then a KPI "helpfully" fell back to another column. Each was defensible on its own, but together they showed a wrong number that looked right. If you default to lenient, don't also default to guessing.
isset()vsarray_key_exists()is a real bug class whenNULLis a meaningful result (anAVGover zero rows).- Check the screenshot before saying "done". The colour config was right and the renderer ignored it. Only looking at the page would have caught it.
- Extend the abstraction before bypassing it. I reached for a column because the formula language was missing one function. Adding the function was smaller, reusable, and didn't touch the schema. "No new columns for reporting values" is a standing rule now.
- Look at the data before promising a metric. "Time to receive" sounded easy until the timestamps showed the received time gets overwritten, and five offline phones skewed the average by three orders of magnitude.
- Symlinking
vendorinto a git worktree doesn't isolate anything. Composer resolves its paths through the symlink. Clone it (cp -Rcon APFS) andcomposer dump-autoload, then verify withReflectionClass::getFileName(). - macOS has no
timeout. A background process plus akill -0loop works fine for bisecting hanging tests. - zsh doesn't word-split
$VAR. Use${=VAR}, or an array. - Next time: run the per-file bisect first, not after two 10-minute timeouts, and verify in the browser before reporting a visual change as done.
Tech Notes
- Stack: Laravel 12, PHP 8.4, modular app (nwidart/laravel-modules), MySQL in dev, SQLite in tests, ApexCharts and Gridstack on the front end, PHPUnit 11.
- Dependencies: none added.
- Schema: one migration added, then rolled back and deleted. Net zero, deliberately.
- Commands worth remembering:
# poor man's timeout on macOS
cmd & p=$!; for i in $(seq 1 90); do kill -0 $p 2>/dev/null || break; sleep 1; done; kill -0 $p 2>/dev/null && kill $p
# a genuinely isolated worktree for a Composer project
git worktree add --detach ../wt HEAD
cp -Rc vendor ../wt/vendor && (cd ../wt && composer dump-autoload)
# zsh: split a string variable into args
php artisan test ${=FILES}
# roll back just the last migration batch
php artisan migrate:rollback --step=1
Zero Focus Dev
Breaking things, learning fast, sharing it all.