Dansday

The Month I Wrote the README

Published on Aug 31, 2025

Seventeen pull requests at 3cat in August, all merged — the third consecutive month with nothing abandoned. Most of it was payments getting more flexible, and one pull request was 268 lines of documentation, which is the first of its kind in twenty-one months. Underneath that there was a product page rebuilt around a slide-up, two more repairs to the exit-intent popup, and a cache bug that had been quietly halving the effectiveness of a system I built last November.

On 25 August I also created a repository of my own, which matters more in hindsight than it did at the time.

Twenty-one months of tribal knowledge

On 26 August I merged a rewrite of the project README. One file, 268 additions.

It opens where any new developer actually starts, which is not with the code:

1. Login to AWS ECR (required before starting):
   make docker-login

2. Start the development environment:
   make up

3. Access the PHP container and install dependencies:
   docker exec -ti 3cat-php bash
   composer install
   npm install

Then the environments — development, staging, production, each with its frontend and admin URL. Then the frontend build commands as a table. Then the sprite icon workflow, which has an order you have to follow and no way to discover: build the sprites, then build the assets, in that sequence, because the second reads what the first produces.

Then deployment, and this is the part I would show somebody. QA environments are built by SemaphoreCI on branch push and deployed with make deploy-qa, each landing at its own URL in the form <branch>-qa.3cat.work. Staging and production go out by version tag, with a stated convention — patch bump for small changes, minor bump for major ones. And production runs two EC2 instances, one on the current version and one on the previous, which is a rollback plan expressed as infrastructure rather than as a procedure somebody has to remember.

None of that was new in August. All of it had existed for a year or more, distributed across people's memories, Lark messages and whatever I happened to have written in a pull request. Writing it down created nothing. It just moved it somewhere a new person could find it.

The scratch branch became documentation

My favourite thing in that file is one link. Under the heading for setting up a development database:

Download MySQL dump files from: GitHub Pull Request #902

In the October 2024 article I wrote about pull request #902 — a branch called Do not merge - Akbar | MySQL Production Dump, opened with no changes in it, still open, with a title that had since been edited to reference a date in 2026. I described it as scratch space I had been quietly reusing for nearly two years, and said every repository has one of these and in this one it had my name on it.

Ten months later it is the canonical, documented source of the development database for anybody joining the project.

I do not think that is a happy ending exactly. A pull request is a terrible place to store a database dump — there is no versioning, no expiry, and it exists because somebody with commit access decided it was easier than asking for an S3 bucket. But it was the honest answer to where do I get a database, and writing the README meant writing down the honest answer rather than the answer I would prefer.

The same file documents npm run build-css in its command table. That script exists because in September 2024 I deleted fourteen lines from this same README — eleven separate Tailwind invocations that a human was expected to type in order — and replaced them with one npm script. I wrote then that documentation describing a manual process is a bug report with no assignee. Eleven months on, the fixed version is a single row in a table.

What the runbook admits

Two things in that README are worth flagging rather than celebrating.

The deployment section says to request private key from senior/team member for SSH access, which is an accurate description of how it works and also a description of a bottleneck. Production access is a person, not a process.

And the development environment's admin credentials are in the file in plaintext. It is a development environment, the password is not reused anywhere that matters, and I am not going to reproduce it here. But it is a credential committed to a repository, which is the kind of thing that is fine until the day the repository's audience changes. If I were revisiting that file I would replace it with a line telling you where to ask.

The customer chooses the split

June's split payments — one order paid by two separate transactions using two methods — spent August growing up. Five pull requests.

The largest lets the customer decide the division: 385 additions, 220 deletions. Previously the split was fixed; now somebody buying a device can say how much goes on the first payment and how much on the second. Which sounds like a slider and is really a validation problem, because both halves have to remain payable by the methods available to them.

That produced the constraint in the pull request I liked most, at 34 additions and no deletions: auto select online bank when payment 2 not reach threshold. Credit card instalments have a minimum amount — banks will not spread RM 200 over twelve months. So if the customer's chosen split leaves the second payment below that floor, the interface stops offering instalments for it and selects online banking instead. The customer never sees an option that would have been rejected.

