Ten pull requests in March and every one of them merged, which had not happened in any month before. Twenty-nine contributions, the quietest month of the sixteen — though when I first wrote this article I described six of the ten and called it quiet, which is not the same thing.
It was also the month February paid for itself. On 25 February we switched a session recorder on for three days. On 17 March I opened a pull request titled Learnings from Hotjar: Quick Fixes.
Six things nobody would have reported
The ticket is a list, and the list is the whole argument for watching people use your software:
1) On the All Stores Page, when customers click on the store image,
bring up the reservation pop/slide-up
2) Remove 'New Store' label from all stores except Giant Shah Alam
3) Full T&C for CatCare should open in New Tab.
4) Tapping on the red zones opens up the 'great quality' slideout.
Tapping on the green zone opens up the 1 year warranty slideup
5) Card height should always follow the tallest card in row
6) When customer pre-fills info under 'delivery' option on checkout,
and then toggles to 'reservation', the first name, last name, email
should still be preservedNot one of those would arrive as a bug report. Nobody emails a shop to say I tapped the picture of your store and expected something to happen. They just tap it, nothing happens, and they leave — and the only trace is a recording of a thumb pressing an image four times.
Item four is the one I find most instructive. Customers were tapping the coloured quality badges on the product page as though they were buttons, because they look like buttons. The fix was not to make them look less tappable. It was to make them actually do the thing people already believed they did.
Item two is the store expansion thread coming home. A New Store label had been attached to outlets as they opened, and by March almost every store on the page was labelled new, which means none of them were. One store keeps the badge. Thirteen lines of config, and the label means something again.
The same name in two places
Item six needed real code, and the reason it was broken is worth recording.
The checkout has two sets of contact fields — one under the delivery option, one under pickup — and toggling between them hides one set and shows the other. Two sets of inputs, two sets of element ids, and no relationship between them. So a customer who typed their name under delivery, changed their mind, and switched to reservation was looking at three empty boxes with their details still sitting in the hidden ones.
copyFields(sourcePrefix, targetPrefix) {
const fieldTypes = ['first_name', 'last_name', 'email'];
fieldTypes.forEach(fieldType => {
const sourceField = document.getElementById(`${sourcePrefix}_${fieldType}`);
const targetField = document.getElementById(`${targetPrefix}_${fieldType}`);
if (sourceField && targetField) {
targetField.value = sourceField.value || '';
}
});
}Called on every toggle, in whichever direction. It works and it is the second-best fix available. The best one would have been a single set of fields that both modes share, because the customer's name does not depend on how they want the phone delivered. What exists instead is the same three facts in two places, kept in step by code that has to remember to run.
I chose the copy because the two sections had been built separately, months apart, by different tickets, and unifying them would have touched the delivery flow, the pickup flow, the full-payment reservation flow and every validation rule attached to them. That is the honest reason: not that it was better, but that the shop was open and the smaller change was the one I could be confident about.
16GB sorted last
On 5 March I filed and fixed my own hotfix: on some product pages the 16GB storage option appeared at the end of the list, after 512GB.
The variant sorting code was already handling the obvious trap. Storage values are strings like 128GB and 1TB, so it casts them to integers and scales terabytes:
- $aStorageInt = (int)$aStorage * (str_contains($aStorage, 'TB') ? 1000 : 1);
+ $aStorageInt = (int)$aStorage * (str_contains($aStorage, 'TB') ? 1024 : 1);But that only sorted the variants. The buttons a customer actually taps come from a different method, which built the list of storage options in whatever order the database returned them — which is the order somebody created them in the admin panel. 16GB was added later than the large sizes, presumably when older and cheaper devices came into stock, so it was created last and therefore displayed last.
if ($attribute['code'] === 'storage') {
uasort($attributeOptions, function ($a, $b) {
$aValue = (int) $a * (str_contains($a, 'TB') ? 1024 : 1);
$bValue = (int) $b * (str_contains($b, 'TB') ? 1024 : 1);
return $aValue <=> $bValue;
});
}The order of a list of options had been an accident of data entry for as long as the product page had existed, and it only became visible when a value arrived out of sequence. This is the same family as the postcode I stored as an integer in September — a value that looks like a number, is stored as a label, and is silently ordered by something other than its meaning.
The part I would fix if I went back is that the conversion now exists twice, in two files, character for character. The day somebody stocks a device with 1.5TB — which casts to 1 — it will be wrong in both places, and only one of them will get found.
A slow seller becomes a rare gem
The product page had been showing historical sales as social proof: how many of this device 3cat had sold. Which works beautifully until the number is small.
For items that don't have a lot of sales, we should display 'Rare Gem!'
instead of the historical sales. This signals to users that they've seen
a hard-to-find product.The implementation is a migration creating two new product attributes, so marketing can set them per device without a developer:
$attribute = Attribute::create([
'code' => 'custom_label',
'admin_name' => 'Custom Label',
'type' => 'text',
]);
$attribute2 = Attribute::create([
'code' => 'custom_icon',
'admin_name' => 'Custom Icon',
'type' => 'image',
]);Sixty-nine lines, mostly that migration, and it is genuinely good product thinking: a device nobody has bought is either unpopular or rare, and which of those is true is a judgement a person should make rather than a number.
It is also the third time I have written this same note down. In November I shipped a Low Stock badge that reads almost gone on products whose quantity is zero. In February I noticed the warranty was still valued at a flat ten percent because no real figure had ever arrived. Now a product with few sales says Rare Gem!. Each one is defensible on its own — the device may be findable in store, the warranty does have value, a low-volume phone genuinely is harder to find. Together they describe a page that has spent sixteen months getting steadily better at presenting absences as features.
I opened this series by writing about making a product page stop claiming a discount it did not have. I still think that was right. I also notice that every one of these later decisions went in without anybody objecting, including me.
There is a detail I left out that makes the point sharper. Rare Gem did not arrive alone. The day before it, a second pull request updated the low-stock section on the product page: it deleted triangle-danger-gray.png, added an image of Teega — the shop's chat persona — and added a file called gem.gif. Fourteen hand-written lines, plus a regenerated sprite manifest.
So in the same week, a warning triangle became a friendly face and a low sales figure became a compliment. Both changes are small, both are defensible, and both move in the same direction: the visual language for we do not have this got warmer at exactly the moment the copy for nobody bought this got flattering. I wrote three paragraphs about the label and did not mention that the icon changed too.
Exit intent
The largest thing I opened in March was an exit-intent popup — 1,204 additions, 825 deletions, forty files — which detects a customer about to leave and offers them something on the way out. It did not merge until 8 April, so it properly belongs to next month.
Alongside it, a smaller popup design and three pull requests about banner tap zones: an update, then a bug titled Unexpected step-through behaviour, then a fix. Making a banner divide itself into regions that each do something different is one of those problems that looks solved until somebody taps near a boundary.
Text written for Google, hidden from people
The largest thing that merged in March, and absent from this article as I first wrote it: 316 additions, 184 deletions, twenty-one files. Deflate it for the generated sprite manifest — 79 lines of it — and it is still the month's biggest merged change.
The ticket is titled Whitespace Cleanup: PDP + Collapse Mobile FAQ Answers + Fade Longform Text on PDPs, and five of its six items are spacing. The sixth is not:
2. For mobile, truncated to the long text to 6 lines, faded the text
and add a "Read More" button.
The bulk of that landed in seo-content-text.blade.php, 57 additions to one file, plus 53 lines of CSS and a change to seo.css. The name of the file is the honest part. That long-form text exists for search engines. It is on the product page because a page with substantial body copy ranks better than a page without one, and this pull request truncates it to six lines on mobile, fades the seventh into the background and puts a button over it.
I wrote the first article in this series about deleting half a megabyte from the homepage so Google would rank it faster. Fifteen months later I am collapsing text that exists for Google so that a customer can get past it. Both changes are correct. Together they describe a page serving two readers with opposite preferences, and the resolution was not to reconcile them — it was to show one reader everything and the other reader six lines.
The same pull request removed the numbering from FAQ answers and made them collapse on mobile only, and set explicit spacing for product pages that have no long-form text at all, which is its own small admission: some products have the SEO copy and some do not, and the layout had been assuming they all did.
An invisible div was eating the clicks
Four additions, four deletions, and the best bug of the month. The ticket said Desktop cards are sometime non-responsive, which is the kind of report that sounds like a browser problem and is not.
The product carousels have previous and next arrows. Those arrows live inside a container that is absolutely positioned across the full width of the carousel, so that one arrow can sit off the left edge and one off the right. The container is invisible. It was also on top of the cards.
-<div class="carousel-arrows ... absolute temp-hide requires-resource w-full">
+<div class="pointer-events-none carousel-arrows ... absolute temp-hide w-full">
- <i class="sprite-carousel-arrow-left ... cursor-pointer">
+ <i class="sprite-carousel-arrow-left ... cursor-pointer pointer-events-auto">
pointer-events-none on the wrapper, pointer-events-auto on the two arrows. Clicks now fall through the empty space between them and reach the product underneath.
"Sometime non-responsive" was exactly accurate: the cards under the arrows were dead and the rest worked, so whether the page responded depended on where in the card you happened to click. It is a two-word CSS fix and it had presumably been costing clicks on every carousel on the site for as long as the arrows had existed. Nobody could have filed a better report than the one that was filed, and no stack trace would ever have contained it.
Sharper logos and a footnote nobody met
The month's second-largest merged pull request, also missing from this article: 232 additions, 238 deletions, mostly replaced image assets. Personal-loan provider logos were added and re-exported because current production is quite blurry, the subtext was rewritten to name who the loans are for — students, salaried employees, or the self-employed — and the responsive rule is specified down to the pixel: two lines of subtext between 360 and 413 pixels wide, one line at 414 and above.
The first item on that ticket is the one worth keeping. It restyles the footnote shown when a customer has not met the minimum spend for a payment method — new colour, new icon, new padding. That footnote is the visible end of a rule I would not properly deal with until August, when the checkout learned to stop offering instalments below the bank's floor and quietly select online banking instead. In March the response to this option is not available to you was to make the explanation prettier. Five months later it was to not show the option.
What March was
Ten pull requests, all merged. Six fixes drawn from watching real customers, a storage list that finally sorts by capacity instead of by data entry, and a name that survives changing your mind about delivery.
Ten pull requests, and the four I originally skipped were the largest one, the second-largest one, the best bug, and the icon change that undercut my own argument about honesty. The through-line is that almost nothing this month came from a bug report. It came from three days of recordings in February, from a colleague noticing 16GB in the wrong place, from someone looking at a product with two sales and asking whether that number was helping. A year earlier I was finding problems by reading stack traces and PageSpeed scores. This month every problem was found by someone looking at the thing a customer sees, which is slower, harder to schedule, and the only way any of these would ever have surfaced.
Revised: this article originally described six of March's ten pull requests and called the month quiet. It omitted the long-form-text collapse, the payment-method asset work, the carousel click bug and the low-stock icon change — including the two largest merged changes of the month. The sections above were added from the original diffs.