<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: mike-betterprompt</title>
    <description>The latest articles on DEV Community by mike-betterprompt (@mikebp).</description>
    <link>https://dev.to/mikebp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3227415%2F001f4896-6f71-4ca2-b370-a6852fec4ef5.png</url>
      <title>DEV Community: mike-betterprompt</title>
      <link>https://dev.to/mikebp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mikebp"/>
    <language>en</language>
    <item>
      <title>Why I Print My Startup Dashboard Every Morning</title>
      <dc:creator>mike-betterprompt</dc:creator>
      <pubDate>Thu, 23 Apr 2026 03:43:55 +0000</pubDate>
      <link>https://dev.to/mikebp/why-i-print-my-startup-dashboard-every-morning-28hn</link>
      <guid>https://dev.to/mikebp/why-i-print-my-startup-dashboard-every-morning-28hn</guid>
      <description>&lt;p&gt;I'm building &lt;a href="https://betterprompt.me" rel="noopener noreferrer"&gt;BetterPrompt&lt;/a&gt;, and this is the reporting workflow I ended up needing every morning.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;after&lt;/em&gt; combining both.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Now I run one command, and a sheet of paper comes out of my printer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwb3s4966a3p79cx7cxph.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwb3s4966a3p79cx7cxph.webp" alt="The daily report, printed" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one command
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why dashboards weren't enough
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;PostHog knows &lt;em&gt;visitors&lt;/em&gt;. Postgres knows &lt;em&gt;users&lt;/em&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metrics that matter
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The one I watch hardest is &lt;em&gt;activated&lt;/em&gt;. 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?).&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;bun run report&lt;/code&gt; actually does
&lt;/h2&gt;

&lt;p&gt;Under the hood, the command is boring, which is exactly what I wanted:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the Postgres queries.&lt;/li&gt;
&lt;li&gt;Run the PostHog HogQL queries.&lt;/li&gt;
&lt;li&gt;Dump each result to a timestamped CSV.&lt;/li&gt;
&lt;li&gt;Load the CSVs, compute the cross-source funnel, summarize each metric.&lt;/li&gt;
&lt;li&gt;Render an HTML report with print-friendly CSS.&lt;/li&gt;
&lt;li&gt;Convert HTML to PDF with headless Chrome.&lt;/li&gt;
&lt;li&gt;Send the PDF to the printer via macOS &lt;code&gt;lp&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The runtime is &lt;a href="https://bun.com" rel="noopener noreferrer"&gt;Bun&lt;/a&gt;, 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bun&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1 + 2: run the queries — each one writes its own CSV&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runPostgresQueries&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runPostHogQueries&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="c1"&gt;// 3: load whatever CSVs landed on disk today&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadLatestResults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="c1"&gt;// 4: compute the one thing neither tool can compute alone&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;funnel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildCrossSourceFunnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// 5: render HTML (tiny template, print CSS)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;htmlPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderHtml&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;funnel&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="c1"&gt;// 6: HTML → PDF via headless Chrome&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pdfPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;htmlPath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;html$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="s2"&gt;`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
    --headless --disable-gpu \
    --print-to-pdf=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pdfPath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; \
    --no-pdf-header-footer \
    file://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;htmlPath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quiet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="c1"&gt;// 7: send to the printer&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="s2"&gt;`lp -d &amp;lt;printer-name&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pdfPath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quiet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is step 4 — the cross-source funnel. That's the part I used to do by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildCrossSourceFunnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QueryResult&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visitors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;daily_unique_visitors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// PostHog&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signups&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;daily_signup_funnel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// Postgres&lt;/span&gt;

  &lt;span class="c1"&gt;// PostHog's visitor count is keyed by date. Postgres's signup-cohort&lt;/span&gt;
  &lt;span class="c1"&gt;// funnel is also keyed by date. Joining them is just a map lookup.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visitorByDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;visitors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;toIsoDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unique_visitors&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toIsoDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;visitors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;visitorByDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;onboarded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onboarded&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;activated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activated_24h&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole trick. PostHog gives me the top of the funnel by date. Postgres gives me the rest by date. A &lt;code&gt;Map&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it ends on paper
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The print step is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lp &lt;span class="nt"&gt;-d&lt;/span&gt; &amp;lt;printer-name&amp;gt; report_2026-04-22.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lp&lt;/code&gt; 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 &lt;code&gt;Start-Process -Verb Print&lt;/code&gt; or similar.&lt;/p&gt;

&lt;p&gt;I review the sheet with my morning coffee. When something looks off, I circle it with a pen. This quickly becomes my morning ritual.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed after I built it
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>startup</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
