feat(tier2): host analytics — Block E

GET /events/{id}/analytics renders a viewer+ dashboard summary:
overview tiles, 30-day response sparkline, invited→opened→responded
funnel, time-to-respond histogram, plus-ones distribution, channel
attribution (utm_source), and stale-guest follow-up list. A
matching /analytics/export.csv hand-offs a flat per-guest table for
Excel + Numbers.

Backend
- Migration 0009 adds tokens.utm_source for source attribution
- AnalyticsRepo with six aggregation queries — all event-scoped, all
  returning canonical-ordered series so empty buckets still render
- Redis 60s cache in the handler, keyed by (event_id, days). Cache
  miss path returns X-Cache: miss; hit returns the JSON straight
  from Redis without re-querying Postgres
- Time-to-respond uses (rsvp.submitted_at - token.created_at) as
  the latency signal (no separate "invitation sent" timestamp yet —
  Block F will add one)

Frontend
- AnalyticsCard.vue: inline SVG sparkline + Tailwind bar charts.
  No chart.js dependency; the bundle stays lean
- Stale-guests list with opened-but-no-response highlighted
- Export CSV button issues an authed fetch + blob download

Tests
- TestAnalyticsAggregations seeds 5 guests with a known mix and
  asserts every count (overview/funnel/plus-ones/time-to-respond/
  stale) matches expected
