9842bd4f45
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>
262 lines
10 KiB
Vue
262 lines
10 KiB
Vue
<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>
|