From a5c8a42a06317fa2947b044a19a324bec9d9ed94 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 9 Jun 2026 05:13:23 +0000 Subject: [PATCH] feat: redesign analytics page Refresh the analytics marketing page around actionable deliverability metrics, realtime dashboard behavior, export surfaces, and a share-card mock that mirrors the product UI. --- site/src/components/ShareCardMock.astro | 157 ++++++++ site/src/pages/analytics.astro | 497 +++++++++++++++--------- 2 files changed, 481 insertions(+), 173 deletions(-) create mode 100644 site/src/components/ShareCardMock.astro diff --git a/site/src/components/ShareCardMock.astro b/site/src/components/ShareCardMock.astro new file mode 100644 index 00000000..dac07ac5 --- /dev/null +++ b/site/src/components/ShareCardMock.astro @@ -0,0 +1,157 @@ +--- +// Pixel-perfect static recreation of the in-app "Share image" card +// (web/src/components/app/analytics/StatsShareCard.tsx, 1:1 preset). +// +// Every dimension is expressed in container-query width units (cqw), mapped +// 1:1 from the real 1080px design (px / 1080 * 100), so the card stays exact at +// any rendered width and on any device. No JS, no fixed pixels. +interface Props { + title?: string; + subtitle?: string; + date?: string; + metrics?: { label: string; value: string; sub?: string }[]; +} +const { + title = 'Workspace performance', + subtitle = 'Last 7 days', + date = 'Jun 9, 2026', + metrics = [ + { label: 'Sent', value: '12,847', sub: 'emails' }, + { label: 'Open rate', value: '34.2%' }, + { label: 'Reply rate', value: '6.1%' }, + { label: 'Bounce rate', value: '1.8%' }, + ], +} = Astro.props; + +// Area-chart geometry — identical math to ShareAreaChart in the app. +const vals = [22, 30, 24, 38, 33, 45, 40, 29, 48, 55, 50, 58, 64, 60, 69, 76, 72, 66, 79, 86, 81, 74, 88, 94, 90, 83, 96, 100]; +const W = 1000, H = 320, padX = 4, padTop = 14; +const baseY = H - 6; +const max = Math.max(1, ...vals); +const w = W - padX * 2, h = baseY - padTop; +const step = w / (vals.length - 1); +const pts = vals.map((v, i) => [padX + i * step, baseY - (v / max) * h] as [number, number]); +const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' '); +const lastX = padX + (vals.length - 1) * step; +const area = `${line} L ${lastX.toFixed(1)} ${baseY} L ${padX} ${baseY} Z`; +const cols = metrics.length || 1; +--- +
+
+
+
+
+ +
+ +
+
+ + Warmbly +
+ {date} +
+ + +
+
+
{subtitle}
+

{title}

