pornboxdBETA
← Field Notes
Devlog

The admin insights tab gets the editorial-terminal treatment

The old /admin/insights page was two 7-day tables and a flat totals row. That’s it. Top event types. Top searches. A strip of counts along the bottom. No time

The old /admin/insights page was two 7-day tables and a flat totals row.

That’s it. Top event types. Top searches. A strip of counts along the bottom. No time range. No charts. Clicking “Insights” in the admin sidebar answered almost nothing, which made sense, because almost nothing was being tracked. The client-side track() beacon fired from exactly four surfaces: the video detail page, the actor detail page, the search page, and the affiliate-link component. Home, studios, tags, lists, user profiles, the blog, all silent. And every user action, logging a watch, writing a review, following someone, liking a list, was completely invisible because there was no server-side event emitter at all. The dashboard wasn’t failing to render useful data; it simply had none to render.

Today it does.

The tracking gap, closed at the seam

The cleanest place to emit an event for “this user just logged a watch” isn’t the browser, it’s the API handler that wrote the row. The browser can be ad-blocked, behind a tracker blocker, in a privacy-mode tab. The Express handler at apps/api/src/routes/me.ts absolutely knows the watch was logged, because it just wrote to watch_log and returned 201. Emitting there gives us a signal that survives every client-side blocker and matches DB truth exactly.

So there’s a new helper, apps/api/src/lib/events.ts exporting logEvent(req, {...}) and logSearch(req, {...}), and every mutation handler now emits. register and login fire from auth.ts. log_watch, log_delete, review_create/edit/delete/like/unlike, favorite_add/remove, watchlist_add/remove, follow_{user,actor,studio,tag} + their unfollow_* pairs, list_create/edit/delete/like/unlike, all from me.ts, reviews.ts, lists.ts. The discipline copies the notifications helper pattern shipped a few weeks ago: the helper itself does not swallow errors, every caller wraps in try { await logEvent(...) } catch { console.error(...) } after the primary action has succeeded, so a DB hiccup on the analytics write can never roll back a like or a follow. Cheap, ad-block-proof, and carries all the context the browser doesn’t, user_id from the JWT, cf-ipcountry from the Cloudflare edge, first-touch referrer, session id.

The session id thread is worth one sentence. Client-side track() has always minted a session id into sessionStorage; now apps/web/src/lib/api.ts reads that same id and sends it as an X-Session-Id header on every authenticated fetch. Which means a server-side follow_actor event fires with the same session id the client’s page_view beacon used three seconds earlier. The two ingestion paths collapse into one continuous visit timeline.

The aggregator finally aggregates

apps/processor/src/aggregate-stats.ts had been populating daily_stats nightly for months, but nothing ever read from it. Today I fixed both ends at once: four new metric families land in daily_stats every night, views_{home,video,actor,studio,tag,list,user_profile,blog,blog_index} per entity_type, dau and dau_sessions, a rolling-30-day mau snapshot anchored at each date, and per-studio affiliate_clicks, and the new insights endpoints read from it.

Worth a one-liner warning: INSERT INTO daily_stats (..., studio_id, ...) SELECT ..., NULL, ... silently cast the bare NULL as text against the uuid column and 500’d the whole run. The fix is NULL::uuid. It’s one of those type-inference corners where Postgres is technically correct, an untyped NULL could be anything, and your dev-mode smoke test passes because your first three columns already pinned the row’s type. Now every site-wide row in the aggregator has the cast explicit, which is slightly ugly to read and will prevent this bug permanently.

The dashboard

I committed to an aesthetic before writing a line of UI code. Editorial terminal. Serif section headers reusing the blog’s --blog-serif stack, the same Iowan Old Style / Palatino the Field Notes posts use, paired with system sans for body text. Hairline dividers between sections instead of boxed cards. font-variant-numeric: tabular-nums on every single figure, because a dashboard where numbers don’t vertically align is a dashboard that looks wrong even when it’s right. A single pink accent for primary sparkline strokes and delta pills; muted green and red for direction; nothing else competes for the eye. The mental reference wasn’t “modern SaaS dashboard”, it was a Bloomberg terminal crossed with a magazine index page.

The layout is one shell file owning the range state (24h / 7d / 30d / 90d, URL-synced) and eight stacked panels: a horizontal stat strip (10 hero cards with deltas and sparklines), a tabbed Traffic chart, a 2×2 Top Content grid, Search intelligence (top queries plus zero-result queries in red as an unmet-demand signal), a 4-step Funnel with drop-off percentages, a country table with flag emoji, a unified Affiliate panel that absorbs the old /admin/affiliates page entirely, and a collapsed Realtime ticker that polls every five seconds when you expand it.

Why no chart library

Recharts is 90 KB gzipped. Visx is larger. uPlot is tiny but imperative. For the handful of shapes I actually needed, sparkline, area, stacked area, horizontal fill bars, vertical bars, a delta pill, each one is ten to thirty lines of SVG math. And there’s a real cost to shipping a chart library on an editorial-terminal page: the defaults look like a chart library. Every recharts dashboard I’ve ever seen has the same axis tick typography, the same tooltip chrome, the same padding. Theming your way out of that takes more code than writing the primitives, and you never quite escape the ambient “this is Recharts” feel.

So apps/web/src/components/charts/ is six tiny React components plus a tokens file. Zero dependencies added. Every chart is a <svg viewBox> that scales to its container, reads colours from CSS custom properties, and handles its own hover tooltip. The stacked area gets a legend row with per-series totals; the area gets a crosshair; the bars get per-bar titles for keyboard hover. Touch long-press handling was already needed for mobile, and baking it into each chart instead of a generic tooltip system kept the total line count low.

Mobile is actually mobile

Below 640 px the hero strip flips from a CSS grid to overflow-x: auto with scroll-snap-type: x mandatory, swipe through the 10 stat cards instead of scrolling a wall of them. Tables collapse to label-above-value stacked rows via a CSS-only pattern (td::before { content: attr(data-label) }) that the Traffic panel, Affiliate panel, and Recent Conversions table all share. Range pills live in a horizontal scroll strip. The tab bar above Traffic wraps cleanly at the two-line break. On a 375 px viewport every panel is usable; the only compromise is that the stacked area legend breaks across two lines instead of one.