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:
@@ -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>
|
||||
Reference in New Issue
Block a user