+ +
+ {metrics.map((m) => ( +
+
{m.label}
+
{m.value}
+ {m.sub &&
{m.sub}
} +
+ ))} +
+ +
+ +
Sends over time
+
+
+ + + + + + + + + + + +
+
+ May 13 + Jun 9 +
+
+
+
+ + +
+ warmbly.com + Cold email, warmed up. +
+
+
+ + diff --git a/site/src/pages/analytics.astro b/site/src/pages/analytics.astro index 6daa5b4e..fc9f7938 100644 --- a/site/src/pages/analytics.astro +++ b/site/src/pages/analytics.astro @@ -1,66 +1,161 @@ --- import Layout from '../layouts/Layout.astro'; import HeroAtmosphere from '../components/HeroAtmosphere.astro'; -import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; +import ShareCardMock from '../components/ShareCardMock.astro'; -// Source: CLAUDE.md analytics + admin sections, internal/repository/* +// Source anchors for everything claimed on this page: +// web/src/app/app/analytics/page.tsx (the real dashboard this mirrors) +// realtime/lib/realtime/* (the websocket fanout) +// tracking/src/handlers.rs (open/click capture + dedupe) +// internal/app/consumer/* (reply classify, deliverability) +// internal/app/advanced/service.go (suppression) +// internal/repository/pg_* (the aggregates the dashboard reads) -// What we lead with vs what we treat as noise +// ========================================================================= +// What we lead with vs what we treat as noise. +// ========================================================================= const signal = [ - { k: 'Inbox placement rate', why: 'Probed via warmup pool partners. The only number that tells you mail is actually being read.' }, - { k: 'Complaint rate', why: 'ARF feedback loops + Gmail Postmaster Tools, aggregated per mailbox. Hard ceiling at 0.10%.' }, - { k: 'Bounce rate', why: 'Hard vs soft split per mailbox. SES enforcement begins at 5%, suspension at 10%.' }, - { k: 'Reply rate', why: 'Per sequence, per step, per variant. The metric a cold-mail program is actually optimising for.' }, - { k: 'Positive-reply rate', why: 'Replies classified as positive (vs OOO, negative, unsubscribe). Strips noise from reply rate.' }, - { k: 'Suppression growth', why: 'Share of contacted recipients added to suppression per period. Spikes are a leading indicator.' }, + { k: 'Inbox placement rate', why: 'Probed through warmup pool partners. The only number that says mail is actually being read.' }, + { k: 'Reply rate', why: 'Per sequence, per step, per variant. The metric a cold program is genuinely optimising for.' }, + { k: 'Positive-reply rate', why: 'Replies classified positive, stripped of OOO, negative, and opt-out. The real one.' }, + { k: 'Complaint rate', why: 'FBL and ARF loops plus Postmaster, per mailbox. Hard ceiling at 0.10%.' }, + { k: 'Bounce rate', why: 'Hard and soft, split per mailbox. SES review at 5%, pause at 10%.' }, + { k: 'Suppression growth', why: 'Share of contacted recipients suppressed per period. A spike leads the bad news.' }, ]; const noise = [ - { k: 'Open rate', why: 'Apple Mail Privacy proxies opens for ~25-45% of B2B inboxes. Gmail caches images. The number is noise dominated.' }, - { k: 'Cold first-touch CTR', why: 'Most well-written cold mail has no links. Optimising for clicks incentivises link-heavy content, which is itself a spam-folder signal.' }, - { k: '"Deliverability score"', why: 'From vendors who will not publish the formula. Unauditable and gameable.' }, + { k: 'Open rate', why: 'Apple Mail Privacy prefetches pixels for a big share of B2B inboxes, so the number reads near 100% within minutes. We show it, we never decide on it.' }, + { k: 'First-touch click rate', why: 'Good cold mail has no links. Optimising for clicks pushes you toward link-heavy mail, which is itself a spam signal.' }, + { k: '"Deliverability score"', why: 'A single vendor number with an unpublished formula. Unauditable and easy to game.' }, ]; -// Real export surfaces +// ========================================================================= +// Drill path. Every workspace KPI resolves down to one recipient event. +// ========================================================================= +const drill = [ + { k: 'Workspace', v: 'one number', d: 'The KPI strip: sent, reply rate, bounce rate, placement.' }, + { k: 'Campaign', v: 'top performers', d: 'Which campaigns carry the result, ranked by reply rate.' }, + { k: 'Sequence', v: 'per step', d: 'Step one against the follow-ups. Where replies actually come from.' }, + { k: 'Variant', v: 'A / B', d: 'Subject and body variants, scored on positive replies.' }, + { k: 'Mailbox', v: 'per sender', d: 'The same metrics per mailbox, pool, and worker IP.' }, + { k: 'Recipient', v: 'one event', d: 'The exact open, reply, or bounce behind the number.' }, +]; + +// ========================================================================= +// What updates live, over the websocket fanout. +// ========================================================================= +const live = [ + { k: 'Counts and KPIs', d: 'Sent, replies, and bounce rate tick up the moment events arrive.' }, + { k: 'Lists', d: 'Campaign and mailbox tables reorder themselves without a refresh.' }, + { k: 'Detail panes', d: 'An open drawer keeps updating while you are reading it.' }, + { k: 'Activity feed', d: 'New opens, clicks, and replies appear at the top instantly.' }, + { k: 'Account health', d: 'A mailbox slipping into watch shows the second it crosses.' }, +]; + +// ========================================================================= +// Exactly what each headline number is, and where it comes from. +// ========================================================================= +const definitions = [ + { k: 'Reply rate', f: 'replies ÷ delivered', src: 'consumer.reply' }, + { k: 'Positive reply', f: 'positive ÷ delivered', src: 'consumer.reply.classifier' }, + { k: 'Inbox placement', f: 'inbox ÷ (inbox + spam + promo)', src: 'warmup pool probe' }, + { k: 'Complaint rate', f: 'complaints ÷ delivered', src: 'consumer.deliverability' }, + { k: 'Bounce rate', f: '(hard + soft) ÷ sent', src: 'consumer.deliverability' }, + { k: 'Open rate', f: 'opens ÷ delivered', src: 'tracking.handlers' }, +]; + +// ========================================================================= +// Real export surfaces. +// ========================================================================= const exports = [ - { k: 'On-demand download', v: 'CSV · XLSX · JSON', why: 'Pick a report, choose fields, download. No row caps.' }, - { k: 'Scheduled email', v: 'Daily · weekly', why: 'Workspace-level summary or a specific saved view to your inbox.' }, - { k: 'Webhooks', v: 'HMAC-signed', why: 'Per-event push for replies, bounces, complaints, suppression and band transitions.' }, - { k: 'REST API', v: 'GET / events', why: 'Pull events into your warehouse. Idempotency keys + rate limits surfaced in headers.' }, - { k: 'S3 push', v: 'Hourly · gzipped', why: 'Newline-delimited JSON to your bucket. Schemas versioned in the event payload.' }, + { k: 'On-demand download', v: 'CSV · XLSX · JSON', why: 'Pick a report, choose fields, download. No row caps.' }, + { k: 'Scheduled email', v: 'Daily · weekly', why: 'A workspace summary or a saved view, dropped in your inbox.' }, + { k: 'Webhooks', v: 'HMAC-signed', why: 'Per-event push for replies, bounces, complaints, suppression, and band changes.' }, + { k: 'REST API', v: 'GET / events', why: 'Pull events into your warehouse. Idempotency keys and rate limits in the headers.' }, + { k: 'S3 push', v: 'Hourly · gzipped', why: 'Newline-delimited JSON to your bucket. Schema versioned in the payload.' }, ]; const faq = [ - ['Why no open rate as a headline number?', 'Apple Mail Privacy Protection prefetches all tracking pixels through Apple proxies for a large share of B2B recipients. The reported open rate is functionally 100% for those mailboxes within minutes of delivery. We surface opens but never use them for deliverability decisions or ranking.'], - ['Where does placement data come from?', 'Warmbly\'s warmup pool double-serves as a placement probe. When a partner mailbox receives a warmup message, it reports the folder (Inbox / Promotions / Spam) along with a verification token. That feeds the placement signal for the sending mailbox.'], - ['How long is data retained?', 'Per-event records (sends, replies, opens, bounces) for 18 months by default. Aggregates retained indefinitely. Custom retention available on Enterprise.'], - ['Can I export raw events to my warehouse?', 'Yes. Webhooks for real-time push, S3 hourly drops for batched ingestion, or pull through the events API with cursor pagination. Same event schema across all three.'], + ['Why is open rate not a headline number?', + 'Apple Mail Privacy Protection prefetches every tracking pixel through Apple proxies for a large share of B2B recipients, so the reported open rate is effectively 100% for those mailboxes within minutes of delivery. We still surface opens, but we never use them for deliverability decisions or ranking.'], + ['Where does placement data come from?', + 'The warmup pool double-serves as a placement probe. When a partner mailbox receives a warmup message, it reports the folder it landed in (Inbox, Promotions, or Spam) along with a signed verification token. That feeds the placement signal for the sending mailbox.'], + ['Is everything really realtime?', + 'Yes. Opens, clicks, replies, and bounces stream into the dashboard over a websocket as the consumer processes them, so counts, lists, and the activity feed update without a refresh. Exports and rollups run on top of the same events.'], + ['How long is data retained?', + 'Per-event records (sends, replies, opens, bounces) for 18 months by default, aggregates indefinitely. Custom retention is available on Enterprise.'], + ['Can I get the raw events into my warehouse?', + 'Yes. Webhooks for realtime push, hourly gzipped S3 drops for batch ingestion, or pull through the events API with cursor pagination. The same event schema is used across all three.'], ]; ---
-
+ + + +
Analytics - Numbers we trust. Not vanity opens. + Signal, not vanity opens.

