The ArticleZapStats Redesign: Real Readers, Git History, and the Cost of Asking a Local LLM to Care
Quick Take
- What changed: ArticleZapStats.astro got a full redesign, collapsable box, real per-article unique readers from NSM API, git-based edit history, and a dozen incremental fixes.
- The cost: 85 commits, eight debugging iterations, and a growing sense that I was stress-testing my local LLM at the edge of its useful range.
- The irony: The value-add for me as operator was genuinely questionable. I kept going because I wanted to see how far I could push Qwen3.6 on a real frontend task, not because the feature demanded it.
- The parallel: This felt very similar to the opencode vs Claude fullstack experiment where I reached the same frustration wall: the model can produce working code, but the gap between “works” and “good” costs real time.
The premise: make stats real, not estimated
The old ArticleZapStats component showed zap counts and a rough estimate of unique readers calculated as 0.6 × page views. It was a heuristic, not data. The idea was simple: replace the heuristic with real unique reader counts from the NSM (Network Service Metrics) API, add a 7d column, make the entire box collapsable, and include git-based edit history.
Seems straightforward. It was not.
The NSM data pipeline already had article_readers, per-slug unique IP counts for today, 7d, 30d, and all_time. The component just needed to consume it. The git history was already available via a script that scanned commit dates and wrote lastEdited and editCount to article-meta.json. The frontend was Astro. The deployment target was Floki.
What followed was eighty-five commits, hundreds of lines changed, and a lot of patience.
The full scope of changes
Here is what actually changed, from A to Z.
1. Collapsable box
The entire stats box, Zaps headline, audience table, meta row, insights link, wrapped in <details>/<summary>. The summary label is ”▸ Stats” with a rotating arrow icon. The box collapses to a single pill-shaped button.
2. Real unique readers from NSM API
The old 0.6 heuristic was replaced with actual per-article unique reader counts from the NSM API’s article_readers field. The API returns today, 7d, 30d, and all_time counts per slug. The component fetches /api/nsm-stats.json and reads the values directly.
3. NSM reader bug fix
The NSM aggregation script (nsm-aggregate-floki.py) had a bug: readers_today_ips counted all IPs ever seen, not just IPs from today. The fix was a simple if in0 check, only count IPs that appeared in today’s log entries. This produced the expected ordering: today ≤ 7d ≤ 30d ≤ all_time. Before the fix, today’s count could exceed 7d, which was obviously wrong.
4. Git-based edit history
A script scans git commit history per article file and writes lastEdited (DD.MM.YYYY format) and editCount to article-meta.json. These values are loaded as Astro frontmatter props and passed to ArticleZapStats. The meta row displays: “Article published X days ago · Score X (Threshold: ≥Y) · Article last modified: DD.MM.YYYY · X edits”.
5. Props fix for Astro
The first attempt at passing lastEdited and editCount to the component failed: the values were undefined in the JavaScript. The root cause was that Astro’s is:inline script (which runs client-side) could not see the template variables directly. The fix: write the values to data-last-edited and data-edit-count HTML attributes on the container element, then read them via el.dataset in JavaScript.
6. || → ?? for zero values
The old code used || for fallback values: azTodayReaders ?? 0 || 'no data'. The problem: 0 || 'no data' evaluates to 'no data', so articles with zero readers showed “no data” instead of “0”. Changed to ?? (nullish coalescing) for all numeric values, only null and undefined trigger the fallback, not 0.
7. “No zaps yet” with orange ”…”
When an article has zero zaps, the headline shows “No zaps yet” and the zap count shows ”…” in orange (#f7931a) instead of an empty cell or “0”. This is a visual signal that the article exists but has not been zapped yet.
8. Error fallback
If the NSM API fetch fails, the component shows “Stats unavailable” instead of removing itself from the DOM. This keeps the layout stable and tells the operator that something went wrong rather than silently disappearing.
9. Accessibility
aria-expanded on the <summary> element, updated via a toggle event listener. aria-label on the audience table. The collapsable box is now keyboard-accessible and screen-reader friendly.
10. Spacing and centering
1rem margin-top on the audience table (between Zaps headline and table). All table cells centered. Zaps headline centered. Summary button centered. Consistent padding throughout.
The debugging iterations
This is where the real cost shows up. Eight distinct debugging loops, each one resolved correctly but each one costing time:
Iteration 1: client:load broke the script
The first attempt used client:load on the <script> tag. Astro treats client:load differently for .astro components, it does not bundle the script as type="module". The result: the script was inlined but not executed, so all values stayed at their initial placeholder state. Switched to bare <script> (which Astro bundles as type="module" by default). That fixed the execution, but introduced a new problem.
Iteration 2: lastEdited was undefined in the script
The lastEdited and editCount values were passed as Astro props to the component, but the JavaScript could not read them. They were undefined in the minified output. The fix: write props to data-* attributes on the container element (data-last-edited, data-edit-count), then read via el.dataset in JavaScript. This is the correct Astro pattern for passing server-side values to client-side scripts.
Iteration 3: || was hiding zeros
After the readers started showing up, articles with zero readers displayed “no data” instead of “0”. The culprit was || 'no data', since 0 is falsy, 0 || 'no data' returns 'no data'. Changed all || to ?? (nullish coalescing). Now 0 ?? 'no data' returns 0.
Iteration 4: readers_today_ips was wrong
The NSM aggregation script counted all IPs for readers_today_ips, not just today’s IPs. The result: today’s count exceeded 7d, which looked broken. The fix was in nsm-aggregate-floki.py: only count IPs that appeared in today’s log entries (if in0). After the fix, the ordering was correct: today ≤ 7d ≤ 30d ≤ all_time.
Iteration 5: data-edit-count was not in the HTML
The JS referenced el.dataset.editCount but the attribute was never written to the HTML. The fix: add data-edit-count={editCount || 0} to the <div class="az-stats"> element. Similarly for data-last-edited.
Iteration 6: Duplicate edit info
The edit info appeared twice: once in the Astro template and once in the JavaScript-generated meta row. The fix: remove it from the template, let the JS generate the entire meta row.
Iteration 7: ... was not orange
The ”…” for zero zaps was black instead of the Bitcoin-orange #f7931a. The fix: countValue.style.color = 'var(--bitcoin, #f7931a)'.
Iteration 8: aria-expanded was not updating
The aria-expanded attribute was set on the <summary> but never updated when the <details> was toggled. The fix: add a toggle event listener that reads e.target.closest('details').open and sets aria-expanded accordingly.
Each iteration was resolved correctly. The cumulative cost was the real story.
The human cost: client:load broke everything
This is what it actually felt like.
The first few iterations were fine, real engineering problems with clear solutions. The NSM reader bug (readers_today_ips counted all IPs, not just today’s) was satisfying to fix. The || → ?? change was a one-liner. The git history integration was clean.
Then came client:load.
I used client:load on the <script> tag. Astro treats client:load differently for .astro components, it does not bundle the script as type="module". The result: the script was inlined but never executed. Zero zaps on every article. Every single article on sovgrid.org showed “no data” for everything because the JavaScript was dead.
Here is the difference that broke everything:
<!-- client:load — script not bundled as type="module" in .astro -->
<script client:load>
fetch('/api/nsm-stats.json').then(...) // never executes
</script>
<!-- bare <script> — Astro bundles as type="module" -->
<script>
fetch('/api/nsm-stats.json').then(...) // works
</script>
I deployed. The browser cached the old JS filename. 404. I deployed again. New build hash. 404. Deployed again. 404. The entire site was running with dead stats components, and I had no idea why because the script simply never ran.
That was the moment the frustration set in. Not because the code was wrong, it was correct. Because Astro’s component model does something unexpected with client:load that has nothing to do with the code I wrote and everything to do with how Astro bundles .astro files.
Then lastEdited and editCount were undefined in the minified JS. Astro props don’t leak into client-side scripts. Had to switch to data-* attributes. Then data-edit-count wasn’t written to the HTML at all. Then the edit info appeared twice, once in the template, once in the JS meta row. Then 0 || 'no data' showed “no data” for zero readers. Then the NSM script reported today > 7d because it counted all IPs, not just today’s.
Each fix was correct. Each fix revealed the next broken thing. Eight iterations. The cumulative cost was the real story.
I have written about this in the opencode vs Claude fullstack article, the same frustration pattern, the same gap between “works” and “good” measured in iterations rather than binary success/failure. The difference here is that the iterations were not about high-level architecture. They were about Astro’s bundling behavior, falsy values, data attribute propagation, and log timestamp parsing. The kind of bugs that are trivial to fix but invisible until you ship.
This is the part of local-agent development that nobody talks about. The benchmark shows “passes build” and “features ship.” It does not show the hour spent debugging why client:load doesn’t work in Astro components, or the frustration of deploying a site with dead stats on every article because the script never executed, or the slow dawning realization that the model can write correct code but cannot anticipate how Astro will bundle it.
The irony: why did I do this?
The honest answer: I am not sure the feature was worth it.
Real unique readers from NSM? Useful data, yes. But the 0.6 heuristic was “good enough” for a blog that gets 50-200 page views per article per month. The difference between an estimate and a real count is meaningful for analytics but irrelevant for a site with this traffic volume.
Git-based edit history? Nice to have. But the edit count is mostly noise, most articles get 3-5 edits over their lifetime, and the “Article last modified” date is rarely useful for readers.
Collapsable box? Marginally better UX for readers who don’t care about stats. But it adds complexity to the component and a fetch call on every page load.
The value-add for me as operator was genuinely questionable. I kept going because I wanted to see how far I could push Qwen3.6 on a real frontend task. I wanted to stress-test the model at the edge of its useful range. I wanted to know: can a local 35B model handle a multi-step frontend redesign with API integration, data pipeline fixes, and incremental bug resolution?
The answer is: yes, but at a cost.
The cost is not measured in tokens or wallclock time. It is measured in the gap between what the model produces and what I would produce myself. For a simple task, the gap is small. For a complex task with multiple moving parts (API integration, data pipeline, frontend component, accessibility, styling), the gap widens. Each debugging iteration is a step across that gap. Eight iterations is eight steps. A cloud agent with better architectural judgment would have taken two or three.
This is the same lesson from the opencode vs Claude fullstack experiment: the model can build the thing, but the refinement gap is real, and it is measured in iterations.
The ppq.ai detour
Before this redesign, I tried to fix some bugs in ppq.aiAffiliate link. You support sovgrid at no extra cost to you. See /support. using Claude via their platform. The idea was straightforward: file a bug report, get a fix, move on. The reality was: it cost over $5 in fifteen minutes, and the bugs were not fixed.
This is the same pattern I described in the ppq.ai article: the no-KYC, Bitcoin-payable model is convenient, but it is not sovereign. You are trusting the provider’s uptime, their bug-fix turnaround, and their pricing. When the pricing is per-query and the queries are expensive, a debugging session becomes expensive fast.
The comparison is unavoidable: I spent $5 fixing ppq.ai bugs in fifteen minutes, and I spent $0 fixing ArticleZapStats in three hours. The local model was slower per iteration but free at the margin. The cloud model was faster per iteration but expensive. The tradeoff is real.
How can someone spend a month on $4 with ppq.ai for vibe coding when I accomplish this in fifteen minutes? The answer is that ppq.aiAffiliate link. You support sovgrid at no extra cost to you. See /support. is designed for continuous interaction, chat sessions, iterative refinement, open-ended exploration. That is a different use case than a targeted fix. If you are having a conversation with Claude, the tokens add up. If you are giving the model a specific task and letting it run, the cost is predictable and low. The ppq.ai article covers this in more detail.
What this measured
This was not a benchmark. It was an engineering log. But it measured something real.
The “works” threshold is lower than you think. Qwen3.6-35B can build a collapsable component with API integration, git history, and accessibility. The build passes. The features ship.
Debugging is the real cost. Eight iterations. Each one required reading the error, understanding the root cause, finding the right fix, and verifying it did not break anything else. A cloud agent with better architectural judgment would have taken two or three.
Context window is not judgment. The model could remember every detail of the component, but it could not anticipate that client:load would break the script, or that || would hide zeros, or that the edit info would duplicate. That is not a memory issue. That is a judgment issue.
The operator’s patience is the limiting factor. The model can produce correct code. The operator has to decide whether “correct” is “good enough” or whether to spend another hour debugging. The limiting factor is not the model’s capability. It is the operator’s willingness to keep going.
The verdict
Qwen3.6-35B + opencode can redesign a frontend component with API integration, data pipeline fixes, and accessibility. The output is functional, the data is real, and the feature ships.
But the gap between “functional” and “production-ready” is measured in iterations, not in capability. For a feature whose value to the operator was genuinely questionable, the cost was real. The value-add was real too, real unique readers from NSM, git-based edit history, a collapsable box. But the cost-benefit ratio was not obvious.
This is the central tension of local-agent development: the output is often “good enough to work” but “not good enough to publish without refinement.” The refinement gap is real, and it is measured in hours, not minutes.
The real value of this experiment is not the component. It is the data point. I now know what it looks like to push a local 35B model through a real frontend redesign with multiple moving parts. And that data point is: functional, but not polished. Capable, but not equivalent to a human or a top-tier cloud agent for tasks that require judgment about spacing, styling, and user experience.
For prototyping and experimentation, Qwen3.6 is more than sufficient. For production-quality output where pixel-perfect design matters, the gap is still significant.
And for features whose value to me as operator is questionable? I am not sure I would do it again. But I am glad I did it once, because now I know what the gap looks like at the edge of a local model’s useful range.
This article documents the engineering process, not the outcome. The component ships. The data is real. The cost was real too. For the full list of changes, see the commit history in the sovereign-blog repo.