# Reports dashboard — design

**Status:** Phase 1, agreed shape. Written against a real 434-journey dataset
spanning 17 weeks, not against an idea of what marketing data looks like.

## The audience, stated plainly

A marketer who opens this once a week. They want two things and nothing else:

1. **Is what I'm doing working?** — volume, direction of travel, which efforts earn
   their budget.
2. **What should I do differently?** — something they didn't already know, that
   points at an action.

Everything below had to earn its place against those two questions. A chart that is
merely true is not enough; it has to change what someone does on Monday.

## What the data can actually support

Profiling the real dataset first killed four of the seven things I had planned to
build. That is the point of profiling first.

| Field | Coverage | Verdict |
|---|---:|---|
| channel / first_channel | 96% / 95% | **strong** — the backbone |
| visits, pages | 95% | **strong** — engagement quality |
| journey (page-level) | 100% stored, 94% populated | **strong** |
| term | 13%, **3 distinct values** | rejected |
| campaign | **1%, 2 distinct values** | rejected |
| first_page | 11% | rejected |
| interaction events | **0%** on backfilled history | rejected |

### Rejected, and why

- **Campaign table.** Three journeys carry a campaign, across two campaign names.
  The site's Google Ads is sending an unresolved `{CampaignName}` placeholder, so
  the field is empty by accident rather than by nature. A campaign table would be a
  permanently empty box on the most important tab. *This absence is itself the
  finding* — it belongs in Data Quality, not in a chart.
- **Search-term table.** 57 journeys carry a term and there are three distinct
  values, all single letters (`p`, `e`, `d`) — Google withholds real query data.
  Ranking three letters is theatre.
- **Landing-page table.** 11% coverage. Any ranking would be built on a tenth of
  the data and would read as authoritative. Deferred until on-site capture (which
  records every page view) has been running.
- **Median days-to-enquiry KPI.** The median is **0** — 79% of leads arrive and
  enquire in a single visit. A KPI that always reads "0 days" teaches nothing. The
  *interesting* version of this fact is the split itself, so it becomes a KPI:
  "enquire on first visit: 79%".

## The dashboard

Six blocks, in this order. Each is listed with the question it answers.

### 1. KPI strip — *"where are we?"*
Four numbers, each with a period-over-period delta against the immediately
preceding window of equal length:

- **Leads** — the count.
- **Top channel** — name and share.
- **Enquire on first visit** — the 79%. Falling means people need more convincing.
- **Repeat enquirers** — people with more than one journey. Real here: 27 of 407.

Deltas are the point. A bare number is a fact; a number with a direction is a
signal.

### 2. Weekly volume by channel — *"is it growing, and which part?"*
Stacked bars, one per week, top 5 channels plus "other". 17 weeks of real data
makes this legible. Hand-drawn SVG — no library, no CDN.

Weeks, not days: at ~25 leads/week, daily buckets are noise.

### 3. Channel performance — *"which efforts deserve more?"*
One row per channel:

| leads | share | avg pages | multi-visit % | assists |
|---|---|---|---|---|

**Volume alone is a trap** — it rewards whatever is biggest, not whatever is best.
The engagement columns are what let a marketer see that a small channel produces
people who read five pages before enquiring. Real example from this dataset:
Direct converts 88 leads at **5.9 pages and 48% multi-visit**, while Google Ads
converts 56 at **1.7 pages and 18%** — a materially different kind of lead, and
last-click volume alone hides it completely.

**Assists** = times this channel was the *first* touch for a lead that converted
somewhere else. This is the column that makes the next block possible.

### 4. What last-click is hiding — *"what am I about to defund by mistake?"*
The strongest thing in the dataset, and the one thing here a marketer cannot get
from their form notifications.

10% of leads (45) were discovered by one channel and converted through another.
The real paths:

```
Google Organic  →  Direct   15
Google Ads      →  Direct    6
Bing Organic    →  Direct    3
DuckDuckGo      →  Direct    2
```

Search finds these people; they come back later and convert as "Direct". Under
last-click, search gets none of the credit and Direct gets all of it. That is a
budget decision made on a false premise.

Shown as a short ranked list of first→last transitions with counts, and a plain
sentence naming the most under-credited channel.

**Honesty requirement:** this number was *fabricated at 44%* during development by
a bug that reclassified the last touch and not the first, turning same-channel
journeys into phantom switches. It is 10%. Any figure here must be derived from
both touches having been through the same classification pass.

### 5. By form — *"which form is pulling its weight?"*
Leads, avg visits, avg pages per form. Real signal here: Contact Form 273 leads at
3.1 pages vs RISE 151 at 2.1 — same site, measurably different engagement. A site
with one form gets one row and that is fine; the block hides itself below two.

### 6. Data quality — *"can I trust any of this?"*
Deliberately last, deliberately not hidden. For this dataset it would say:

- **19% of leads are attributed to your own subdomain** (`get.…`) — a self-referral
  counted as an acquisition channel.
- **17 journeys (4%) have no channel at all.**
- **Campaign names are not arriving** — 1% of journeys carry one, and the ad
  platform is sending an unresolved placeholder.

Most analytics tools present broken measurement as confident numbers. Telling
someone their ad platform isn't passing campaign names is worth more than any
chart on this page, because it is a thing they can fix this afternoon.

## Cross-cutting decisions

**Date range.** Preset windows — 7 / 30 / 90 days, this year, all time — defaulting
to 30 days. No custom pickers in v1: two more inputs, and every preset already
answers a real question. The comparison window is always the equal-length period
immediately before.

**Sparse data.** This ships to sites with 4 journeys, not 434. Rules:
- Under 10 journeys in range: KPI strip and channel table only, with a line saying
  more data is needed for trends. No chart drawn from four points.
- Any block whose underlying field has under 20% coverage hides itself and says
  why in Data Quality, rather than rendering an empty table.
- Zero journeys: one short explanation of what will appear once leads arrive.

**No interaction/event reporting in v1.** Backfilled history carries none, so every
site that imports its past would see an empty block. Revisit once on-site capture
has run long enough to make it real.

**Performance.** All six blocks come from one pass over the range's rows, decoded
once. At a few thousand journeys that is fine; beyond that the aggregate gets
cached against a key of range + row count + latest id.

## Deliberately not in v1

Conversion-rate reporting. The plugin sees journeys that *converted* — it has no
denominator of visitors who didn't. Every "conversion rate" it could print would be
a ratio of leads to leads. Doing this properly means capturing sessions that never
convert, which is a different feature with a different privacy conversation.
Printing a confident wrong number would be worse than printing nothing.