- Numbers we trust.
Not vanity opens. + Numbers you can
actually act on.

- Placement, complaints, bounces, reply rate, positive-reply rate, suppression growth. Per mailbox, per pool, per worker, per sequence. Drill from a workspace KPI to the exact recipient behind it. + Placement, reply and positive-reply rate, complaints, bounces, suppression. Every number drills from a workspace KPI to the exact recipient behind it, and the whole dashboard updates live.

@@ -70,153 +165,31 @@ const faq = [ - - Read the handbook + + What we measure
- -
-
-
- - -
-
-
Analytics
-
Deliverability across the workspace
-
-
- {['7d', '30d', '90d'].map((o) => ( - - ))} -
-
- - -
- {[ - { label: 'Total sent', value: '12,847', sub: 'last 7 days' }, - { label: 'Open rate', value: '34.2%', sub: 'after delivery' }, - { label: 'Reply rate', value: '6.1%', sub: 'incl. positive' }, - { label: 'Bounce rate', value: '1.8%', sub: 'hard + soft' }, - ].map((s, i) => ( -
-
{s.label}
-
{s.value}
-
{s.sub}
-
- ))} -
- - -
-
-
- Email performance -
-
-
- {Array.from({ length: 28 }).map((_, i) => { - const v = 22 + Math.sin(i * 0.5) * 18 + ((i * 13) % 17); - return ( -
-
-
- ); - })} -
-
- 7d ago - today -
-
-
- - -
- - - -
- {[ - { mb: 'ben@acme.com', day: 22, today: 32, cap: 40, state: 'healthy' }, - { mb: 'sara@acme.com', day: 14, today: 24, cap: 40, state: 'healthy' }, - { mb: 'mark@acme.com', day: 31, today: 40, cap: 40, state: 'watch' }, - { mb: 'kai@acme.com', day: 9, today: 19, cap: 40, state: 'healthy' }, - ].map((m) => { - const pct = Math.round((m.today / m.cap) * 100); - const chip = m.state === 'watch' ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-700'; - const dot = m.state === 'watch' ? 'bg-amber-500' : 'bg-emerald-500'; - const bar = m.state === 'watch' ? 'bg-amber-500' : 'bg-sky-500'; - return ( -
-
- {m.mb} - - - {m.state} - -
-
- {m.today} - / {m.cap} today · D{m.day} -
-
-
-
-
- ); - })} -
-
-
-
+ -
+
Signal vs noise