Two smaller pull requests fixed arithmetic: one in the payment gateway calculation, one where the frontend showed the wrong amount on the first toggle of the split. Both are eleven lines or fewer. Money features are like this — the feature is a week and the last five percent is a fortnight of numbers not agreeing with each other in one specific sequence of clicks.

Instalments moved to the product page

The other payments thread was moving instalment options out of the checkout and onto the product page: 504 additions, 339 deletions, twenty-one files, with a production fix the day after.

In February I split credit card instalments and loan options apart at checkout, because they had been sitting in one undifferentiated list. In August the same information moved earlier in the journey, because a customer deciding whether they can afford a MacBook wants to know the monthly figure while they are looking at the MacBook, not after they have committed to buying it. A bank was added to the self-serve list the same month.

Both of those changes are the same insight arriving in two places: the answer to can I afford this should be visible at the moment the question occurs.

June's setting starts saying no

In June I built the shipping SLA setting — per product, operations declares Ready Stock Only, Readily Replenished, or Allow Pre-Order. In August it began removing options:

- @if (!$isPreOrderable)
+ @if (!$isPreOrderable && !$isReplenished)

Free reservation disappears for anything not in stock right now. Which is correct and slightly sad: a free reservation is a promise to hold a specific device for you, and you cannot hold a phone you have not bought yet. The paid and fully-paid reservation paths stay, because money changing hands makes it a real commitment on both sides.

Six additions and four deletions to express that. The interesting part is that it was possible at all — two months earlier the system had no vocabulary for we can get this versus we have this, so there was no way to make the checkout behave differently for them.

The nearest store, this time on purpose

One more callback. In May I shipped a livestream header that ran a geolocation lookup on every page view, needed only to break ties between simultaneous streams, and had to revert the whole feature partly because of it.

In August: Auto Select Nearest Store at Checkout. For reservations, detect the customer's location, pre-select their state in the dropdown and pre-select the closest shop. 197 additions, including 85 new lines in the stores class.

Same capability, opposite judgement. It runs on the checkout page, for customers who have already chosen to collect in person, to save them scrolling a list of shops across eight states. The lookup happens where the answer is used, once, for a customer who benefits from it — rather than on every page for a case that almost never occurs.

Two commits on 25 August

The other thing I did in August barely registers as work: two commits, on the day I created the repository, to a Discord bot.

2025-08-25  GO BLOX v1.2.0
2025-08-25  Update GaG source channel

A version bump and a channel configuration change, carried over from a Roblox community project I had been running. Nothing in those two commits suggests anything. Two months later it had a web panel and a version 3.0.0 release; three months later it took a hundred and twenty-one commits in a month and had a levelling system, moderation, and its own security pass.

I am recording it because a recap of a month is a poor instrument for spotting beginnings. The 268-line README above is the interesting thing about August by any measure you could apply at the time. The two-commit repository is the thing that turned into a second job.

The variant selector became a slide-up

The largest non-payment change of the month, and one I left out of this article when I first wrote it: 414 additions, 80 deletions, seven files, of which 282 additions land in a single Blade template for the product page.

The ticket's first line is the whole argument: We're reducing the scroll distance to checkout by optimising the variant selector tool. On mobile the inline variant picker was removed entirely and replaced with a summary — how many storage, colour, connectivity and case options exist, or, if a promotion applies, which variants it applies to. Tapping that summary brings up a slide-up panel with the real selector in it, and you can check out from the panel without ever returning to the page.

What makes it a 282-line diff rather than a 40-line one is the promotion logic threaded through it. If a promotion applies, the promotional badge has to appear on the image frame, again in the variant summary, again as an overlay on each qualifying variant inside the slide-up, and the panel border has to take the promotion's colour. The same three facts — is there a promotion, which variants qualify, what does it say — get asked and re-rendered in four places in one screen.

I would flag that in review now, and it is the same shape as the thing I flagged in September about the post-voucher price being computed in four places. A promotion's appearance is a derived value with no single owner, and the reason this template keeps growing is that every new surface asks the question again rather than being handed the answer.

Invalidating one of two URLs

Sixty-eight additions, five deletions, seven files, titled Cache Clear Rules for CMS Pages. It is the smallest important pull request of the month and I did not mention it at all.

