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:
@@ -33,13 +33,16 @@ export function PageviewsChart({ data }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Merge the two series (Umami returns them separately) into one row per
|
||||
// bucket so we can drive a single chart.
|
||||
// Merge the two series (Umami returns them separately when `compare` is
|
||||
// requested) into one row per bucket so we can drive a single chart.
|
||||
// `sessions` is optional on Umami v3 — only present when the request
|
||||
// included a comparison directive. Guard the read so an undefined
|
||||
// array doesn't crash the chart.
|
||||
const byX = new Map<string, { x: string; pageviews: number; sessions: number }>();
|
||||
for (const p of data.pageviews) {
|
||||
byX.set(p.x, { x: p.x, pageviews: p.y, sessions: 0 });
|
||||
}
|
||||
for (const s of data.sessions) {
|
||||
for (const s of data.sessions ?? []) {
|
||||
const row = byX.get(s.x);
|
||||
if (row) row.sessions = s.y;
|
||||
else byX.set(s.x, { x: s.x, pageviews: 0, sessions: s.y });
|
||||
@@ -78,6 +81,7 @@ export function PageviewsChart({ data }: Props) {
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelFormatter={formatTooltipLabel}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area
|
||||
@@ -101,11 +105,24 @@ export function PageviewsChart({ data }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact tick labels: full datetime → just MM-DD or MM-DD HH:00. */
|
||||
/** Compact tick labels: drop the timestamp entirely — for multi-day ranges
|
||||
* the hour component is meaningless (a "day" bucket aggregates the whole
|
||||
* day) and just causes visual crowding. Keep MM-DD. */
|
||||
function formatXTick(value: string): string {
|
||||
// Umami can return either "YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD".
|
||||
if (value.length >= 16) {
|
||||
return value.slice(5, 16); // "MM-DD HH:mm"
|
||||
}
|
||||
return value.slice(5); // "MM-DD"
|
||||
return value.slice(5, 10); // "MM-DD"
|
||||
}
|
||||
|
||||
/** Tooltip header: format "2026-03-30 00:00:00" → "Mar 30, 2026" so the
|
||||
* meaningless 00:00:00 timestamp doesn't show. */
|
||||
function formatTooltipLabel(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const datePart = value.slice(0, 10); // "YYYY-MM-DD"
|
||||
const d = new Date(`${datePart}T00:00:00Z`);
|
||||
if (isNaN(d.getTime())) return datePart;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user