- A personal morning dashboard is four moving parts: a schedule, a folder of small collectors, an orchestrator, and one HTML template.
- Each collector talks to exactly one thing (Cloudflare, GSC, Stripe, your DB, whatever) and writes a single JSON file. That's the whole contract.
- The orchestrator never calls an API. It reads JSON, slots it into a Jinja2 template, writes one HTML file. If a section is missing or stale, it renders an "unavailable" badge and keeps going.
- Open it locally via
file://. Don't host it. Around 1000 lines of Python, replaces five SaaS dashboards. - For the resilience details, see the dropbox pattern post.
People keep asking me how my morning dashboard works. Short version: I open one HTML file with my coffee, and every number I care about across my products is on it. No tabs, no SaaS, no login. This is the longer version.
This is not a copy-paste of my actual code. The code is private, it has my API keys and customer leads in it. But the shape of the thing is easy to describe, and the shape is what matters. Build your own version with the shape below and it'll work. Mine is maybe 1000 lines of Python total. Most of those lines are HTML and CSS.
I already wrote the deeper post on failure handling: My Morning Dashboard Went Blank for a Day. That one is about the day mine rendered empty, and the dropbox pattern that should have saved it. This post is the higher-level recipe. The "how do I build something similar" version.
The Four Moving Parts
A morning dashboard is just four things glued together:
- A schedule, one thing that fires every morning, on its own.
- A bunch of small collectors, one file per data source.
- An orchestrator, one script that reads the collectors and writes HTML.
- A template, one HTML file with all the design opinions in it.
That's it. No database, no message queue, no web framework. Files on disk, glued by a cron job. Boring on purpose.
Part 1: The Schedule
The whole point is that you don't have to log into eight SaaS tabs every morning. So the dashboard has to actually run by itself. If it depends on you remembering to run it, you've built a worse version of opening eight tabs.
On macOS cron still works (or launchd if you want to be proper). On Linux cron. On Windows Task Scheduler. I run mine at 08:00 local. The cron entry is one line:
0 8 * * * /usr/bin/env bash -lc '~/Tools/morning-digest/run.sh'
The bash -lc matters on macOS. Without a login shell, cron won't have Homebrew on PATH, your collectors will all fail with "command not found", and you'll wonder why your dashboard is blank. Voice of experience.
At the end of the orchestrator script, one extra line pops the file in your default browser:
open ~/reports/morning-latest.html # macOS
xdg-open ~/reports/morning-latest.html # Linux
When you sit down with coffee, the tab is already open. That's the whole UX.
Part 2: Collectors
One file per data source. Cloudflare. Google Search Console. Your WordPress database. Stripe or Polar. GitHub. Reddit. Your email signup table. Whatever you care about. Each collector talks to exactly one thing, fetches what it needs, and writes the result to a JSON file on disk. Done.
No collector knows the others exist. No collector imports another collector. No collector renders HTML beyond a tiny snippet. They're the dumbest, most replaceable parts of the system, and that's the whole point.
The contract every collector follows looks like this:
def collect_X():
try:
data = call_some_api()
write_json({
"state": "ok",
"title": "GitHub",
"generated_at": now_utc_iso(),
"data": data,
})
except Exception as e:
write_json({
"state": "error",
"title": "GitHub",
"generated_at": now_utc_iso(),
"error": str(e),
})
Two rules. First one is non-negotiable: a collector must never crash the orchestrator. It always writes some JSON, even if the API is on fire. Second: always include a state field, so the renderer knows whether to draw the panel normally or paint a small "unavailable" badge.
That's the whole contract. Six fields, one rule about never crashing. Anything that follows that shape is a valid collector. Mine are around 30 to 80 lines each. The Cloudflare one is the longest because GraphQL queries are wordy. The GitHub one is shorter because I just shell out to gh.
The point of the contract isn't elegance. It's that you can write a new collector in 20 minutes, drop the file into the folder, and the orchestrator picks it up on the next run without knowing anything about it. New data source = new file. That's the whole integration story.
Part 3: The Orchestrator
One Python script. It reads every JSON file in the collectors folder, slots them into a Jinja2 template, writes one HTML file. The orchestrator is read-only on data. Never calls an API. Never panics. Never imports a collector. Doesn't even know their names ahead of time.
The whole logic is "glob the folder, load each JSON, hand it to the template". Pseudocode:
def render():
sections = []
for path in sorted(COLLECTORS_DIR.glob("*.json")):
sections.append(load_section(path))
html = template.render(
sections=sections,
generated_at=now_utc(),
)
OUTPUT.write_text(html)
The interesting part is load_section. Its job is to never raise:
def load_section(path):
base = {"name": path.stem, "state": "unavailable"}
if not path.exists():
return {**base, "reason": "no data file"}
try:
raw = json.loads(path.read_text())
except Exception as e:
return {**base, "reason": f"invalid json: {e}"}
age_h = age_in_hours(raw["generated_at"])
if age_h > STALE_HOURS: # I use 36
return {**base, "reason": f"stale ({age_h:.1f}h old)"}
if raw.get("state") != "ok":
return {**base, "reason": raw.get("error", "collector error")}
return {**raw, "state": "ok", "age_hours": age_h}
Default behavior: unavailable. The function only returns a healthy section if the file exists, parses, is fresh, and says state: ok. Any failure along the way downgrades that one section, and the rest of the report still renders.
If too many sections are missing, the orchestrator can refuse to overwrite the previous report. That way you don't wake up to a blank dashboard when everything broke overnight. That whole self-healing story has its own post: the dropbox pattern. Read that one for the gory failure-mode details.
Part 4: The Template
One HTML file. All the design opinions in it. Hard-code your layout. Don't try to make it configurable. You'll never reconfigure it. You'll just iterate on the HTML directly when you decide the masthead is too tall.
I do roughly this:
- A masthead with the top-line aggregate numbers: total pageviews across all sites, total revenue last 24h, total new signups. The "did I have a good day" panel.
- A grid of project panels, one card per product, each with that product's slice of Cloudflare, analytics, revenue, GSC, etc.
- A few generic panels at the bottom: GitHub commits, Reddit mentions, server uptime, email queue depth, whatever.
The Jinja2 trick that makes this robust is the {{ section.data.foo }} indirection. When a section is missing or stale, section.data just isn't there, and you render a small empty cell with an "unavailable" badge instead of crashing the template. Nothing explodes. The dashboard always renders something.
A minimal panel macro looks like this:
{% macro panel(section) -%}
<div class="panel">
<h2>{{ section.title }}</h2>
{% if section.state == "ok" %}
<div class="value">{{ section.data.value }}</div>
{% else %}
<div class="badge muted">unavailable - {{ section.reason }}</div>
{% endif %}
</div>
{%- endmacro %}
Boring. That's the whole shape. Styling is wherever you want to take it. I run a dark editorial layout (Fraunces serif headline, IBM Plex Mono labels, muted gold accents), because I read it at 8am and dark feels right. Pick what you like. The data flow doesn't care.
Recipe Tweaks Worth Mentioning
A handful of small details that turned my dashboard from "kind of works" into "actually runs every day":
Two-pass runner
At 8am the macOS keychain might still be locked. One of your collectors will try to fetch a credential and fail. Don't retry the whole thing, only retry the ones that flagged state != "ok":
for collector in collectors/*.py; do
python3 "$collector" || true # never abort the whole run
done
sleep 30 # give the keychain / network a beat
for collector in collectors/*.py; do
name=$(basename "$collector" .py)
state=$(jq -r '.state // "missing"' "collectors/$name.json" 2>/dev/null)
if [ "$state" != "ok" ]; then
python3 "$collector" || true
fi
done
uv run digest.py
Saves API quota. Hides flakiness. The dashboard renders a clean page even though the first pass had three failures.
Open it locally, not online
This is your dashboard. It contains revenue, leads, customer email addresses, maybe internal server hostnames. Keep it as a file:// link in your home folder. Don't host it. Don't put it behind a login either, because there's no login to compromise if the file never leaves your machine. The attack surface is literally your laptop.
Archive each run
Also drop a copy of the HTML into reports/morning-archive/2026-05-14.html. You get a free changelog. You'll be surprised what you learn flipping through old ones. ("Oh, that's the week MRR plateaued. What was I doing that week?")
Credentials in the system keychain
Never paste API keys into a config file. Use the system keychain: security on macOS, secret-tool on Linux. Have the collector fetch the key at runtime. If the keychain is locked at 8am, that's exactly what the two-pass retry handles. Don't store secrets in .env files in the project folder. The whole thing lives in your home directory, and it talks to APIs that move money. Be a little paranoid.
What to Actually Put on It
This is the part nobody can tell you. The dashboard only works if the numbers it shows are numbers you actually care about. Mine are: pageviews, unique visitors, new leads, email signups, 24h revenue, GSC clicks, server uptime, GitHub commits, Reddit mentions of my products. That list is shaped by what I sell and what I'm trying to grow.
Your list will be different. Here's the only filter that matters:
If you wouldn't change your day based on a number, don't put it on the dashboard. That's the whole filter.
"Followers on Twitter": would you do anything different today if it went up or down by 20? No. Don't put it on the dashboard. "Email signups in last 24h": yes, if it spiked I'd want to know which post drove it, and if it died I'd want to check the form. Put it on the dashboard. Apply the filter ruthlessly. Removing things makes the dashboard stronger. Adding things rarely does.
Why Not Just Use Grafana / Metabase / a SaaS
Honest answer: you can. They work. But every one of them is built for teams, and teams need things personal dashboards don't: auth, RBAC, dashboards-as-config, SSO, audit logs. You'll spend more time getting Grafana to talk to your WordPress database than writing the whole dashboard from scratch.
You also don't want to give a hosted SaaS your Cloudflare API token and your Stripe secret key. The whole appeal of the file-on-disk approach is that nothing leaves your machine. Keys live in your keychain. Data lives in ~/reports/. Nothing to log into, nothing to compromise.
And here's the part I didn't expect: when you own all the code, the dashboard becomes a place you can drop one-off ideas. Yesterday I added a panel that shows the top three Reddit posts from any subreddit that mentioned my domain in the last 24h. It's 60 lines of Python and one new entry in the template. Try doing that in a SaaS dashboard tool.
Closing
Start with one collector. Whichever metric you keep opening a tab to check, that one. Write the collector for it tonight. Write a tiny one-panel HTML template. Wire it to cron. Tomorrow morning, the tab is already open. Add a second collector when you find yourself opening a different tab. Repeat.
You'll end up with something that takes 30 seconds to read in the morning, costs nothing to run, and replaces five SaaS dashboards you were never going to pay for anyway. Mine has run every morning for months. Went blank exactly once, and even that taught me something. The dropbox pattern writeup is the postmortem.
That's the whole recipe. Steal it.