Skip to main content

The dashboard

The signed-in analytics surface: what /dashboard and /dashboard/sources render, where the numbers come from, and the honesty rules the code enforces so they cannot be rendered dishonestly. The pipeline that produces the rows is in analytics-pipeline.md; the plan quotas and cadence are in measurement.md.

The shape of it

Everything below load.ts is pure and unit-tested. The components render whatever the aggregation produced and make no statistical decision of their own — which is the point: a rule enforced in one pure function cannot be forgotten by the fourth component that needs it.

What it measures

Over a selectable window (4, 8 or 12 weekly slots) against the equal-length window immediately before it: Each is rendered four ways: a headline card, a weekly series, a per-engine breakdown, and a row in the competitor ranking. The engine list comes from config.engines and is threaded in as an argument — no module under lib/analytics/ holds an engine name. Share of voice and the ranking are Pro-only, and the gate is a read gate. DashboardAnalytics.competitorRanking is an Entitlement<CompetitorRanking> — a discriminated union, so a component cannot reach the share-of-voice card, its series, its per-engine breakdown or the standings without having answered which case it is in. All four are one field rather than four because they are one sold feature, and share of voice belongs with the ranking rather than with visibility because its denominator is the tracked competitor set. The enforcement is upstream of the aggregation: source-supabase.ts resolves the owner’s plan before it issues the brand reads and, without the entitlement, never selects the competitor brands or their brand_daily_metrics rows — so a free workspace does not receive the data and then have it hidden. The resolver answers for the workspace owner and raises for anyone else — subscriptions is owner-readable only, so a non-owner seat would resolve to free and be gated on a workspace that may be on Pro; see measurement.md for the packaging and for what serving seats will need. Every row takes the width of what it actually renders — components/dashboard/analytics-view.tsx looks a literal column class up by the number of cards, charts or breakdowns the row holds, and each row is an as const tuple so an unmapped count is a compile error — which cuts both ways: the free view collapses and reads as a smaller complete product rather than a bigger damaged one, and the entitled view widens rather than stranding its third chart at half width against empty space. Share of voice counts answers, not name-drops. Both sides of the fraction are mention_run_count, so an answer that names a brand five times is one observation, exactly like an answer that names it once. mention_count — every occurrence — would inflate n and hand Clopper–Pearson repeated observations of the same answer as if they were independent trials, producing an interval narrower than the evidence and, through intervalsOverlap(), a directional claim on less evidence than the contract implies. position_count is already one row per (run, brand), so average position is answer-denominated for free. Each measure states its threshold in its own unit. workspaces.min_sample_n counts succeeded runs. Visibility is denominated in runs and takes it unchanged. A run can name several brands in the voice set, so shareOfVoiceThreshold() scales it by the naming density actually observed — the share becomes reportable at exactly min_sample_n runs’ worth of naming, rather than on a fraction of the runs visibility needs at the same nominal number. Average position keeps the number (rankedAnswerThreshold()): a brand is ranked at most once per answer, so scaling would only ever loosen it, and a mean carries no interval to read the resulting width from. The threshold that was applied travels on Measure/MeanMeasure as minSampleN, so reportabilityNote() and the ranking’s warning quote what was actually tested against instead of the workspace setting in the wrong unit. analyticsWindow() anchors on the Monday of the current ISO week, the same slot lib/runner/slots.ts writes, so the newest bucket on the chart is the newest slot the runner produced.

The honesty rules, and where each one lives