The visible half is what the ticket asked for. CMS pages had to be cache-cleared by hand after an edit, so the admin page controller now dispatches events before and after an update, the path resolver learned to build a page path, and CMS pages joined the existing throttled invalidation queue — the ten-minute debounce I had built in May after the invalidation job started firing too often.

The other half is a bug fix nobody asked for, and it is the interesting one:

foreach ($uniquePaths as $path) {
-    $this->scheduleInvalidation($path, $now);
+    foreach ($this->getBothSlashVariants($path) as $variant) {
+        $this->scheduleInvalidation($variant, $now);
+    }
}

Every invalidation pattern in the config carries a trailing slash — /{url_key}/. CloudFront treats /iphone-13 and /iphone-13/ as two different cached objects. So for nine months, from the day I built this system in November 2024, every invalidation had been clearing one of the two forms and leaving the other one stale. The fix generates both variants for every path, skipping wildcards and the full-invalidation path, which cannot be varied.

This belongs in the middle of a thread I have told badly. November 2024: I built cache invalidation and wrote about 863 lines lost to 137. May 2025: it fired too often, so it got rate-limited and debounced. September 2025: I gave up on it in favour of a ten-minute nginx TTL and opened a pull request deleting 1,151 lines. August is the missing link, and it is the one that makes the September decision make sense. The reason I stopped defending this system is not that it was slow or expensive. It is that in August I discovered it had been half-wrong the entire time, in a way no dashboard would ever have shown me, and that a customer complaining about a stale price could have been right for either of two completely different reasons.

The popup, twice

Two pull requests, thirty-two additions and eight deletions between them, and both of them are the exit-intent popup I built in April.

On 4 August, a hotfix I raised myself: the popup was appearing on the split-payment page and on the order confirmation page. That is a please don't go interstitial firing at somebody in the middle of paying, and at somebody who has already paid. The fix was to give those two views a DOM id and bail out if either is present:

const orderConfirmElement = document.getElementById('order-confirm');
const splitPaymentElement = document.getElementById('split-payment');

if (orderConfirmElement || splitPaymentElement) {
    return;
}

Ten additions, two deletions, three files. It is the right emergency fix and the wrong permanent one: the popup does not know what page it is on, so it now has a list of pages it must not appear on, which means every future page is opted in by default.

The second, on 19 August, came from a ticket with a genuinely good observation in it: Users often open multiple 3cat tabs to compare devices. Currently, the Exit Intent Popup timer starts on page load even if the tab isn't in use and is sometimes shared across tabs. The timer had been running in background tabs and the session flag was shared, so a popup could fire in a tab you were not looking at, or be silently consumed by one. That work pauses the timer when the tab loses focus, resumes it on return, and renames the catch-all popup type from regular to other so its session key matches the one the page-type gate was already checking.

I did not write about either of these at the time, and the omission has consequences beyond this article. When I described the popup's history in December, and again in January 2026 while stripping it down to a single ninety-second timer, I counted an emergency disable on two page types in August, a behaviour fix the same month as part of the pattern. That pattern was assembled from months that had said nothing about it. The seven-pull-request history of that one file is only visible if every month admits its share, and August did not.

What August was

Seventeen pull requests, a README, and a two-commit repository that did not look like anything. A payment split the customer controls, instalments visible while you are still deciding, and a store pre-selected before you look for it.

The README is what I would point at, and not because writing documentation is virtuous. It is because of what compiling it revealed: a database that lives in a pull request, production access that depends on asking a specific person, a build step with an undiscoverable order, and a password in a text file. None of those were decisions anybody made. They accumulated, each one reasonable on the day, and the only reason I can list them now is that somebody had to write down how the project actually works rather than how it was supposed to.

The same is true of the three things above. A template that asks the same question four times, a popup with a growing list of places it must not appear, and an invalidation system that had been clearing half of what it claimed to. None of those were decided either. They are what accumulates when every change is individually reasonable and nobody is keeping the ledger.

Revised: this article originally covered seventeen pull requests but described only eleven of them, and omitted the variant selector rebuild, the trailing-slash invalidation bug and both popup repairs — roughly forty-seven per cent of the month's changed lines. The three sections above were added from the original diffs. I have left the rest of the piece as it was written.