The Daily Report: Getting a Morning Snapshot of Minnich.Awesome Without Opening Firestore
I got tired of opening the Firestore console every morning just to see how the site did the day before. Here's the automated email report I built instead — and the three things it needed that didn't exist yet: web tool usage tracking, daily article read counts, and a way to count unique players.
Every morning for a while now, checking on the site has meant the same little ritual: open the Firestore console, click into a handful of collections, squint at raw documents, and try to piece together whether yesterday was a good day or not. It works, but it's slow, and it doesn't actually answer the question I care about — not "what does one counter say" but "how did the site do yesterday, as a whole." So I built myself a daily email instead: one message, sent automatically, with the numbers I actually want to see. Getting there turned out to need more than just wiring an email — three of the numbers I wanted didn't exist anywhere yet.
What was already there, and what wasn't
Some of this was easy because the tracking already existed. Game plays have had daily and weekly counters for a while (webapp/plays.py), and site visits, new accounts, coins earned, and cosmetics purchased all already roll up into a shared per-day document (webapp/daily_stats.py). "New accounts created yesterday" turned out to just be reading one field off one document — nothing to build there at all.
But three things I wanted for the report didn't exist yet. Web tools had zero usage tracking — the only signal on a tool was its thumbs up/down rating, nothing counting how many times it was actually opened. Devlog posts had a lifetime read count, but no daily breakdown, so there was no way to ask "what got read yesterday" at all, only "what's been read ever." And there was no concept of a "unique player" anywhere in the codebase — plenty of counters for events, nothing counting distinct people.
Bringing web tools and articles up to parity
For web tools, I didn't invent a new pattern — I copied the one games already use. webapp/tool_plays.py is a near-exact mirror of webapp/plays.py: a total counter, a daily bucket, a weekly bucket, one Firestore commit for all three instead of three separate round trips. A new _track_tool_play hook fires the same way _track_game_play already does, just mapped against the web tools catalog instead of the games one. Every tool page load now counts, the same way every game page load always has.
Devlog posts needed less — just a daily bucket bolted onto the existing lifetime counter, not a whole new module. No weekly bucket this time, because nothing actually needs one; the report only ever asks about yesterday.
Counting people, not events
Unique players was the one genuinely new idea. Every session already gets a random id stamped into it — session["sid"], set the first time the presence tracker needs to know who's currently online. Rather than invent a second identity scheme just for this, I reused that same id. Once a day, the first time a session plays a game, it writes one small document keyed by its own sid into that day's collection:
create_document(f"unique_players/{day_id(day)}/sids", session_id, {"seen": 1})
create_document already refuses to overwrite a doc that exists, so a session playing five different games in one day still only ever costs one write — every attempt after the first is a no-op. Counting how many unique players showed up on a given day becomes nothing more than asking Firestore how many documents are sitting in that day's collection.
Deciding what "top game" should actually mean
I wanted the report to say something like "Top Ace: 340 plays," but a raw number on its own doesn't mean much without something to compare it to. I could have shown it against the game's all-time total, but that buries the actually interesting comparison: how much of yesterday's total gameplay went to the top spot. So the report sums every game's daily count across the whole catalog and shows the leader's share of that — "42% of yesterday's total plays" reads a lot better than a bare 340.
Getting that sum means looping over every game (and every tool, and every devlog post) and reading each one's daily count individually, rather than one shared total. That's deliberate, not an oversight: per-item daily counters intentionally live in their own subcollections instead of fields on one shared document, specifically so a burst of plays across a dozen unrelated games doesn't all pile up as writes to the same single Firestore document. A few dozen extra reads once a day, in a job nobody's waiting on, is a fine trade for not reintroducing that contention on the live site.
Cloud Run has no clock, so I had to bring my own
The site runs on Cloud Run, which doesn't keep a process alive between requests the way a normal server would — there's nowhere to put an in-process scheduler that fires once a day, because the instance running it might not even exist anymore by the time tomorrow rolls around. So instead of a scheduler living inside the app, I built a small locked door for something outside the app to knock on: POST /internal/daily-report, gated by a shared-secret header checked with secrets.compare_digest, failing closed if the secret isn't even configured. The rest of the site is public, so this couldn't ride on Cloud Run's own IAM auth without locking down everything else too — a header check inside Flask was the simpler fit.
The knocking part is a Google Cloud Scheduler job, set up straight through the console rather than a one-off command I'd never see again: a daily cron trigger that POSTs to that endpoint with the right header attached. Boring, but exactly the kind of boring I want from something that's supposed to just quietly work every morning without me thinking about it.
The first HTML email this site has ever sent
The bug report and feature request forms already send mail through Flask-Mail, but both are plain text — nobody had built an actual styled email before. Real email clients don't render flexbox or grid reliably, so this meant going back to nested tables with inline styles, the way email had to be built years before modern CSS layout existed. I pulled the site's own accent colors in rather than reaching for the retro arcade fonts used everywhere else on the site — fun for a game page, a bad fit for a table of numbers someone's trying to actually read at 7am.
Verifying something that only really runs once a day
I can't exactly wait a day and see if this works, so I verified it in pieces instead. Hitting the endpoint with no header, and then with the wrong header, both correctly came back as a 403. Building the report and rendering the email both worked cleanly even against a completely empty day — no plays, no reads, no new accounts — which matters, since that's exactly the state the very first run of this will actually be in. Game, tool, and devlog pages all still loaded fine with the new tracking hooks attached. And with the right header, the whole pipeline ran all the way to a real attempted send to Gmail's SMTP server, which only failed locally because there's no mail password sitting in my local shell — the same send path the bug report form already uses successfully in production, every time someone submits one.
Tomorrow morning, for the first time, there should just be an email waiting instead of a reason to open Firestore.
More from the Devlog