- We lead with six metrics. Three we ignore. + Six numbers we lead with. Three we ignore.

- A metric is a signal only if it is reliable and acts on. Open rate fails both tests today. Click rate on cold first-touch fails the second. Vendor "scores" fail the first. + A metric earns the headline only if it is reliable and you can act on it. Open rate fails the first test today. First-touch click rate fails the second. Vendor "scores" fail both.

@@ -232,7 +205,7 @@ const faq = [
    {signal.map((s) => ( -
  • +
  • {s.k}
    {s.why}
  • @@ -262,11 +235,182 @@ const faq = [
+ +
+
+ +
+
Share image
+

+ Post your numbers without a screenshot. +

+

+ One tap turns the workspace view into a branded image: your headline metrics and the sends trend on a clean Warmbly sky card. Pick an aspect, preview it exactly as it will export, then copy or download a crisp PNG. +

+
+ {[ + { k: 'Pick an aspect', d: '1:1 for social, 3:2 for slides, 16:9 for a banner.' }, + { k: 'Preview live', d: 'The card renders exactly as it exports, every metric in place.' }, + { k: 'Copy or download', d: 'Straight to the clipboard, or a high-resolution PNG to disk.' }, + ].map((s, i) => ( +
+ {String(i + 1).padStart(2, '0')} +
+
{s.k}
+

{s.d}

+
+
+ ))} +
+
+ + +
+
+
+ +
+ +
+
+ 1:1 + 3:2 + 16:9 +
+
+ + + Copy + + + + Download PNG + +
+
+
+
+
+
-
+
+
+
+
Drill down
+

+ Every number opens up. +

+

+ A KPI you cannot explain is a KPI you cannot fix. Each headline number unfolds one level at a time, from the workspace strip all the way to the single recipient event behind it. No dead ends. +

+
+ +
+
+
+ {drill.map((r, i) => ( +
+
+
+ {String(i + 1).padStart(2, '0')} +
+
+
+
+
{r.k}
+
{r.v}
+
+

{r.d}

+
+
+ ))} +
+
+
+
+ + +
+
+
+
Realtime
+

+ It updates while you watch. +

+

+ Events fan out over a websocket the moment the consumer processes them, so the dashboard stays current on its own. No refresh button, no five-minute polling lag. It feels closer to a chat app than a report. +

+ + + + + + Live by default + +
+ +
+ {live.map((l) => ( +
+ +
+
{l.k}
+

{l.d}

+
+
+ ))} +
+
+
+ + +
+
+
+
Definitions
+

+ No mystery math. +

+

+ Every metric is a plain ratio over a denominator we name, computed in a codepath you can point at. Nothing is smoothed, weighted, or hidden behind a single blended "score". +

+
+ +
+ {definitions.map((d, i) => ( +
+
+
+ {String(i + 1).padStart(2, '0')} · {d.k} +
+
{d.src}
+
+
+
{d.f}
+
+
+ ))} +
+ +

+ Mirrors the real dashboard: web/src/app/app/analytics/page.tsx +

+
+
+ + +
Exports
@@ -274,13 +418,13 @@ const faq = [ Your data is yours.

- Five ways to get the data out. Same event schema across all of them, versioned in the payload. + Five ways to get the data out, same event schema across all of them, versioned in the payload.

{exports.map((d, i) => ( -
+
{String(i + 1).padStart(2, '0')} · {d.k} @@ -297,14 +441,14 @@ const faq = [
-
+
FAQ

- Four reporting questions. + Five reporting questions.

@@ -312,8 +456,8 @@ const faq = [
@@ -345,5 +489,12 @@ const faq = [
- +