When progress stops lying: redesigning statistics in Puzzles and Cards — Development · Blupoli

The day we opened the panel and saw an empty pentagon

The old Puzzles statistics page had a radar chart. It looked good: five axes, five cognitive areas, a polygon that grew with practice. Its looks were its lie. If a player had not touched four of the five areas, the radar drew itself anyway, three axes pinned to the center and two short axes at a third of the way out. The closed shape looks like information; it is residual geometry. The radar never lied on purpose; it lied by default, the way any chart lies when it draws a polygon over a variable whose value is zero.

That problem, the visual that looks like measurement without doing any measuring, was the tip of an iceberg. Below it sat three more. Cards statistics depended on a local counter that vanished whenever the browser cache was cleared; Puzzles lived with two copies of the same history, one in localStorage and one among Supabase events, reconciled by hand; both products exposed a "Synchronize" button that most players forgot about half the time. The result was an ecosystem where progress could be real or could fail to be real, with no way to tell which without opening the browser console.

By late September we had completed two earlier moves that prepared the ground. The switch to a certified Numberlink generator had proved that measuring before migrating beats migrating and then checking; the consolidation of Puzzles and Cards into a single monorepo made it possible to touch both statistics in an afternoon. The thing we lacked was the trigger: deciding that measuring was the priority.

The decision: three layers that should not have been tangled

When we finally sat down to plan, we opened a sheet with three columns. The first was data: where truth lives, who writes it, how it reconciles, what happens when two tabs write at once. The second was visualisation: how games are counted, how those counts are drawn, what should never be drawn at all. The third was narrative: which slice of progress gets shown on which surface, and why. Until then, the three layers had lived tangled inside a handful of files, and that tangle was exactly what produced the empty pentagons and the buttons that nobody pressed.

Untangling them was not architectural vanity. It was the precondition for letting each layer be audited. If the data layer knows what it must know — idempotent events, a durable outbox, snapshots kept off the device — it can answer "did this player win 23 games and abandon 4?" without consulting the UI. If the visualisation layer only knows what the data layer feeds it, it can give up drawing cute charts over zero values. And if the narrative layer decides what to teach on each surface, it can do so without breaking the other two whenever it reorders content.

That was the criterion we used to set the acceptance bar for each pull request. It is why PR #465 closes the first column, PR #464 works on the second and the third, PR #467 smooths the coupling between all three, and PR #474 crowns the visual redesign. PR #470 and PR #485 close the loose ends the others left. Each PR answers one concrete question and leaves the next one ready for review without stacking six months of diff.

Layer one: making Supabase the truth and the outbox durable

The first move, PR #465, was almost invisible from the outside. It replaced the "Synchronize" button with automatic saving, made Supabase the single source of truth for history, and asked the UI to subscribe to changes rather than reload the page. It sounds clean, but getting it clean meant integrating five partial PRs — #439, #451, #461, #465 and the automation itself — and tackling two related issues, #45 and #346. The reason it took so many round trips is that on every step we uncovered an assumption that no longer held.

The most expensive assumption was that the player would decide when to synchronise. The evidence arrived quickly: half of Puzzles and Cards abandons never reached the cloud, because nobody presses a button after losing. The new rule is that every completed game is sent to Supabase as soon as the result is recorded locally in a safe way; aggregate progress is written with a short debounce and retried on connectivity changes, visibility changes and reopening the tab. The "Saved to Supabase" indicator only appears after remote confirmation, which avoids the false reassurance of a "saved" message that is then lost.

Automatic saving solves only half the problem. The other half is the reverse path: when a player opens Puzzles or Cards in a fresh tab, the client has to know which games they won three months ago, without leaning on a local snapshot that may no longer exist or on localStorage that may have been wiped. The answer was a direct query against the game_events table, scoped to the authenticated user with row-level security and no historical copies in IndexedDB. The pending event queue uses sessionStorage as an ephemeral projection and is bound to the account identifier, so a user switch cannot leak pending events to someone else's profile. As soon as that first remote sync is confirmed, the legacy local caches are deleted. There is no longer a double ledger, which is exactly what we had asked for.

