Dansday

The Month a Postcode Became 2624

Published on Sep 30, 2024

Fourteen pull requests in September, ten of them merged. My busiest month since March, and almost none of it was a feature.

December's article opened by describing 3cat as a company with four stores in Kuala Lumpur. By the end of September the config file held fifteen, across five states and an airport. Most of what I did this month was the code catching up with a business that had got bigger than its own assumptions.

Store openings, week 36 and week 37

Two of my pull requests are titled Store openings and numbered by week. That is the cadence the shop was expanding at, and the site had to keep up: a new outlet needs a name, an address, a photograph, coordinates, a Google place identifier and a position in the ordering.

Once there were stores in more than one state, the flat list stopped making sense. So the store section learned about geography:

'stores' => [
    'store_max_show'  => 3,
+   'state_max_show'  => 4,

And a per-store show_on_all_stores flag came out of three entries, because grouping by state made it redundant — the question was never should this store appear, it was which state's list does it belong to. A flag that answers the wrong question is worse than no flag.

One of those diffs quietly corrects a Google place identifier for the Aman Central store from ChIJpRemiblESzARdkytmmL-ofQ to ChIJqdwOVgBFSzARvC1yJElYgCw. Somebody had pasted the wrong one, which means the map link and the reviews on that store's card had been pointing at a different building. Nothing errors. The page renders perfectly. It is just wrong, and the only way to catch it is for a human to click it.

A postcode became 2624

On 25 September I filed my own bug, which is a different feeling from being handed one. I had been looking at a real order in production — order 432953 — and the pickup store's postcode was not a postcode:

Found minor issues in prod with pickup store that's have postcode start
with 0, php threaten it as octal number and convert to decimal,
supposed 05100 but it become 2624

The store list is a PHP config file, and the postcodes were written as bare numbers:

- 'postcode' => 05100,
+ 'postcode' => '05100',

A leading zero on an integer literal in PHP does not mean a zero. It means octal. So 05100 is not five thousand one hundred; it is 5×512 + 1×64, which is 2624. The language did exactly what it has documented since before I was writing code, and I linked the PHP 8.1 explicit-octal notes in the ticket because I wanted the next person to see the reasoning rather than just the fix.

What makes this my favourite bug of the year is the shape of it. Fifteen stores in that file, and exactly three were broken: 05400, 05100 and 05000, which became 2816, 2624 and 2560. Every one of them is in Kedah. Kuala Lumpur postcodes start with 5, Johor with 8, Penang with 1 — none of them trigger it. The bug had been latent in the file format since the day it was written and it could only ever fire when the company expanded into a state whose postcodes begin with zero.

You cannot test your way to that. There is no unit test for we will one day open a shop further north. The general lesson is the one I keep relearning in this codebase: an identifier that happens to be made of digits is not a number. A postcode is a string, an order reference is a string, a phone number is a string. The moment you store one as an integer you have signed up for arithmetic on a thing that should never be arithmetic. I quoted the coordinates in the same pass for the same reason.

Three attempts at one merge

On 5 September I opened three pull requests within minutes of each other, called 777+738, 777 and 738, and 777 merge 738. Sixty additions, thirty-three deletions, ten files. I have diffed all three and they are byte-identical. All three were closed.

Two branches had drifted apart — store names being reorganised into states on one, a week's store openings on the other — and both touched the same config file. Three pull requests is three attempts at reconciling them, and the branch names get progressively more resigned.

In the end neither approach was merged. The two branches went in separately, and the actual conclusion of that thread is a pull request titled 777: Add period: one addition, one deletion, one file, a production fix for a missing full stop. Half a day of merge archaeology and the thing that shipped was a punctuation mark.

The back button, again

August was the month I found that the checkout page wrote to the database when you looked at it, and the trigger was a browser back button. September brought the same button back with a smaller complaint: return to checkout and the shipping option you had picked was not the one selected.

The old code asked the DOM what was checked:

- let shipingMethod = this.shippingMethodTargets.find(target => target.checked)?.value;
- this.changeShippingMethod({target: {value: shipingMethod}});

Which works on a fresh page load and not on a restored one. Browsers cache and restore pages on back navigation with their own rules about what state survives, and the server-rendered checked attribute reflects the default, not what the customer chose two minutes ago. So the choice had to be recorded somewhere that belongs to the browsing session rather than the document:

+ sessionStorage.setItem('selectedShippingMethod', event.target.value);
+ const selectedShippingMethod = sessionStorage.getItem('selectedShippingMethod');
+ const shippingMethodInput = selectedShippingMethod
+     ? document.querySelector(`input[name="delivery-option"][value="${selectedShippingMethod}"]`)
+     : document.querySelector('input[name="delivery-option"][checked]');
+
+ if (shippingMethodInput) {
+     shippingMethodInput.checked = true;
+     this.changeShippingMethod({ target: shippingMethodInput });
+ }

Read the remembered choice, fall back to the markup's default, and guard the element before touching it — which is the lesson from August's preview button applied without being reminded.

Sixteen additions and thirty deletions. The fix is smaller than the code it replaced, which is usually a sign you have found the right one.

Eleven commands became one

The same pull request contains a change with nothing to do with shipping options, and I want to point at it because it is the kind of thing nobody tickets.

The project built its stylesheets one page at a time, and the way you knew which ones to build was a list of eleven commands in the README. Every deploy, somebody read that list and typed them. Miss one and a page ships with stale CSS, which is exactly the class of bug that gets blamed on caching for two days.

Fourteen lines came out of the README and one line went into package.json:

"build-css": "npx tailwindcss -i ./resources/styles/atf.css -o ./public/css/critical.css --minify && ..."

It is an ugly line. It chains eleven invocations with && and it will keep getting longer. It is also the difference between a build step and a ritual, and I would take the ugly line every time. Documentation that describes a manual process is a bug report with no assignee.

Reserving a device

The month's one real feature was a confirmation step for in-store pickup: choose to reserve, and a panel slides up summarising what you are committing to before you commit to it. 232 additions across twelve files, mostly a new summary component, sixty-six lines of checkout styling and a chunk of the checkout page.

March's throwaway STATUS_RESERVED experiment, April's delivery-versus-pickup panels, and now a reservation flow with its own confirmation. None of those three months planned the next one. Looking back across them it reads like a roadmap, and it was actually a business finding out what its customers would agree to, one ticket at a time.

Every round deleted words

The other checkout work this month was copy, and the trajectory is the interesting part. In early September the pickup option said:

- 'Your device will be reserved for self pick-up until :date'
+ 'Pay when you pick-up by <span class="font-bold">:date</span>.'

Three weeks later it said less again:

- 'Pay when you pick-up by <span class="font-bold">:date</span>.'
+ 'Pick-up by <span class="font-bold">:date</span>.'

And delivery went from Estimated delivery time 3-5 business days to Estimated delivery 3-5 business days to Delivery in 3-5 business days. Nine words down to three, over three pull requests, by three separate decisions. Nobody set out to shorten it; each round somebody looked at the screen and removed the word that was doing the least.

Alongside it went a promotional line — 'label_bottom' => 'Limited-time: ZERO Deposit!' — which is the reservation deposit temporarily set to nothing, and the reason the confirmation panel needed building in the first place.

The last pull request of the month is pure pixels: padding from pt-4 to pt-7, a pill's height from 23 to 18, text sizes stepped down. Ninety additions, ninety-two deletions, and not one behavioural change. That is what checkout optimisation actually looks like after the architecture is right — somebody in front of the real page, moving things two pixels.

What September was

Fourteen pull requests, ten merged. Eleven new stores' worth of config, a postcode that had been secretly octal, a shipping choice that survives the back button, and a stylesheet build that no longer depends on somebody remembering a list.

The postcode is the one I would tell. Not because it was hard — the fix is two quote marks — but because it is the clearest example I have of a bug that was written in December and could not possibly fire until the company grew in a particular direction nine months later. I keep finding those. They are the real cost of assumptions you did not notice you were making, and you only ever find them by looking at production with the specific suspicion that something quiet is wrong.