> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tryrekkal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Dashboard

# 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](./analytics-pipeline.md); the plan quotas and cadence are in [measurement.md](./measurement.md).

## The shape of it

```
app/dashboard/page.tsx              app/dashboard/sources/page.tsx
        │                                    │
        ▼                                    ▼
lib/analytics/load.ts        loadDashboardView() / loadSourcesView()
        │                    resolves the workspace, picks the source
   ┌────┴─────────────┐            ┌─────────┴──────────┐
   │                  │            │                    │
source-supabase.ts   fixture.ts   sources-supabase.ts  sources-fixture.ts
(plain RLS selects)  (seeded)     (plain RLS selects)  (seeded)
   └────┬─────────────┘            └─────────┬──────────┘
        ▼                                    ▼
aggregate.ts                         sources-aggregate.ts
buildDashboardAnalytics()            buildSourcesAnalytics()
        │                                    │
        ▼                                    ▼
components/dashboard/*       cards, SVG charts, breakdowns, ranking,
                             domain table, gap table
```

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:

| Measure          | Numerator / denominator                                     | Unit of `n`    | Source                                            |
| ---------------- | ----------------------------------------------------------- | -------------- | ------------------------------------------------- |
| Visibility       | `mention_run_count` / `run_count`                           | run            | `brand_daily_metrics` over `workspace_daily_runs` |
| Share of voice   | brand `mention_run_count` / tracked-set `mention_run_count` | answer mention | `brand_daily_metrics`                             |
| Average position | `position_sum` / `position_count`                           | ranked answer  | `brand_daily_metrics`                             |

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](./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`](../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:

```sh theme={null}
REKKAL_DASHBOARD_FIXTURE=1 pnpm dev      # populated
```

`?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.

| Measure                      | Numerator / denominator                                | Unit of `n`    | Source                                             |
| ---------------------------- | ------------------------------------------------------ | -------------- | -------------------------------------------------- |
| Citation rate                | `retrieved_run_count` / `sourced_run_count`            | sourced answer | `domain_daily_metrics` over `workspace_daily_runs` |
| Competitor share of a source | citing answers naming a tracked rival / citing answers | citing answer  | `run_citations` × `run_mentions`                   |

**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 `SELECT`s 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](./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.