- TestAnalyticsCSVExport: header row + per-guest rows parse cleanly
- TestAnalyticsAuthzMatrix: viewer 200, outsider 404 on both endpoints
- Full integration suite passes (109.9s)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-17 23:11:13 +01:00
parent 3973e4058d
commit 9842bd4f45
8 changed files with 1150 additions and 0 deletions
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
// Tier 2 Block E — host analytics dashboard card. Shown on the event
// detail page; viewer+ role.
//
// Charts are inline SVG rather than chart.js — we avoid the ~70kB bundle
// hit for charts this small, and keep the page server-renderable. If the
// host's expectations grow (zoom, tooltips, animations) chart.js becomes
// the right call.
interface Overview {
invited: number
attending: number
declined: number
maybe: number
pending: number
plus_ones_total: number
}
interface AnalyticsPayload {
overview: Overview
response_rate: { date: string; count: number }[]
funnel: { invited: number; opened: number; responded: number }
time_to_respond: { label: string; count: number }[]
plus_ones: { label: string; count: number }[]
channels: { source: string; invited: number; attending: number }[]
stale_guests: {
guest_id: string
name: string
email?: string | null
has_opened: boolean
invited_at: string
}[]
generated_at: string
}
const props = defineProps<{ eventId: string }>()
const config = useRuntimeConfig()
const auth = useAuth()
const data = ref<AnalyticsPayload | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
async function refresh() {
loading.value = true
error.value = null
try {
data.value = await useApi<AnalyticsPayload>(`/events/${props.eventId}/analytics?days=30`)
} catch (e: any) {
error.value = useErrMessage(e, 'Could not load analytics')
} finally {
loading.value = false
}
}
onMounted(refresh)
const responseRateMax = computed(() => {
const series = data.value?.response_rate ?? []
return Math.max(1, ...series.map((p) => p.count))
})
const plusOnesMax = computed(() => {
const series = data.value?.plus_ones ?? []
return Math.max(1, ...series.map((p) => p.count))
})
const ttrMax = computed(() => {
const series = data.value?.time_to_respond ?? []
return Math.max(1, ...series.map((p) => p.count))
})
const funnelPct = computed(() => {
const f = data.value?.funnel
if (!f || f.invited === 0) return { opened: 0, responded: 0 }
return {
opened: Math.round((f.opened / f.invited) * 100),
responded: Math.round((f.responded / f.invited) * 100),
}
})
const responseRateLineD = computed(() => {
const series = data.value?.response_rate ?? []
if (series.length === 0) return ''
const w = 600
const h = 80
const max = responseRateMax.value
const step = w / Math.max(1, series.length - 1)
return series
.map((p, i) => {
const x = i * step
const y = h - (p.count / max) * h
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
})
function exportCSV() {
// Issue a fresh ws-ticket-style download. Browsers preserve the
// Authorization header on <a download> if we set it via fetch, so we
// use fetch + blob + anchor.
const url = `${config.public.apiBase}/events/${props.eventId}/analytics/export.csv`
fetch(url, {
headers: auth.liveAccessToken() ? { Authorization: `Bearer ${auth.liveAccessToken()}` } : {},
credentials: 'include',
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.blob()
})
.then((blob) => {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'analytics.csv'
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 1000)
})
.catch((e) => {
error.value = 'Could not export: ' + String(e)
})
}
function fmtDate(iso?: string | null) {
if (!iso) return ''
try { return new Date(iso).toLocaleDateString() } catch { return iso }
}
</script>
<template>
<section class="card">
<header class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-semibold">Analytics</h2>
<button class="btn-ghost text-sm" :disabled="!data" @click="exportCSV">Export CSV</button>
</header>
<p v-if="loading" class="text-sm text-zinc-500">Loading analytics</p>
<p v-else-if="error" class="text-sm text-red-400">{{ error }}</p>
<div v-else-if="data" class="space-y-6">
<!-- Overview tiles -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
<p class="text-xs uppercase tracking-wider text-zinc-500">Invited</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ data.overview.invited }}</p>
</div>
<div class="rounded-md border border-brand-900/60 bg-brand-950/20 p-3">
<p class="text-xs uppercase tracking-wider text-brand-400">Attending</p>
<p class="mt-1 text-2xl font-semibold tabular-nums text-brand-200">{{ data.overview.attending }}</p>
</div>
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
<p class="text-xs uppercase tracking-wider text-zinc-500">Declined</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ data.overview.declined }}</p>
</div>
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
<p class="text-xs uppercase tracking-wider text-zinc-500">Maybe</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ data.overview.maybe }}</p>
</div>
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
<p class="text-xs uppercase tracking-wider text-zinc-500">Pending</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ data.overview.pending }}</p>
</div>
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-3">
<p class="text-xs uppercase tracking-wider text-zinc-500">Plus-ones</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">+{{ data.overview.plus_ones_total }}</p>
</div>
</div>
<!-- Response rate over time -->
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Responses, last 30 days</p>
<svg viewBox="0 0 600 80" preserveAspectRatio="none" class="h-20 w-full">
<path :d="responseRateLineD" fill="none" stroke="#22c55e" stroke-width="1.5" />
</svg>
</div>
<!-- Funnel -->
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Funnel</p>
<div class="space-y-1.5">
<div class="flex items-center gap-3">
<span class="w-24 shrink-0 text-xs text-zinc-400">Invited</span>
<div class="h-6 flex-1 overflow-hidden rounded bg-zinc-900">
<div class="h-full rounded bg-brand-500" :style="{ width: '100%' }"></div>
</div>
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ data.funnel.invited }}</span>
</div>
<div class="flex items-center gap-3">
<span class="w-24 shrink-0 text-xs text-zinc-400">Opened</span>
<div class="h-6 flex-1 overflow-hidden rounded bg-zinc-900">
<div class="h-full rounded bg-brand-400" :style="{ width: funnelPct.opened + '%' }"></div>
</div>
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ data.funnel.opened }}</span>
</div>
<div class="flex items-center gap-3">
<span class="w-24 shrink-0 text-xs text-zinc-400">Responded</span>
<div class="h-6 flex-1 overflow-hidden rounded bg-zinc-900">
<div class="h-full rounded bg-brand-300" :style="{ width: funnelPct.responded + '%' }"></div>
</div>
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ data.funnel.responded }}</span>
</div>
</div>
</div>
<!-- Time-to-respond + plus-ones side by side -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Time to respond</p>
<div class="space-y-1.5">
<div v-for="b in data.time_to_respond" :key="b.label" class="flex items-center gap-3">
<span class="w-12 shrink-0 text-xs text-zinc-400">{{ b.label }}</span>
<div class="h-4 flex-1 overflow-hidden rounded bg-zinc-900">
<div class="h-full rounded bg-brand-500" :style="{ width: ((b.count / ttrMax) * 100) + '%' }"></div>
</div>
<span class="w-8 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ b.count }}</span>
</div>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Plus-ones (attending)</p>
<div class="space-y-1.5">
<div v-for="b in data.plus_ones" :key="b.label" class="flex items-center gap-3">
<span class="w-12 shrink-0 text-xs text-zinc-400">{{ b.label }}</span>
<div class="h-4 flex-1 overflow-hidden rounded bg-zinc-900">
<div class="h-full rounded bg-brand-500" :style="{ width: ((b.count / plusOnesMax) * 100) + '%' }"></div>
</div>
<span class="w-8 shrink-0 text-right text-xs tabular-nums text-zinc-300">{{ b.count }}</span>
</div>
</div>
</div>
</div>
<!-- Stale guests -->
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">
Haven't responded yet ({{ data.stale_guests.length }})
</p>
<p v-if="data.stale_guests.length === 0" class="text-sm text-zinc-500">
Everyone you've invited has replied. 🎉
</p>
<ul v-else class="space-y-1.5">
<li
v-for="g in data.stale_guests.slice(0, 10)"
:key="g.guest_id"
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm"
>
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-zinc-100">{{ g.name }}</div>
<div class="truncate text-xs text-zinc-500">
{{ g.email || 'no email' }} · invited {{ fmtDate(g.invited_at) }}
<span v-if="g.has_opened" class="ml-1 text-amber-400">opened</span>
</div>
</div>
</li>
</ul>
<p v-if="data.stale_guests.length > 10" class="mt-2 text-xs text-zinc-500">
and {{ data.stale_guests.length - 10 }} more export the CSV for the full list.
</p>
</div>
</div>
</section>
</template>
+5
View File
@@ -1120,6 +1120,11 @@ function checkLabel(band?: string): string {
</aside>
</div>
<!-- Analytics (Tier 2 Block E). Read-only summary; viewer+ can see it. -->
<div v-if="event" class="mt-6">
<AnalyticsCard :event-id="eventId" />
</div>
<!-- Team (Tier 2 Block C). Visible to anyone with viewer+ access; action
buttons gated to owners. -->
<div v-if="event" class="mt-6">