These are not presentation preferences. Each is enforced at a single point, and the type system is doing most of the work. Every proportion shows its n. Measure from lib/stats.ts carries successes, n and value: number | null, and <SampleSize> renders the n as text in the layout — not in a tooltip. 11% from 9 runs and 11% from 900 are different claims, and evidence a reader has to hover to find is evidence most readers never see. Every point estimate carries its interval. Clopper–Pearson, exact binomial, on the cards, on the per-engine bars (<IntervalBar> draws the interval as the bar and the point estimate as a tick inside it), on every row of the ranking, and as a shaded band on the charts. A delta is directional only when the intervals are disjoint. verdictFor() in aggregate.ts is the only producer of DeltaVerdict, and moved — the only variant carrying a direction — is only returned when intervalsOverlap() is false. verdictDisplay() maps every other variant to tone: "neutral", and <Delta> keys its arrow and its colour off that tone. There is no path from an overlapping delta to a green arrow. Average position is never given a direction at all. The rollups store position_sum and position_count but no sum of squares, so the variance of the mean is not recoverable and there is no interval to test with. compareMeans() returns untestable, the card shows both periods side by side, and MeanMeasure deliberately has no interval field so no component can reach for one. Absence renders its reason. no_prior_window, no_current_window and insufficient_prior_n each get their own sentence, because which half of the comparison is missing is exactly what the reader needs to know. no_prior_window names the unit that ran out — “the previous window recorded no answer mentions” — rather than asserting that nothing ran, which is only true for visibility: a prior window reaches n = 0 on the other two cards by running and naming nobody, or by running and ranking nobody. verdictFor()’s defensive branches each name the half they found missing, so no branch can explain the wrong side. value: number | null is what makes fabricating a zero a type error rather than a habit. There is exactly one ?? 0 in the read path, and it is mandated. visibilityMeasure() in aggregate.ts reads brandSlice?.mentionRunCount ?? 0: a brand with no brand_daily_metrics row for a slice that did have runs was measured against that slice’s workspace_daily_runs.run_count and went unmentioned, so the zero is a real observation and the rollup migration’s documented policy (20260725120500_rollups.sql) is what produces it. Do not delete that line while enforcing the no-?? 0 rule — it is the difference between a measured zero and absence. Every other zero-fill stays forbidden: a (engine, bucket_date) slice with no workspace_daily_runs row contributes nothing at all, not a zero. The two cases look alike in a diff and are opposite decisions. A plan boundary is not an empty state. They have different causes and different remedies, so they get different screens: CompetitorRankingGate says which plan carries the view, what it would show, that the tracked competitors keep working, and links somewhere — it never says “no data yet”. data-slot="plan-gated" marks it, against the empty states’ data-slot="no-value", so a test cannot confuse the two. Reusing the empty state here would tell a customer their data is missing when their plan is what is missing, which is the fabricated-zero failure wearing a different hat. The empty state is a decision, not a wall of “no data”. hasBrandToMeasure() gates the surface: brand is null until onboarding writes a kind = 'self' brand, and saveOnboarding inserts the workspace and the brand in separate non-transactional steps, so a workspace with runs and no brand is reachable. That state renders the “Set up your brand” invitation, not an analytics page where every measure happens to be absent. A gap is a gap. workspace_daily_runs has no row for a week nothing ran, and buildChartGeometry() groups points into maximal consecutive runs so each is its own polyline. Nothing interpolates across a missing week, and the missing week keeps its position on the x axis with a dashed marker and a named entry in the “No data” line underneath. A brand that was measured and went unmentioned is different — that is a real zero against a real denominator, and the rollup migration’s zero-fill policy is what produces it.

Why there is no charting library

The one thing these charts must do is leave a hole where no runs happened, and interpolating across a missing point is the default behaviour of every line-chart library worth adding. Twelve points of a weekly series need one polyline and one polygon; computing that in lib/analytics/chart.ts keeps the whole surface a Server Component, puts the gap rule in a unit test, and adds no dependency. Revisit this when a chart needs real interactivity — not before.

Running it with no data

There is no seeded database on a development machine, so the dashboard has a fixture source in the same style as the engine and onboarding adapters:
?scenario=populated|small|empty|gated switches between the four seeded shapes once it is on, and a banner says the data is seeded. The scenarios exist because the honesty contract has four states worth being able to look at: a healthy sample with one reportable movement and one suppressed one; a free-plan-shaped sample of three runs a week where nothing clears the threshold; no runs at all; and — gated — the populated rows on a plan without the competitor ranking, which is the one state that proves the gate reads as a plan boundary rather than as missing data. The gated fixture drops the competitor brands from its snapshot exactly as the Supabase source declines to select them, so it cannot show a surface the real source would have no rows for. fixtureScenarioFromEnv() requires both the variable and a non-production build, for the same reason createOnboardingGenerator does — the failure to guard against is not a typo in an env var, it is a production deployment cheerfully showing a customer someone else’s invented numbers and looking exactly like a working one.

Sources — /dashboard/sources

