DEV Community

mike-betterprompt
mike-betterprompt Subscriber

Posted on • Originally published at betterprompt.me

Why I Print My Startup Dashboard Every Morning

I'm building BetterPrompt, and this is the reporting workflow I ended up needing every morning.

My startup metrics live in two places. PostHog has traffic and top-of-funnel behavior. Postgres has signups, onboarding, prompt runs, subscriptions. The annoying part wasn't that the data was split — every startup has that problem. The annoying part was that the funnel I actually cared about only existed after combining both.

So every morning I had two options: open multiple dashboards and do the math by hand, or skip it entirely. Most days I skipped it.

Now I run one command, and a sheet of paper comes out of my printer.

The daily report, printed

The one command

bun run report
Enter fullscreen mode Exit fullscreen mode

That command runs a set of Postgres and PostHog queries, writes the raw outputs to CSVs, computes the cross-source funnel, renders a print-ready PDF, and hands the PDF to my laser printer.

Why dashboards weren't enough

The hard part wasn't querying either system. Both are great on their own. The hard part was that the question I cared about — "how much traffic turned into activated users?" — didn't live in a single dashboard.

PostHog knows visitors. Postgres knows users. The funnel I care about — visitor → signup → onboarded → activated — starts in one and ends in the other. You can't answer it from one place, and once you stop answering it daily, you stop noticing when a step breaks.

The metrics that matter

The report is intentionally small. I don't need a BI suite in the morning — just the handful of numbers that tell me whether the business is moving: 7-day traffic, signups, DAU, prompt runs, failure rate, credit cost, and the full funnel.

The one I watch hardest is activated. For BetterPrompt, activation means a new user has run one prompt. Before that moment they're a tire-kicker; after it they've experienced the product. Everything else in the funnel is a means to that number. The report tracks it both within 10 minutes of signup (did onboarding work?) and within 24 hours (did they come back when life let them?).

What bun run report actually does

Under the hood, the command is boring, which is exactly what I wanted:

  1. Run the Postgres queries.
  2. Run the PostHog HogQL queries.
  3. Dump each result to a timestamped CSV.
  4. Load the CSVs, compute the cross-source funnel, summarize each metric.
  5. Render an HTML report with print-friendly CSS.
  6. Convert HTML to PDF with headless Chrome.
  7. Send the PDF to the printer via macOS lp.

The runtime is Bun, which offers a bunch of benefits. Zero build step, native TypeScript, a built-in SQL client, and a shell DSL that makes steps 6 and 7 one-liners. The orchestration is one TypeScript file:

import { $ } from "bun"

async function main() {
  // 1 + 2: run the queries — each one writes its own CSV
  await runPostgresQueries()
  await runPostHogQueries()

  // 3: load whatever CSVs landed on disk today
  const results = await loadLatestResults()

  // 4: compute the one thing neither tool can compute alone
  const funnel = buildCrossSourceFunnel(results)

  // 5: render HTML (tiny template, print CSS)
  const htmlPath = await renderHtml({ results, funnel })

  // 6: HTML → PDF via headless Chrome
  const pdfPath = htmlPath.replace(/\.html$/, ".pdf")
  await $`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
    --headless --disable-gpu \
    --print-to-pdf=${pdfPath} \
    --no-pdf-header-footer \
    file://${htmlPath}`.quiet()

  // 7: send to the printer
  await $`lp -d <printer-name> ${pdfPath}`.quiet()
}

main()
Enter fullscreen mode Exit fullscreen mode

The interesting part is step 4 — the cross-source funnel. That's the part I used to do by hand:

function buildCrossSourceFunnel(results: QueryResult[]) {
  const visitors = results.find(r => r.name === "daily_unique_visitors") // PostHog
  const signups  = results.find(r => r.name === "daily_signup_funnel")   // Postgres

  // PostHog's visitor count is keyed by date. Postgres's signup-cohort
  // funnel is also keyed by date. Joining them is just a map lookup.
  const visitorByDate = new Map(
    visitors.rows.map(r => [toIsoDate(r.day), Number(r.unique_visitors)])
  )

  return signups.rows.map(r => {
    const date = toIsoDate(r.date)
    return {
      date,
      visitors:   visitorByDate.get(date) ?? 0,
      signups:    Number(r.signups),
      onboarded:  Number(r.onboarded),
      activated:  Number(r.activated_24h),
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

That's the whole trick. PostHog gives me the top of the funnel by date. Postgres gives me the rest by date. A Map keyed on the date stitches them together, and I end up with one row per day where every stage sits side by side. The rates — V→S, S→O, S→A — are just division.

Why it ends on paper

Dashboards are easy to ignore. There's always another tab, another filter, another date range to fiddle with — and the moment you have to think about how to read the thing, you've already lost. A printed page doesn't negotiate. It's one fixed snapshot of the business, sitting on my desk whether I want it there or not.

The print step is one line:

lp -d <printer-name> report_2026-04-22.pdf
Enter fullscreen mode Exit fullscreen mode

lp is the standard CUPS print command on macOS and Linux. Point it at a named printer, and it sends the job. On Windows you'd swap in Start-Process -Verb Print or similar.

I review the sheet with my morning coffee. When something looks off, I circle it with a pen. This quickly becomes my morning ritual.

What changed after I built it

The biggest benefit wasn't speed. It was consistency. Before this, checking analytics depended on motivation. After this, it's a fixture of my day.

I also catch funnel problems earlier. Twice now, the sheet has shown me a stage that fell off a cliff overnight — once a silent onboarding bug, once a broken signup email. Neither was obvious in any single dashboard. Both were obvious once the numbers were lined up in one row.

Closing

I'm building BetterPrompt, but this is the kind of internal tooling that actually keeps a small startup running. Not more dashboards or fancy analytics setup. Just one command, one report, and one daily view of the numbers that matter — on paper, on my desk, by the time the coffee is done.

If you've got metrics split across two tools and you're tired of doing the math in your head, try this approach. A boring script, a cross-source join by date, and a printer. It took me an afternoon, and it's been the single best piece of internal tooling I've built this year.

Top comments (0)