feat(analytics): Umami website-analytics suite — world map, realtime, sessions, heatmap, pixel tracking, tracked links
Adds the read-side Umami integration queued in last week's website-analytics plan (Phases 1–6 of `docs/website-analytics-flesh-out-plan.md`): - Realtime panel polls Umami at 5s intervals; world map renders visitor origins via echarts + `public/world-map/echarts-world.json` topo. - Sessions list + session-detail-sheet drill-down (per-session event timeline pulled from `/api/v1/website-analytics`). - Weekly heatmap (day-of-week × hour-of-day) for engagement timing. - Metric-detail pages under `/[portSlug]/website-analytics/[metric]` for pageviews / referrers / events deep-dives. - Email-pixel write path: `/api/public/email-pixel/[sendId]` 1×1 GIF beacon backed by `email_open_tracking` (migration 0076); resolves inline on render in inbox. - Tracked-link redirect: `/q/[slug]` routes through `tracked_links` (migration 0077) and forwards to the canonical destination after logging the click. - Dashboard `website-glance-tile` now reads from the live Umami service instead of placeholder data. Deps: `@umami/node`, `echarts`, `echarts-for-react`, `@types/geojson`, `@types/topojson-client`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
245
src/components/website-analytics/realtime-panel.tsx
Normal file
245
src/components/website-analytics/realtime-panel.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Realtime panel — Umami's "what's happening RIGHT NOW" view, surfaced
|
||||
* as a collapsible card at the top of the website-analytics page.
|
||||
*
|
||||
* Folds in five things from Umami's /api/realtime/<id> endpoint:
|
||||
* - Totals strip (visitors / views / events / countries in the last 30m)
|
||||
* - Top URLs being viewed
|
||||
* - Top countries
|
||||
* - Top referrers
|
||||
* - Recent event stream (pageviews + named events as they arrive)
|
||||
*
|
||||
* Polling pauses when the card is collapsed so we're not hammering
|
||||
* Umami at 5 s intervals while no one is looking.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Globe, Activity, MapPin, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { useUmamiRealtime } from './use-website-analytics';
|
||||
|
||||
export function RealtimePanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const query = useUmamiRealtime(open);
|
||||
const data = query.data?.data ?? null;
|
||||
|
||||
// Hide the entire bar when Umami reports a quiet 30-minute window —
|
||||
// a "Live activity (0 visitors)" header is just noise. We still poll
|
||||
// every 60 s while hidden so the bar reappears the moment traffic
|
||||
// arrives.
|
||||
const isQuiet = !!data && data.totals.visitors === 0 && data.events.length === 0;
|
||||
if (isQuiet && !open) return null;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-muted/40 sm:px-5"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Live activity</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{open
|
||||
? 'Auto-refreshing every 5s · last 30 minutes'
|
||||
: 'Click to expand — top pages, countries, and a live event stream'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{open ? (
|
||||
<ChevronUp className="size-4 text-muted-foreground" aria-hidden />
|
||||
) : (
|
||||
<ChevronDown className="size-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<CardContent className="border-t border-border pt-4 sm:pt-6">
|
||||
{query.isLoading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : !data ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No realtime data available.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Totals strip */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label="Visitors" value={data.totals.visitors} />
|
||||
<Stat label="Pageviews" value={data.totals.views} />
|
||||
<Stat label="Events" value={data.totals.events} />
|
||||
<Stat label="Countries" value={data.totals.countries} />
|
||||
</div>
|
||||
|
||||
{/* Three-column ranked-list strip */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<RankList
|
||||
icon={<ExternalLink className="size-3.5" aria-hidden />}
|
||||
title="Top pages right now"
|
||||
rows={recordToRows(data.urls).map((r) => ({
|
||||
...r,
|
||||
label: r.label === '/' ? 'Homepage' : r.label,
|
||||
}))}
|
||||
emptyLabel="No pageviews yet"
|
||||
/>
|
||||
<RankList
|
||||
icon={<MapPin className="size-3.5" aria-hidden />}
|
||||
title="Top countries"
|
||||
rows={recordToRows(data.countries).map((r) => ({
|
||||
...r,
|
||||
label: getCountryName(r.label, 'en') || r.label || 'Unknown',
|
||||
}))}
|
||||
emptyLabel="No country data yet"
|
||||
/>
|
||||
<RankList
|
||||
icon={<Globe className="size-3.5" aria-hidden />}
|
||||
title="Top referrers"
|
||||
rows={recordToRows(data.referrers).map((r) => ({
|
||||
...r,
|
||||
label: r.label || '(direct)',
|
||||
}))}
|
||||
emptyLabel="No referrers yet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent event stream */}
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||
<Activity className="size-3.5 text-muted-foreground" aria-hidden />
|
||||
Recent activity
|
||||
</h3>
|
||||
{data.events.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border py-6 text-center text-xs text-muted-foreground">
|
||||
No events in the last 30 minutes.
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-1.5 text-xs">
|
||||
{data.events.slice(0, 20).map((ev, i) => (
|
||||
<li
|
||||
key={`${ev.createdAt}-${i}`}
|
||||
className="flex items-baseline justify-between gap-2 rounded-md bg-muted/40 px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
<span className="font-medium">
|
||||
{ev.eventName
|
||||
? `Event: ${ev.eventName}`
|
||||
: !ev.urlPath || ev.urlPath === '/'
|
||||
? 'Homepage'
|
||||
: ev.urlPath}
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{[ev.country && getCountryName(ev.country, 'en'), ev.browser, ev.device]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{fmtAgo(ev.createdAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xl font-semibold tabular-nums">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RankRow {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function RankList({
|
||||
icon,
|
||||
title,
|
||||
rows,
|
||||
emptyLabel,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
rows: RankRow[];
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const max = rows[0]?.value ?? 1;
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{icon}
|
||||
{title}
|
||||
</h3>
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border py-3 text-center text-xs text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1 text-xs">
|
||||
{rows.slice(0, 5).map((r) => {
|
||||
const pct = (r.value / max) * 100;
|
||||
return (
|
||||
<li key={r.label}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{r.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{r.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 h-1 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1 rounded-full bg-brand"
|
||||
style={{ width: `${Math.max(2, pct)}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function recordToRows(rec: Record<string, number>): RankRow[] {
|
||||
return Object.entries(rec)
|
||||
.map(([label, value]) => ({ label, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
function fmtAgo(iso: string): string {
|
||||
const t = new Date(iso).getTime();
|
||||
if (isNaN(t)) return iso;
|
||||
const diff = Date.now() - t;
|
||||
const seconds = Math.max(1, Math.round(diff / 1000));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
Reference in New Issue
Block a user