The same window, the same components and the same contract, in a different unit: which domains the engines actually cited, and where the customer is missing. The denominator is sourced_run_count, and there are three plausible numbers it is not. refresh_domain_daily_metrics (20260725120800_source_payload.sql) denominates every source metric on the runs that carry a durable runs.source_payload, because a run whose sources were never captured contributes to no domain’s retrieved_run_count and counting it would report every domain the answer really cited as “not retrieved”. Of the other three: run_count is the wider brand-side sample and would depress every rate; retrieval_count is the count of citations, a different and deprecated metric the rollup’s own column comment warns against dividing by; and summing the domain rows’ own copied run_count would denominate each domain on only the slices it happened to appear in, so everything would read as near-100%. Classification is three values, each read off a column that already exists. owned is the workspace’s own brand_domain (or the self brand’s), matched exactly or as a subdomain; competitor is a tracked competitor’s primary_domain whose brands.domain_status is corroborated or confirmed; third_party is everything else. The two refusals are the interesting half. An unverified domain is a model’s guess — the column comment says it is “never used for attribution, never joined to Sources”, because promoting a guess is how the French school “ECE” resolved to a German company — and a suggested brand is not part of the set the customer chose to be compared against. There is deliberately no publisher or page-type taxonomy: domains.classification exists but nothing populates it, and deriving categories from a hostname is the confident guess this product exists to refuse. The gap rule, in one sentence: a domain is a gap when, among the answers in this window that cited it, at least one named a tracked competitor and none named the customer. It is the only thing on the dashboard that cannot be read from a rollup — domain_daily_metrics carries no brand — so it is computed at run grain from run_citations and run_mentions, rows the pipeline already writes. A domain classified owned is excluded, because the customer’s own site is not a place to go and get covered; a competitor’s own domain is kept and labelled, because suppressing it would be a taxonomy judgement the reader is better placed to make. “Nothing here” has three causes and three sentences. SourcesCoverage makes the caller pick between them: no runs at all; runs that happened and whose sources were never captured; and sourced answers that were read and cited nothing. Telling a reader “no runs” about the middle case is the fabricated zero in another costume — the numbers on /dashboard for that same window are perfectly real, and only the sources are missing. An empty gap set gets the same treatment: “no competitors tracked”, “nothing measured” and “every source that named a rival also named you” are three different claims and only the last is good news. There is no ?? 0 in sources-aggregate.ts, and the mandated exception on /dashboard does not extend here. Where a domain has no rollup row for the previous window, its numerator is the sum of an empty set of rows — previousRetrievalsFor seeds its accumulator with the domains being reported on and adds rows to it — and the proportion is only built once the previous window’s sourced_run_count has been shown to be non-zero. A window with no sourced runs yields null, never 0%. Two of the reads are at row grain, which is a deliberate, bounded exception. run_citations and run_mentions are read over the current window only (nothing on the page looks at the previous one at that grain), mentions are filtered to the voice set by id, and both page with a safety bound that raises rather than returning a prefix. Truncation matters more here than anywhere else on the dashboard: a dropped mention page turns a source that named the customer into one that did not, which promotes it into the gap list — a confident recommendation to go and get covered somewhere that already covers them. They are also the expensive part of the page and nothing caches them — at the Pro ceiling on the twelve-week window that is order 150 sequential paged round-trips — and the comment at the read path says so rather than dressing it up, because capping either read is the one fix that is not available. Every safety bound is dimensioned off the table’s own grain. domain_daily_metrics is one row per (domain, engine, bucket_date), so its bound is engines × spanning buckets × a named ceiling on distinct domains per slice, not citations per run: a workspace’s few hundred distinct hostnames in one engine-bucket is ordinary, and a bound sized off the wrong dimension turns an ordinary Pro workspace into a hard error on data that is perfectly fine. Both lists show the top 25 and count the rest. DOMAINS_SHOWN and GAPS_SHOWN cut the two tables the same way, because two tables on one page should not behave differently for the same reason. A display cut and nothing more: every rate, n, interval and delta on the rows shown was computed against the whole window before the slice, the order is the aggregation’s, and the line below each list names how many domains — or how many leads — are not on screen rather than saying “more”. The seeded scenarios are worth knowing about: sources-fixture.ts derives its domain_daily_metrics rows from its seeded citations, applying the same rule as refresh_domain_daily_metrics, so the seeded domain table and the seeded URL list cannot drift apart. It also seeds both classification refusals (qonto.com as an unverified guess, indy.fr as a suggested brand’s domain) and one domain whose citation rate moves far enough for the intervals to separate, so both delta renderings are on screen at once.

Account and billing

/dashboard used to be the account page; it is now /dashboard/settings, which both Stripe redirects (success_url, return_url) point at. What a customer pays for is the measurement, so the measurement is what the signed-in landing page shows. Each route has its own loading.tsx, because the layouts do not match: /dashboard skeletons three cards, one plan-neutral band where the weekly charts go, two breakdowns and the ranking, /dashboard/sources the gap table and the cited-sources table, and /dashboard/settings the brand card plus the two-card account/billing row. The chart row is the band rather than a mirror because its column count is derived from the charts it holds and so depends on the plan, which nothing has resolved yet at that point in the render — the reasoning is in the file’s own comment, and a test pins that the placeholder carries no column count. All three pick their workspace with the same ordered query (order("created_at"), limit(1)), so a member of more than one workspace cannot see the pages disagree about which brand is on screen.

Reads only

lib/analytics/source-supabase.ts issues five plain SELECTs through the caller’s own RLS-scoped client — the fifth being the subscriptions read the competitor-ranking gate turns on — and lib/analytics/sources-supabase.ts six. The rollup tables carry member-scoped SELECT policies (20260725120500_rollups.sql), so nothing here needs the service role and nothing here needs a SECURITY DEFINER function — which is worth keeping true, since a definer function is PostgREST-reachable and would need its revoke shipped in the same migration (see supabase.md). The three list reads are paged, and an incomplete one throws. PostgREST caps a response at its max-rows (1000 on a default project) and reports the cap only in the Content-Range header, so a short page is indistinguishable from the end of the data — the same hazard loadAliases() in lib/runner/store-supabase.ts guards, and readAllPages() follows that pattern: a stable total order, .range(), an exact count, and a raise if fewer rows came back than exist or the safety bound was passed. Truncation here would not show up as a missing number: dropped brand_daily_metrics rows shrink the share-of-voice denominator, inflate the self brand’s share and reorder the ranking. A plausible wrong number is the one failure this surface exists to rule out, so it is an exception, not a shorter list.