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
256 lines
9.5 KiB
TypeScript
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`;
|
|
}
|