The last detail PR #465 looked after was the snapshot story. Before, half-finished games were mirrored locally and in the cloud, with all the grief that creates when a device changes. The new rule is that an unfinished board only lives on the device: if the player closes the browser, they continue from where they left off, without waiting for a sync. When the game ends, the local snapshot is deleted and the remote result becomes the only record. Eighteen legacy snapshots sitting unread in Supabase were left in place for safety rather than deleted, marked for a future housekeeping pass, so that we would not break games a player might still have open in another tab.

The surprise: reloading the page was worse than the button

Once Supabase became the authority, a problem we had not anticipated showed up. PR #465 made the client aware when fresh data arrived, but the legacy way of reflecting that was location.reload(). The consequence: every time the statistics panel finished hydrating, the browser reloaded the page, flickered, and dumped the player back to the home view. The sensation was mild in Puzzles; in Cards, where the tab usually shows the daily solitaire, it was devastating, like a reset every thirty seconds.

PR #467 and its closing PR #468 replaced the reload with an event: when Supabase confirms that the aggregate data changed, consumers update their projections in the same document, without touching engines or schema, and without interrupting navigation. Behind the change sits a shared synchronisation module that emits typed events, plus views that know how to subscribe to the slice they care about. The technique is nothing exotic — it is standard eventing — but the effect is the part that matters: the player watches their panel update in silence, while the solitaire they had open stays exactly where it was.

To verify that hydration no longer reloads, we wrote browser regressions that hydrate the page, assert that the URL has not changed, assert that the gameplay state is still what it was, and only then compare the displayed numbers against the source. If the URL or the focus moves, the test fails. It is the sort of regression that never appears in a feature rubric but one a player feels every time the web feels stable.

Layer two: resuming only what did not end

The next move was more surgical. PR #470 fixed an inherited behavior: if a player had three saved games and had finished two, the app tried to resume all three. In Puzzles tapping "Continue" was usually enough, but in Cards opening a menu just to discard what you did not want to play was a daily annoyance.

The new rule is simple and lives in the engine layer: a game is offered for resumption only if the saved result is unfinished. Finished games are archived and disappear from the resume menu, although they still count for statistics and for the remote history. The change is invisible when it works, but it is exactly the kind of friction a player never mentions and that separates an app that cares about details from one that does not.

The interesting part is that the resume logic now lives in a module shared between Puzzles and Cards, called from the engine whenever a new game starts. That lets both products inherit the rule without rewriting it, and leaves the door open for future variations (for instance, resuming with a different move count or asking for confirmation) without touching each game one by one.

Layer three: the Cards home at last shows what you play

Once the data could be shown without lying, the time came to show it. PR #464 redesigned the Cards home around the same visual language already approved for Puzzles. Before, the home showed five featured solitaires in a regular grid; now it shows a clean composition with an operational hero, the daily solitaire with the seven real days of the week, four main featured titles and a side panel listing benefits.

The side panel is the new piece. It is called weekly wellbeing and replaces the old "streaks" promise with something more concrete and less punitive. Wellbeing is fulfilled when any of three conditions holds: four days with at least one game won in the local week, seven games in total, or sixty active minutes. The progress bar updates on every remote-confirmed result and never inflates: if today you have not played, the weekly percentage does not move. That is not innovation; it is the "no lying" promise applied to the product narrative.

Behind the panel sits a new Cards domain that counts active games from remote events rather than local state. That means wellbeing stays synchronised across devices without double-counting, and switching accounts does not accidentally accumulate crossed achievements. The home also reorders the thirteen-solitaire catalogue so the most-played games appear first without sacrificing search or filters. The redesign was validated visually by E2E tests capturing at 320, 390, 768 and 1440 pixels, with no mobile overflow and the header working in both light and dark themes.

The visual redesign: ten categories, sparse bars and one SVG ring

PR #474, which closed the implementation and whose plan we described in the matching SDD, was the largest piece. It changed the entire statistics composition in Puzzles across all three scopes — Global, Category and Game — without touching the data model, and rebuilt Cards around a deep solitaire selector with fourteen-day series. In Puzzles, the most visible change was dropping the radar.

