April produced about seven hundred and ninety contributions across four repositories. May produced ninety-one.
3cat-Sdn-Bhd/3cat 24 pull requests (private)
3cat-Sdn-Bhd/automation 1 pull request
dansday-com/dansday-discord-bot 1 commit (395 in April)
dansday-com/dansday-main 1 commit (45 in April)That is an eighty-eight percent drop in a single month, and this is the article where I have to be straight about why.
Ninety-one against seven hundred and ninety
Look at where the collapse actually is. The day job produced twenty-four merged pull requests, which is a completely normal month by the standards of the previous two years — more than January 2025, more than March 2025, roughly the same as September 2024.
What stopped was everything else. The Discord bot went from three hundred and ninety-five commits and twenty-two pull requests to one commit. This website went from forty-five commits to one. Both of them simply stopped on 1 May and did not restart.
Two record months in a row, one of which included building a portfolio site from an empty repository in four weeks while shipping thirty-eight pull requests at work, and then nothing.
What I published on 14 May
Halfway through the month I published an article on this site called Recharging the Engine, subtitled My Honest Approach to Burnout and Finding Balance. I am going to quote it rather than paraphrase it, because these retrospectives are supposed to describe what happened and this is what happened:
Lately, I have been thinking a lot about the illusion of the perfect
work-life balance. In our industry, we often glorify the grind, the
late-night debugging sessions, and the endless race to ship the next
feature. But there comes a point where the engine simply runs out of
fuel, and burnout sets in. When I hit that wall, I have learned that
the best thing I can do is stop.
There are days when I simply cannot bring myself to go to the office.
It is not because I am lazy or because I do not care about my work. It
is because my brain has reached its absolute capacity.There is one line in it that belongs in an engineering retrospective more than a personal one: trying to force productivity when you are running on empty only leads to bad decisions, messy architecture, and deeper exhaustion.
I would point at the previous two articles in this series as evidence for that claim rather than against it. March gave me a nine-line fix that lived in production for two minutes before being reverted. It also gave me a status guard that read the wrong database column entirely and stopped fully-paid orders from advancing — a mistake I did not catch until April, and which I initially misdiagnosed even then. Those were not the months where I was careful. They were the months where I was fast.
The rest of the article is three things: listen to the physical signals, normalise taking a complete break, and seek out environments with zero cognitive load. The specific form that took, according to the other articles I published that fortnight, was low-mechanic games, a Minecraft server on a small AWS instance, shader packs, and conversations with strangers online. On 6 May I published something called Camping with 99 Cats.
I do not have an engineering lesson to attach to that and I am not going to invent one. The number at the top of this article is ninety-one, and the honest explanation for it is in an article I wrote on 14 May rather than in any repository.
The day job did not notice
The twenty-four pull requests at work are the part that surprises me looking back. If the previous section were the whole story you would expect the job to have suffered, and it did not.
Most of the month is the flash sale feature finishing what April started, and it resolves something I flagged as an open conflict last month.
The problem: a flash sale has a hard start time and a countdown, and this shop serves its pages from a ten-minute edge cache that I introduced in September 2025 specifically so I could delete an entire invalidation system. A sale that opens at noon and a page that may be ten minutes stale are incompatible.
The answer was not to weaken the page's caching. It was to build an explicit, per-route escape hatch in nginx and then make two API endpoints eligible for it. Four small pull requests on 8 May do the work, and the mechanism is in nginx.conf:
map $request_uri $header_cache_control {
~^/(admin|checkout|warranty)/.*$ 'private,no-cache,no-store,must-revalidate,max-age=0';
~^/api/.*$ 'private,no-cache,no-store,must-revalidate,max-age=0';
default 'public,max-age=600,stale-while-revalidate=900,stale-if-error=1800';
}
map $request_uri $php_controls_cache {
~^/api/(promotion|flash-sale) 1;
default 0;
}fastcgi_hide_header Cache-Control;
add_header Cache-Control $effective_cache_control always;Two maps. The first is the site-wide policy. The second is a flag, true for exactly two paths, and $effective_cache_control resolves to PHP's own header when that flag is set and to nginx's policy otherwise. Everywhere else, nginx hides whatever PHP sent and imposes its own answer; on those two routes, the application is trusted to decide.
What that replaced was a hack. The previous version of the same map had a line reading ~^/api/(promotion|flash-sale) ''; — an empty cache-control string as a way of saying not this one. The refactor turns a special case into a declared capability.
The controller then uses it, though only on one branch:
return response()->json([
'active' => false,
...
])->header('Cache-Control', 'public, max-age=300, s-maxage=300');Five minutes, shared caches included — on the response that says there is no sale. The branch where a sale is running does not get a header in that diff, which is the correct way round: the answer that changes on a schedule stays uncached, and the answer that is true most of the time gets cached for five minutes.
The routes also had to be made cacheable at all, which is the part I would have missed if I had only read the titles. They already existed under /api/; a separate pull request moved them into a group that strips five middlewares:
Route::prefix('api')
->withoutMiddleware([
StartSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
AddQueuedCookiesToResponse::class,
EncryptCookies::class,
])A response that sets a session cookie cannot be shared-cached, because Set-Cookie makes it per-visitor. Declaring s-maxage on an endpoint that still runs session middleware would have achieved nothing. Removing the session is what makes the cache header true.
Two more changes in that cluster are not about caching at all, and I had assumed they were. One raises the FastCGI buffer sizes across three virtual hosts and adds a second sub_filter rule for the alternate form of an S3 hostname — the buffers because sub_filter rewrites the response body as it streams and now has more work to do. The other changes those filters from rewriting S3 URLs to $host to rewriting them to hardcoded domains:
- sub_filter 3cat-production-assets.s3.ap-southeast-1.amazonaws.com $host;
+ sub_filter 3cat-production-assets.s3.ap-southeast-1.amazonaws.com 3cat.my;Rewriting asset URLs to whatever Host header arrived means a request with an unexpected Host produces a page whose images point at that host. Hardcoding the domain per virtual host closes that. It is four lines across three files and nobody filed it as a security ticket.
The timer's own change is not the loading spinner I assumed either. It is a guard against fetching twice:
function load() {
if (state.loaded || state.loading) return;
state.loading = true;
fetch('/api/flash-sale?v={{ CartRule::max("updated_at") ... }}')And that query string is the detail I like most in the month. The cache-busting parameter is a timestamp derived from the most recently updated cart rule, so the five-minute cached response is invalidated the moment anybody edits any promotion — without an invalidation system, a webhook or a queue. The URL changes because the data changed. It is the same idea as September 2025's decision to let a cache expire rather than be told, and it is a better version of it.
The largest piece of the flash sale work is not caching at all. It is 815 additions against 154 deletions, titled with the word Efficiency in brackets, and the ticket explains why:
Currently, the team is required to configure a Flash Sale as a completely
separate, full-blown promotion. This involves manually setting up voucher
codes, discount values, and pricing rules specifically for the flash sale.
Because a flash sale is realistically just a subset of an overarching
monthly promotion, this process forces redundant configuration work,
clutters the system with duplicate pricing rules, and increases the risk
of setup errors.So a flash sale stops being its own promotion and becomes something you switch on inside an existing one. It needed a new 259-line resolver to work out which promotion applies, 186 changed lines in the product-page promotion endpoint, and a hundred and sixteen in a controller that previews which conditions a rule will match — so whoever configures it can see the answer before saving.
That is the same ticket as March's promotion creation screen, which cut setup from three or four hours to twenty minutes by collapsing three configuration pages into one. This one removes the duplicate promotion underneath it. Both are about the people who run this shop, and neither changes anything a customer sees.
Refunds, and a catalogue you can edit in bulk
Two larger pieces landed mid-month, both about the people who operate this shop rather than the people who buy from it.
Refund Process Improvement — 1,363 additions, 26 deletions, seven files. Three of those files are new admin views, and the largest is a 517-line PDF template. In February 2024 I wrote about being pleased with a 434-line printable invoice, because an invoice is the one page a customer keeps. A refund note is the same document at the worst moment, and it took twenty-seven months longer.
The specification is a single arithmetic rule and one guard:
Teega only needs to fill in Refund Amount
Grand Total is calculated (= Subtotal - Discount - Refund Amount)
Grand Total Cannot be less than 0 (prevent Refund Amount from being
larger than balance refundable)One field for the operator, everything else derived, and a floor at zero so nobody can refund more than the order has left to give. That last clause is the whole ticket — an over-refund is money leaving the company with no order to account for it, and the only thing standing between that and a typo is a comparison against zero.
I am not sure that is unusual and I am fairly sure it is not defensible. Refunds are rarer than purchases, so they are always the last flow to get built properly, and the people who suffer for that are a small number of customers having their worst experience of the company plus whoever on the operations team has to do it by hand.
Bulk Update Catalogue on Bagisto is 1,468 additions and no deletions at all, and it is not the same thing as March's bulk editing. March added inline bulk edits on a page. This is a round trip: an export defining its own column set, a migration inserting the attributes that make products bulk-updatable, and a 733-line service that takes the edited file back and applies it. The operator leaves the admin panel, works in a spreadsheet, and uploads the result. In March I wrote that I had spent two years optimising the customer's path while the team edited variants one at a time. That observation has produced roughly three thousand lines of tooling since, and it is the single most useful thing I have noticed while writing these retrospectives.
Fourteen hundred lines of Gemini deleted
On 12 May I merged two pull requests. One deletes 1,494 lines across eleven files. The other updates Claude workflows to pass a model argument through claude_args. A week later, in a new repository called automation, ninety-two lines adding Claude code review and action workflows.
The deleted eleven files are worth listing, because deprecated CLI command files undersells what came out:
.github/workflows/gemini-dispatch.yml -221
.github/workflows/gemini-scheduled-triage.yml -215
.github/workflows/gemini-triage.yml -159
.github/workflows/gemini-plan-execute.yml -127
.github/workflows/gemini-invoke.yml -120
.github/workflows/gemini-review.yml -110
.github/commands/gemini-review.toml -172
.github/commands/gemini-scheduled-triage.toml -116
.github/commands/gemini-plan-execute.toml -103
.github/commands/gemini-invoke.toml -97
.github/commands/gemini-triage.toml -54Six workflows and five slash-commands: dispatch, invoke, review, triage, scheduled triage, and plan-and-execute. That is an agentic CI setup with a cron job that triaged issues on its own. What replaced it is ninety-two lines of code review and action workflows.
Fourteen hundred lines out, ninety-two in. Either the new setup is dramatically leaner for the same result, or capability was dropped in the switch — scheduled triage and plan-execute have no obvious counterpart in what went in. I cannot tell which from the repository, and I did not write it down at the time.
The same day I published an article titled 3cat c means claude:
I have decided to make a significant shift in how my team at 3cat
operates. As of now, we are moving our entire workflow, from business
operations to full stack development, over to Claude.Eight days earlier I had published one called 3cat x dansday, whose summary line says I was added to an account with a two hundred thousand dollar monthly usage limit across any Claude model.
I want to record the mechanics without editorialising much, because I am aware this article is being written by the tool in question and that makes me a poor judge of it. What the repository shows is factual: an AI code-review workflow now runs in this company's build pipeline, a competing tool's integration was deleted rather than kept alongside, and the deletion was five times larger than the addition that replaced it.
The one observation I will make is the one I would make about any dependency. In July 2024 I wrote about how vendoring a framework into your repository means every one of its defaults becomes yours, including the ones you never read — and in December 2025 that cost me an installer sitting open on a live shop for two years. Moving a team's entire workflow onto a single vendor's model is the same category of decision at a larger scale, and the fourteen hundred deleted lines are what leaving the previous one looked like.
A store closed
On 21 May, two additions and twenty-five deletions across five files: Remove KLIA 2 Stores.
In the September 2024 article I wrote about store openings arriving as weekly tickets, and about the config file growing from four stores in Kuala Lumpur to fifteen across five states and an airport. The airport was gateway@KLIA2, and I remember adding it because its Google place identifier and its postcode were part of the batch where I discovered that three Kedah postcodes had been silently octal since the file was written.
Twenty months later the entry comes out. Twenty-seven lines of diff for a shop closing, and the code does not distinguish that from a shop that was entered by mistake. There is no closed-down state, no archive, no note. The store simply stops existing in the config file, which means the site is correct and the history is gone.
I do not think that is worth building a feature for. I do think it is the smallest possible illustration of something these articles keep circling: a config file describes the present, and every fact about the past has to live somewhere else, which in this company's case is a series of blog posts I write at the end of each month.
iOS 26 and a rounded corner
Two pull requests on 6 May, one line each. Fixing Video thumbnail corner on ios 26, then Enhance video thumbnail frame styling with overflow and rounded corners.
The virtual inspection feature from March puts a video screen on the product page with a frame around it. A new version of iOS renders that frame's corners differently, so on the newest iPhones the corner was wrong, on a shop whose entire inventory is iPhones.
The rest of the month was small and worth listing for completeness: hiding the display stock on out-of-stock devices as a hotfix, a bulk price edit field overlapping its currency placeholder, a desktop variant of the exit-intent popup, making the mobile product page trigger the variant selector, a 443-line clean-up of the product page frame and video thumbnail, three hundred lines updating the catalogue on the warranty portal's domain, and a logic change to the prefilled chat message on the product page.
What May was
Twenty-five pull requests, ninety-one contributions, and two personal projects that stopped for a month.
The flash sale now has its own cache policy instead of fighting the page's. Refunds got proper attention twenty-seven months after the first payment. The catalogue can be edited in bulk. A competing AI integration was deleted in favour of a single one. An airport store stopped existing.
And the number at the top is ninety-one, after two months of four hundred and then eight hundred. I could have written this article as a story about a quiet, well-paced month of solid feature work, which is what the pull request list looks like in isolation. That version would have been true and would have left out the reason, which I published myself on 14 May and which is that I hit a wall hard enough to write about it.
The thing I would tell someone is that the two months I am proudest of in this entire series produced my worst bug — a guard reading a column that never held the value, blocking fully paid orders from being fulfilled, live for two weeks across two failed attempts to fix it. And the month I have the least to show for is the one where nothing broke.