Files
pn-new-crm/src/components/website-analytics/realtime-panel.tsx
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

256 lines
9.5 KiB
TypeScript

'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 { CountryFlag } from '@/components/shared/country-flag';
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 inline-flex items-center gap-1.5 text-muted-foreground">
{ev.country ? (
<CountryFlag code={ev.country} className="h-2.5 w-3.5" decorative />
) : null}
<span>
{[
ev.country && getCountryName(ev.country, 'en'),
ev.browser,
ev.device,
]
.filter(Boolean)
.join(' · ')}
</span>
</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`;
}