In its place a real breakdown by category, with bars that hide themselves automatically when there is not enough activity to fill them. Categories with zero games are not drawn at a fictional minimum; they are listed at the end of the breakdown, with their label, in a stable order, so the player knows they exist without fooling themselves into thinking they have practiced them. That distinction — showing a short bar or showing nothing at all — is the same philosophy that dropped the pentagon, applied to a different chart.

Another important piece was introducing game icons and a clearer header hierarchy. Each KPI carries a small visual cue tied to its dominant game (a numeral for number puzzles, a loop for Slitherlink, a wall for Castle Wall), so the screen scans well without reading every number. The hero of the screen changes when a category or a game is selected: totals are recalculated for that scope, the header explains exactly what is included ("only games in this category"), and the breakdown is reordered. The explicit promise is that each filter represents exactly what it says it represents, without invented aggregations.

In Cards the logic is slightly different because the taxonomy is per game rather than per cognitive category. PR #474 introduced a deep solitaire selector with records, best time, daily streak, dated wins and an SVG ring for the win percentage, accessible via keyboard and screen reader. The prohibited conic gradient was discarded and replaced with a composition of flat arcs that include a textual percentage. Same idea: if the metric is real, the visual must be able to represent it with the canonical palette, not with an effect that hides the absence of data.

Layered diagram with remote sources at the top, the Supabase aggregator with durable outbox in the middle, document projections below and Puzzles, Cards and Home screens at the bottom, each with its own kind of visualisation
Three separated layers: data in Supabase, projections in the document, narrative on each surface. Mixing them produced the misleading visuals.

The ten cognitive categories, finally

PR #485 closed a thread PR #474 had left loose: the old radar displayed five categories, not ten. When Puzzles' cognitive taxonomy expanded to eight processes and two additional families, the radar fell behind, and the breakdown — already in bar form — needed to catch up. This PR fixed two things at once: first, that the ten categories were counted exactly once when a game belongs to several, without double attribution; second, that zero-play categories appeared at the end of the breakdown in a stable order rather than being inflated to a residual value.

Behind the change sits a model detail: attribution is still by primary category, not by intersection. If a game counts as both Logic and Numeric, it goes into its primary category and is documented as secondary. That choice avoids double counting, but it also avoids the opposite problem: a player who practices only one category could otherwise inflate all the others. It is a less flashy decision than removing the radar, but it is the more sustainable one in the long run.

The team's original plan put it in writing: "show ten axes, with a full legend, legible on mobile and accessible, without inferring cognitive skills or inventing activity." That last clause is worth highlighting. Self-evaluating cognitive libraries exist and promise to measure your intelligence from your games. Blupoli does not enter that territory. The panel counts how many games you played, how long they took and how many you abandoned, and leaves the cognitive interpretation to whoever wants to make one, with a tool that does not pretend to be what it is not.

Nonogram: the 5×5 size is leaving and 7×7 is taking its place

One decision that almost passed unnoticed but deserves attention: PR #478 replaced the 5×5 Nonogram size with a 7×7, and PR #481 closed the legacy generator. Why does it matter in a statistics devlog? Because it introduces a size change into a published game, which forces the statistics themselves to handle boards coexisting with older sessions on the same device. The rule we followed was the same one we applied when changing the Numberlink bank: games saved in 5×5 are kept for compatibility, but new boards are generated at 7×7, 10×10, 15×15 or 20×20 with difficulty independent of size.

The visible consequence is that the average Nonogram session becomes slightly longer and average times grow, without meaning the player has slowed down: there are more cells to reveal. The per-game statistics panel documents the new distribution and a tooltip clarifies that older games continue to count in their original format. It is one of those changes where the visual barely moves but the measurement policy moves completely.

The next day, PR #486 aligned the Spanish and English guides with the current sizes. A small inconsistency had been sitting there: the guides still described sizes as if they were 5×5, 10×10 and 15×15, when reality had been something else for several days. It is a minor fix, but we mention it because the "no lying" promise also applies to editorial content: if the documentation reflects a previous state, it stops being a reliable source.

How it was validated: a rubric, E2E and a limitation that remains

