A case study in what human–AI collaborative engineering actually looks like — the decisions, the dead ends, and the debugging.
The Goal
Two websites on the same server needed a “heartbeat counter” — a small bar at the bottom of each page showing how many people had visited, with like and share buttons. A simple idea. Surprisingly deep engineering.
-
whanau.tv — a Laravel/PHP framework site, the community portal. The counter here measures the pulse of the community itself.
-
iho.whanau.tv — a plain HTML/PHP site, the Te Reo Māori learning video player. The counter here measures the reach of individual learning moments across 36 hours of nanolearning content.
The metaphor we used throughout: a flow meter on a beating heart.
What AI++ Co-Design Actually Looks Like
This wasn’t “ask AI to write the code and paste it in.” It was a genuine back-and-forth: human context and judgment, AI pattern recognition and code generation, human verification and testing, AI diagnosis and revision. Roughly 27 exchanges across multiple sessions, each one building on the last.
The human brought:
-
Domain knowledge (what the counter means in the context of #iuwe)
-
Server access (PuTTY, WinSCP, Notepad++)
-
Ground truth (screenshots of what actually happened)
-
Judgment about what mattered and what could wait
The AI brought:
-
Architecture design
-
Code generation across PHP, JavaScript, and Redis
-
Systematic elimination of false hypotheses
-
Explanation at every step in plain language
Neither could have done it alone at this pace.
The Sprint — In Plain Language
Act 1 — Building the Foundation
Designing the data flow. Before writing a single line, we had to decide how to count. The answer was a two-layer system: Redis (an ultra-fast in-memory store) catches every visit the instant it happens, and MySQL (the permanent database) stores the history. Think of Redis as a whiteboard and MySQL as the filing cabinet.
Writing the counter service. PageViewService.php — the brain of the operation. It receives a visit, works out where in the world the visitor is (timezone → region), checks if they’re a new unique visitor, increments the right counters in Redis, and queues the visit for later saving to MySQL.
Building the API. Two routes: POST /api/page-views (record a visit) and GET /api/page-views/counts (read the totals). These are the doors that both websites talk through.
Building the display component. An Alpine.js component that sits invisibly on every whanau.tv page, fires a POST when the page loads, then fetches and displays the count as a compact floating bar at the bottom of the screen.
Act 2 — The First Wave of Bugs
| Bug | Root Cause | Fix |
|---|---|---|
| Session error on API routes | Laravel’s session isn’t available on API routes by design | Added hasSession() check before reading session ID |
| Redis pipeline silent failure | Code called .execute() — correct method is .exec() |
One-letter fix |
| Double-slash in Redis keys | Path had a leading slash AND the prefix ended with a slash | ltrim() to strip the leading slash |
| Alpine.js timing failure | Component ran before Alpine had finished loading | Wrapped initialisation in alpine:init event listener |
| Counter always showed zero | JavaScript was reading data.today.total; API returns data.all_time.total |
Corrected the key path in the fetch handler |
Each of these was a silent failure — the page loaded, no errors appeared, but nothing was being counted.
Act 3 — Porting to iho.whanau.tv
iho.whanau.tv has no framework — no Laravel, no Alpine.js. The counter logic had to be rewritten in vanilla JavaScript: trackView(), fetchCounts(), toggleLike(), sharePage(). Same API, different language.
Separating the path keys. Both sites share one API, so they needed to count separately. whanau.tv records under the key whanau.tv/ (no leading slash). iho.whanau.tv records under /iho.whanau.tv/ (with a leading slash). A deliberate naming convention to keep the two sites’ data cleanly separated in Redis.
Other fixes in this phase:
-
Loading spinner getting stuck on video buffering events — fixed by correctly handling both
playingandwaitingvideo events -
Counter bar overlapping the info bar — fixed with
bottom: 70pxCSS adjustment -
Display format changed from verbose text to compact
53:24(total:unique)
Act 4 — The 302 Mystery
This was the hardest bug. It took the longest to find because every obvious hypothesis was wrong.
The symptom. Every POST to the API returned an HTTP 302 redirect to the homepage instead of a JSON response. Both counters stopped working.
What we ruled out, in order:
-
Middleware in
Kernel.php— clean, no auth on API routes -
RouteServiceProvider.php— clean -
The controller itself — read every line, no
redirect()calls anywhere
The breakthrough. The curl test being used to diagnose the problem was missing the Accept: application/json header. When Laravel’s validation fails (because page_path was absent from the bare test request), it asks: “is this an API request?” It decides based on the Accept header. Without it, Laravel treats the caller as a web browser and redirects back — hence the 302. With the header present, it returns a proper 422 error.
The API was never broken. The test was wrong.
The lesson: A diagnostic tool that doesn’t accurately simulate the real caller can send you on a very long detour.
Act 5 — The Count Stays at 2
The symptom. Even after the API was confirmed working, the counter displayed 2:2 and never changed.
The Redis prefix discovery. Laravel automatically prefixes all Redis keys with a string derived from the app name. APP_NAME=whanau.tv → Str::slug('whanau.tv') → whanautv → full prefix: whanautv_database_. Running redis-cli keys "*pv*" revealed 37 keys that had been invisible to earlier searches.
The timezone/date mismatch. Redis had 12 views stored under the key dated 2026-05-08 (New Zealand date). But getPageCounts() was querying the key dated 2026-05-07 (UTC date — NZ is 12 hours ahead). The views existed; the query was looking on the wrong day.
The fix. Rewrote getAllTimeCounts() to use Redis::keys() with a wildcard pattern (pv:realtime:total:{pagePath}:20*) to scan all date keys for a page path and sum them up, rather than only looking at today’s UTC date. Timezone-proof.
Act 6 — The Missing MySQL Write
While reading the service file carefully, a critical omission was found: flushBuffer() — the method that saves Redis data to MySQL — was building a $records array and then never inserting it. The PageView::insert($records) call was simply missing. Views were being counted in Redis but would disappear after 48 hours when the Redis TTL expired.
The fix. Added the insert call inside a try/catch block, with proper error logging. Views now persist permanently.
The Result
| Site | Counter | Status |
|---|---|---|
| whanau.tv | 53:24 (total:unique) |
Live, incrementing, persistent |
| iho.whanau.tv | 6:2 (total:unique) |
Live, counting independently |
| POST /api/page-views | Returns 201 JSON | Fixed — was 302 due to missing Accept header |
getAllTimeCounts() |
Scans all Redis date keys | Fixed — was only reading UTC date key |
flushBuffer() |
Inserts to MySQL | Fixed — was building records but not saving |
The Meta-Lesson
The deepest bugs weren’t in the code — they were in the assumptions:
-
That a test without proper headers was testing the right thing — the 302 mystery
-
That “today” means the same date everywhere — the timezone/UTC mismatch
-
That writing code to save data is the same as the data actually being saved — the missing insert
Each of those took careful, systematic elimination of false leads before the real cause emerged. That is what a real debugging sprint looks like — and it is also what AI++ co-design looks like at its best: not magic, but rigorous collaborative reasoning, one step at a time.
Part of the #iuwe intergenerational universal whānau education initiative — 36 hours of Te Reo Māori nanolearning, 3,600 phrases, targeting 6 billion lifelong learners.