Postmortems

The panel that was empty for all 48 teams

Every team page in my World Cup tracker had a record panel that showed nothing, and nothing complained. The bug was a contract mismatch: the frontend read a field the backend never populated, and a missing field is invisible to almost every guard you have.

Every national team in my World Cup tracker has a page, and every one of those pages had a panel titled Tournament record that showed nothing. Not an error. Not a zero. Just an empty box where a team's played, won, drawn, lost and points belonged. Forty-eight teams, forty-eight blank panels, and the app was not complaining about any of it.

Empty but not broken is the worst failure mode to debug, because nothing points at the problem. The page rendered. The panel rendered. The panel simply had no data to put in itself, and it had been told, in effect, that having no data was normal.

Two endpoints, one of them lying by omission

The data has two ways into the app. There is a teams-list endpoint that builds every team's row for the index, and there is a per-team detail endpoint behind each team's own page. I had assumed both carried the same record. Only one did.

The list endpoint builds its record by joining against the standings. It takes each team and pulls played, won, drawn, lost, goals and points out of the group tables, through a helper that assembles that shape deliberately. The detail endpoint went straight to the sport feed's own per-team resource and trusted it to include a record. It does not. That resource returns the squad, the crest and the fixtures, and no record block at all. Not sometimes. Never, for any team.

So the frontend asked the detail page's data for team.record, the detail data had never contained team.record, and the panel dutifully rendered the nothing it was handed. The contract the frontend believed in and the shape the backend actually produced disagreed, and because the disagreement was a missing field rather than a wrong value, nobody threw.

Why nothing caught it

A missing optional field is invisible to almost every guard you have. The types were happy, because the record was optional and undefined is a legal value for an optional field. The renderer was happy, because it was written to show a panel and, if the record was absent, show an empty one, which is the correct and honest behaviour for a team that genuinely has no record yet. Every layer did exactly what it was told. The bug was that the layers had been told slightly different stories about where the data lived.

This was the second time in this project that a backend shape and a frontend expectation drifted apart quietly. Both times the symptom was identical: a section that was present, correct, and empty, on real data, with no error anywhere.

The fix, and the tell

The fix was to make the detail endpoint fill the gap the same way the list endpoint already did. When the per-team feed carries no record, fall back to the standings-derived record, built with the exact same helper so the shape is identical whichever path produced it. Now a team page gets its record from wherever the record actually exists, and Algeria's page reads P3 W1 D1 L1 GF5 GA7 Pts4, its real numbers, checked against the group table.

The tell I took from this is to distrust a panel that is empty on data that should not be empty. An error is loud and gets fixed the same day. A blank that looks intentional gets shipped, because it reads as "this team has no record yet" rather than "this field was never wired up". When two sides of a contract can each be individually correct and still disagree, the disagreement hides in the place that looks fine: a real page, a real component, and a quiet, permanent nothing where the data was supposed to be.

← All devlog stories