The team documented delivery quality with an explicit rubric: PR #474 scored 95 out of 100, with five points deducted for two reasons. The first is that no independent visual comparison between before and after was run across a broad device matrix. The second is that contrast auditing of the new visuals is preliminary. Those two statements are written into the corresponding SDD, not an oversight. Acknowledging what has not been measured matters as much as describing what was measured, and it is a practice we want to keep on every large delivery.

The rest of the rubric was met: the new statistics work across all three scopes without changing the model, Cards preserves records, streaks and achievements, internationalisation covers the six published languages and the E2E flows for solitaire filters, mobile and translations are green. In the pipeline, the plan spelled out that "the E2E flows verify that no change breaks an already working navigation and that the translated strings are the right ones." That guarantee is delivered by tests that walk through the filters, simulate a mobile device and compare aggregate results against the remote source without scraping.

Finally, the browser regressions hydrate remote data without reloading and explicitly verify that neither the URL nor the focus changed. If a future PR breaks that promise, we will know which one it was. The "no lying" commitment demands the same discipline in regression as in the visual itself.

What changed for the player

For someone who opens the app every day, the most visible shift is that the screens feel stable. The Cards home shows the seven real days of the daily solitaire with marks on the days already played; weekly wellbeing advances when it should and not when it should not; records and streaks live in a dedicated tab; and Blupoli's achievements unlock on the real date they were earned, not on the review date.

In Puzzles, the global statistics finally read at a glance. The header summarises completed games, best time, active days and completion rate; the category breakdown shows real, ordered bars; when a specific game is selected, the numbers stop mixing with the rest of the catalogue. In the activity profile, the ten axes exist even when several categories are empty; playing a couple of Kakuro or Slitherlink games is enough to make a bar appear. That progression, from a permanently closed polygon to bars that only show up when there is real data, is probably the most pedagogically clear difference between the before and the after.

From a maintenance point of view, the change is that there is no longer anything to reconcile by hand. Events live in Supabase, projections are computed on the client from that single source, and every PR that touches statistics has an SDD plan with goals, decisions and open validation in docs/specai/. If a metric stops adding up, we know which layer to break and why.

What we deliberately left out

Three fronts remain open and deserve to be named. The first is the exhaustive visual comparison of the redesign on real devices: the rubric flagged it and we have not closed it. The second is the contrast audit of the new visuals, preliminarily evaluated but pending a dedicated pass with proper tooling. The third is the connection between achievements and platform-wide streaks: the Streaks tab keeps its current behavior, but the general streaks system is still a goal listed in content/devlog.json as the next step, not part of this delivery.

There is also an editorial harmonisation to come. Some guides still mention figures that have shifted — for instance, the Nonogram catalogue, which moved from five documented sizes to four. PR #486 already aligned the Spanish and English guides; the next step is reviewing the guides touching records and streaks so the editorial promise stays consistent with the technical one. That consistency is, in the long run, more important than the redesign itself.

The lesson: measure better before showing more

This run leaves a lesson that goes beyond statistics. When a product accumulates dashboards, buttons and charts over time, the temptation is to keep adding — another panel, another metric, another rubric. We did the opposite here: we measured what was lying to us, separated the layers that had become tangled, and redesigned only what survived that separation. The measure did not add numbers; it removed numbers that confused.

The second lesson is about provenance. Moving authority to Supabase was not copy and paste; it was deciding which events persist, which are retried, which are discarded and which are kept for future audits. Trust is delegated to the tool, responsibility stays with the code that decides what to ask of it.

And the third lesson is about rhythm. Five pull requests in less than a week, each with its own plan, its own tests, its own validation and its own review history, moved further than any earlier sprint with less care. The cadence is not dictated by the editorial calendar; it is set by the capacity to close each PR with its plan fulfilled. That is the cadence we want to keep.

Puzzles and Cards no longer need a Synchronize button to be up to date, nor a fake radar to display ten categories, nor a context-free home to invite the player back. They measured what mattered, displayed what they measured and stopped displaying what they could not measure. Blupoli's editorial promise — open access, no installs, no invented pledges — found its most honest form this week.