Module kit / Analytics and tracking
Tracking Dashboard
analytics-tracking/ui/tracking-dashboardlive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | prod |
| Reusable | extract |
| Design system | alchemy-ui |
| Stack | react-19, react-router-7, tailwind-3.4, alchemy-ui, recharts-2, axios-1 |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton |
| Renders | analytics-tracking/event-ingestion-and-sessions, analytics-tracking/visitor-identification-and-session-merging, analytics-tracking/conversion-events-and-consent-gate, analytics-tracking/tracking-settings-and-site-registration, analytics-tracking/tracking-insights-dashboard, analytics-tracking/lead-behavioral-insights-and-predictions |
| Entry | alchemy_labs/frontend/src/pages/TrackingDashboard.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
The customer-facing home of Scribe, Labs' first-party analytics: a tenant opens /tracking and sees, on one screen, their session and event totals, a time-series and hourly/daily breakdown of visits, where traffic comes from, which campaigns and landing pages convert, a row-by-row session list they can expand into that session's own events, an AI-generated read of the same period (funnel, engagement, device mix, plain-language recommendations), an ad-hoc query and funnel builder over the raw event store, and the copyable script tag that turns tracking on in the first place. The invariant that makes it trustworthy: every number on the page is a straight read of one of six GET /api/scribe/* routes, each scoped server-side to the site(s) the signed-in user is actually allowed to see (require_site_id_or_admin); nothing here is computed client-side from data the tenant does not already have a right to.
It makes visible the six backend recipes it renders on top of: the beacon ingest and session bookkeeping (event-ingestion-and-sessions), fingerprint-based visitor identification and session merging (visitor-identification-and-session-merging), the consent gate and the events it forwards to ad platforms (conversion-events-and-consent-gate), the site registry and pixel-id config the Setup tab reads (tracking-settings-and-site-registration), the aggregate stats/timeseries/sessions/campaigns/landing-pages/insights reads the Data and Insights tabs render (tracking-insights-dashboard), and the lead-scoring and prediction layer the Explore tab's query schema can reach (lead-behavioral-insights-and-predictions); none of those six write anywhere a tenant can see except through this page's own reads.
When to use
Trigger phrases: "the analytics page", "the tracking dashboard", "my Scribe data", "why is my tracking empty", "install the tracking script", "the Explore tab", "query builder over events", "the funnel builder", "Kiosk" is the wrong page (that is billing-credits/ui/subscription-manager, a different domain entirely, both reached from the same left rail but unrelated). Surface: SPA, a private Labs page behind PrivateRoute, reached at /tracking (App.tsx line 460, nav.tsx "Insights" domain, paths: ['/tracking'], line 190 to 203 at 62ba4fc).
Not this recipe: /tracking/settings (TrackingSettings.tsx, the pixel-id and site-registration form this page's "Einstellungen"/"Konfigurieren" buttons open, itself the frontend for tracking-settings-and-site-registration), /tracking/tester (a manual event-firing tool, unrelated to this recipe), /admin/tracking (the cross-tenant admin console, a different page under a different domain gate), the six backend recipes this page is a window onto (backend_modules above, none of which a tenant reaches directly), and the contact-journey/lead-timeline/lead-score panels (get_contact_journey, get_lead_timeline, get_lead_score_breakdown, tracking_router.py 1710 to 2154) which exist in the backend but have no surface on this page at all; the page fetches recent_contacts (TrackingStats) and never renders it (Gotchas).
Stack assumptions
S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react 19, react-router-dom 7, tailwindcss 3.4, lucide-react, recharts 2 (the four chart primitives this page and ScribeExplore's siblings use, not one of the four packages a demo may import from, Frontend), axios 1. Component library: ../components/alchemy-ui (the same re-export subscription-manager's demo documents importing the equivalent of, @prima-modules/alchemy-ui here), of which this page uses Button, Heading, Text, Card, Badge, Divider; AlchemyIcon (a page-local SVG mark, not part of any package a demo may import from, substituted with AZSymbol below, Frontend). Data-fetch style: the api singleton (S2), the Bearer header from accessToken in localStorage, same as subscription-manager. Copy language: German, de-CH, every string on the page including every fallback sentence; no English leak here, unlike subscription-manager's voucher message (that recipe's Gotchas).
Data model
Client state (TrackingDashboard.tsx lines 242 to 265), fifteen useState hooks in the page component plus ScribeExplore's own six, nothing persisted beyond S2's two auth tokens:
interface TrackingStats {
totals: { visitors: number; contacts: number; events: number };
event_counts: Record<string, number>; // fetched, typed, never rendered (Gotchas)
page_views: Record<string, number>; // fetched, typed, never rendered (Gotchas)
recent_events: Array<{...}>; // fetched, typed, never rendered (Gotchas)
recent_contacts: Array<{...}>; // fetched, typed, never rendered (Gotchas)
}
interface DashboardInsights {
summary: { total_sessions, unique_visitors, identified_contacts, total_events, page_views,
form_submissions, identification_rate, avg_events_per_session,
avg_session_duration, avg_session_duration_formatted, bounce_rate };
traffic_sources: { sources: Array<{source, medium, label, sessions, channel}>; channels: Array<{channel, sessions}> };
top_pages: Array<{ page: string; views: number }>; // fetched, typed, never rendered (Gotchas)
engagement_distribution: { counts: {high,medium,low}; percentages: {high,medium,low} };
conversion_funnel: { stages: Array<{name, count, percentage}> };
device_breakdown: { counts: {mobile,tablet,desktop}; percentages: {mobile,tablet,desktop} };
trends: { current_period, previous_period, change_percent, trend: 'up'|'down'|'stable' };
insights: Array<{ type: 'success'|'warning'|'info'; title; message; metric?; action? }>;
}
interface TimeSeriesData { series: Array<{date, events, sessions, page_views, contacts}>; hourly: Array<{hour,label,count}>; daily: Array<{dow,label,count}>; granularity; days }
interface SessionItem { id, anonymous_id, source_label, landing_page, pages_viewed, duration_seconds, duration_formatted, status: 'identified'|'anonymous', contact_email, utm_campaign, has_gclid, has_fbclid, has_li_fat_id, first_seen, last_seen }
interface CampaignItem { campaign, sessions, unique_visitors, identified_contacts, conversion_rate, bounce_rate, has_gclid_count, has_fbclid_count, has_li_fat_id_count }
interface LandingPageItem { landing_page, sessions, bounce_rate, avg_duration_seconds, avg_duration_formatted, identified_count }
activeTab: 'data'|'setup'|'insights'|'explore'; loading: boolean (init true); refreshing: boolean;
trackingSiteId: string; pixelIds: Record<string,string>; // pixelIds set, never read again (Gotchas)
dateRange: 1|7|14|30|90; showDatePicker: boolean;
sessions: SessionItem[]; sessionsTotal: number; sessionsPage: number;
expandedSession: string|null; sessionEvents: Record<string, SessionEvent[]>;
campaigns: CampaignItem[]; landingPages: LandingPageItem[]; copiedId: string|null;
The demo's props (demo.tsx): { viewer, workspace, activeTab, loading?, refreshing?, trackingSiteId?, dateRange?, stats, insights, timeseries, sessions?, sessionsTotal?, sessionsPage?, sessionExpandedId?, sessionEventsById?, campaigns?, landingPages? }; activeTab seeds a local useState the way subscription-manager's demo seeds voucherCode, since the source drives the initial tab with two effects a static demo cannot run (Application logic).
Browser storage: nothing of its own beyond S2's accessToken/refreshToken. currentWorkspaceId, if set, is attached by the API client's interceptor; every route below derives its scope from require_site_id_or_admin(current_user) instead, not from the workspace header (Security and tenancy).
API surface
Contracts consumed: one config read, six GET reads, and (inside ScribeExplore, not walked in depth here) a query/funnel/export surface on /api/scribe/query, /funnel and /segments/export.
| Call (line) | Route | Auth / tenancy | Notes |
|---|---|---|---|
api.get('/api/scribe/config') (275) | GET /api/scribe/config (tracking_router.py 270 to 314) | Depends(get_current_user), WorkspaceID | site_id here is minted from projects.tracking_site_id on first call, a second, older mechanism that predates and does not consult the tracking_sites registry tracking-settings-and-site-registration otherwise documents (that recipe's API surface); the five pixel-id fields are wrapped in a bare try/except, silently omitted on any failure |
api.get('/api/scribe/stats') (304) | GET /api/scribe/stats (1680 to 1706) | Depends(get_current_user) via require_site_id_or_admin | hourly-warm-cache aggregate merged with a short-TTL live feed; non-admin scoped to the caller's own site |
api.get('/api/scribe/insights/dashboard?days=') (305) | GET /api/scribe/insights/dashboard (2890 to 2921) | same | run inside the same Promise.all as /stats but wrapped in its own .catch(() => ({ data: null })) (line 305), so this one call failing never blocks /stats from populating (error state below) |
api.get('/api/scribe/timeseries?days=&granularity=') (294) | GET /api/scribe/timeseries (2408 to 2452) | same | granularity chosen client-side from days (hour at 1 day, day up to 30, week beyond); server clamps days 1 to 365 |
api.get('/api/scribe/sessions?days=&page=&page_size=15') (326) | GET /api/scribe/sessions (2454 to 2593) | same | page_size fixed at 15 client-side; server allows 1 to 100 |
api.get('/api/scribe/sessions/{id}/events') (338) | GET /api/scribe/sessions/{session_id}/events (2594 to 2643) | same | only fetched once per session id (if (sessionEvents[sessionId]) return, line 336), never refetched on a second expand |
api.get('/api/scribe/campaigns?days=') (347) | GET /api/scribe/campaigns (2644 to 2738) | same | |
api.get('/api/scribe/landing-pages?days=') (356) | GET /api/scribe/landing-pages (2739 to 2823) | same | |
api.delete('/api/scribe/contact/{email}') (421) | DELETE /api/scribe/contact/{email} (1988 to 2075) | same | deleteContact is declared and calls this on confirm, but no button in the JSX calls deleteContact; a dead function, not a dead route (Gotchas) |
Error bodies: none of the eight calls above has a bespoke catch that surfaces server text to the screen; every catch block is console.error(...) only (fetchTrackingConfig, fetchStats, fetchTimeseries, fetchSessions, fetchSessionEvents, fetchCampaigns, fetchLandingPages, lines 286 to 288, 316 to 317, 296 to 297, 330 to 331, 340 to 341, 349 to 350, 358 to 359), so a refusal from any of these seven reads is silent to the tenant; the UI simply keeps whatever it last had (or its own empty/loading look), never a red error card the way subscription-manager renders one. deleteContact's own catch is the one exception, an alert('Fehler beim Löschen des Kontakts'); reachable only by calling the function from a console or a future button, since no current button does (API surface, Gotchas). No WebSocket, no postMessage from this page.
Application logic
TrackingDashboardmounts withloading: true,activeTab: 'data'; oneuseEffect(363 to 372) fires all six independent reads (fetchTrackingConfig,fetchStats,fetchTimeseries,fetchSessions,fetchCampaigns,fetchLandingPages) plus a 60-secondsetInterval(fetchStats, 60000)that re-polls/statsand/insights/dashboardfor the life of the mount. Whileloading, the header and tab bar are already on screen; only the Data tab's body is a single spinner card (686 to 691).fetchStats(301 to 322) runs/statsand/insights/dashboardin onePromise.all, but the second call is wrapped in its own.catch(() => ({ data: null }))(305): a refusal or throw from/insights/dashboardalone never rejects thePromise.alland never touchesstats. If/statsitself throws, the wholetryblock'scatchfires and neitherstatsnorinsightsis touched (316 to 317);loading/refreshingstill resolve tofalsein thefinally(318 to 321), so a total/statsfailure renders identically to a genuinely empty workspace's Data tab (!hasData, sincestatsis falsy either way), except it does not trigger the setup-tab redirect below, because that effect's guard isif (stats && ...);statsstaysnull, not an{totals: {events: 0}}object, so the guard is false andactiveTabnever moves off wherever it already was. There is no dedicated error UI anywhere in this page (Gotchas): a total read failure and a fresh, correctly-loading empty workspace both land on the same "Noch keine Daten" card, distinguishable only by whether the Setup tab ever got surfaced automatically.- Two more effects derive the tab:
if (stats && stats.totals.events === 0) setActiveTab('setup')(382 to 386) surfaces install instructions the moment a real (non-null) stats object reports zero events; the inverse, insidefetchStatsitself (313 to 315),if (statsRes.data.totals.events > 0 && activeTab === 'setup') setActiveTab('data'), only fires on a fresh stats response with events now present, so a tenant who manually switches todatawhile still at zero events is not fought back tosetupby the 60-second poll; that guard only re-triggers off a stats response, not offactiveTabchanging on its own. dateRangechanging (374 to 380) refetchestimeseries,stats+insights,sessions(reset to page 1) and both tables; it does not refetchtrackingSiteId(config is fetched once, on mount, only).handleRefresh(388 to 395) is the same five-call batch as thedateRangeeffect, manually triggered, withrefreshingdriving the header button's spin.- Session expand/collapse (
toggleSessionExpand, 397 to 404): collapsing never discards the fetched events (sessionEventskeyed by id, kept for the life of the mount); expanding a session already insessionEventsfetches nothing (336). copyCode(410 to 414) writes to the clipboard and flipscopiedIdfor 2 seconds, used by the site-ID row and both Setup-tab code blocks;formatRelativeTime(492 to 505) is used everywhere a session or event timestamp shows on the Data tab, falling back to the fixedformatTimestamppast 7 days.ScribeExplore(its own file) fetchesgetScribeQuerySchema()once on mount, independent of everything above; its ownmodetoggle (query/funnel) and itshandleRun/handleExportCsvare entirely local to the Explore tab and untouched bydateRange,activeTabswitching away and back, or the 60-second stats poll.
Invariants
- Every number on the page is a server read, never a client computation: the metric cards, the charts, the tables and the session list are all direct fields off one of the seven responses above, and the only client-side arithmetic is display formatting (
formatDuration,formatGrams-style truncation of paths) and the twouseMemos that pick the single largest hourly/daily bucket to caption as "Hauptzeit"/"Aktivster Tag" (549 to 559). - Tenancy is enforced once, server-side, per call: every one of the eight routes this page calls derives its scope from
require_site_id_or_admin(current_user, site_id), never from a workspace id the client supplies; a non-admin'ssite_idquery param, if the client ever sent one, is not trusted over the server's own resolution (tracking_router.py177 to 209, referenced here, walked inevent-ingestion-and-sessions's Security and tenancy). - A read failing degrades its own section, never the page: the independent
.catchon/insights/dashboard(Application logic step 2) and the plainconsole.erroron the other six reads mean no single failed call can blank a section that has its own successful data, and no failed call ever surfaces a red banner (unlike subscription-manager's top-level error card); the tradeoff is that nothing tells the tenant a call failed at all (Gotchas). - The empty-workspace redirect only fires off a genuinely resolved, event-less stats object, never off a
nullone: the effect'sstats &&guard (Application logic step 3) is what keeps a total fetch failure from being silently relabelled as "you have no data yet". - A session's events are fetched at most once per mount: the
sessionEvents[sessionId]short-circuit (Application logic step 6) means a stale expand-then-collapse-then-expand never re-hits the network, even across adateRangechange (session ids are not invalidated by that effect).
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
default server rendered, hydrated in view loading server rendered, hydrated in view empty server rendered, hydrated in view error server rendered, hydrated in view setup server rendered, hydrated in view analytics-tracking/ui/tracking-dashboard/demo.tsx
/**
* Live demo for analytics-tracking/ui/tracking-dashboard.
*
* Labs' TrackingDashboard.tsx (main, 62ba4fc, 1517 lines) with its five
* independent reads (`/api/scribe/config`, `/api/scribe/stats` +
* `/api/scribe/insights/dashboard` run together but caught separately,
* `/api/scribe/timeseries`, `/api/scribe/sessions`, `/api/scribe/campaigns`,
* `/api/scribe/landing-pages`) replaced by props: `loading` for the page's
* first-fetch gate, `trackingSiteId` for the config read, `stats` /
* `insights` for the two reads the dashboard's own `Promise.all` runs
* together (`insights` alone can be `null` while `stats` is populated,
* because `fetchStats` catches the insights call on its own,
* `.catch(() => ({ data: null }))`, line 305 — reproduced below as the
* `error` state), and `timeseries` / `sessions` / `sessionsTotal` /
* `campaigns` / `landingPages` for the four reads that run independently of
* `fetchStats` and of each other. `activeTab` seeds a local `useState` the
* same way `subscription-manager`'s demo seeds its voucher input, since the
* source drives the tab with a `useEffect` this demo cannot run
* (`if (stats.totals.events === 0) setActiveTab('setup')`, line 382 to 386;
* `if (statsRes.data.totals.events > 0 && activeTab === 'setup')
* setActiveTab('data')`, line 313 to 315); the prop is what that effect
* would have produced for each state's numbers, computed by hand once
* rather than run live.
*
* What is kept byte-faithful: the header and its date-range dropdown, the
* four-tab bar and each tab's unread badge, the Data tab's five metric
* cards (with the exact `insights ?? stats` fallback chain per card, line
* 716 to 764), the time-series/hourly/daily/traffic-source card headers and
* their exact empty-state guards, the campaigns and landing-pages tables
* (including the landing-page row's proportional background bar, line
* 1158 to 1160), the sessions list and its expand/collapse row with a
* session's events, the Insights tab's four metric cards, recommendations
* list, conversion funnel, engagement distribution and device breakdown,
* and the Setup tab's three feature cards, site-ID card, install
* code block and JavaScript API examples. All copy is the source's, German
* (de-CH).
*
* What is different, and why:
* - `recharts` (`AreaChart`, `BarChart`, `PieChart`, line 39 to 53) is not
* one of the four packages a demo may import from (AUTHORING.md "The
* exports"), and the gate's esbuild bundle only externalizes react,
* react-dom, react-router-dom and lucide-react
* (tooling/verify/render-smoke.mjs), so bundling it would fail to
* resolve. `MiniAreaChart`, `MiniBarChart` and `MiniDonutChart` below
* are a from-scratch inline-SVG rebuild, following the same convention
* bookkeeping/ui/bookkeeping-financial-dashboard's demo already
* establishes for its own two recharts widgets: same gridline and axis
* tones, same three series colors on the area chart (`#6844C4` sessions,
* `#3b82f6` page views, `#22c55e` contacts) and the same channel color
* table (`CHANNEL_COLORS`, kept verbatim). Neither chart's hover
* `Tooltip` (line 808, 841, 863, 901) is rendered; a static
* server-rendered frame has no hover to show it on.
* - `ScribeExplore` (`components/scribe/ScribeExplore.tsx`, 166 lines) and
* the three components it composes (`ScribeQueryBuilder.tsx`, 339
* lines; `QueryResultsPanel.tsx`, 280 lines; `FunnelBuilder.tsx`, 346
* lines — 965 lines together, none of them part of the four packages a
* demo may import from) are not ported line for line. `Explore` below
* rebuilds `ScribeExplore`'s own shell byte-faithfully (the schema
* loading spinner, the schema-error retry card, the Abfrage/Funnel
* mode toggle, line 84 to 139 of that file) and, once the schema is
* ready, a condensed but structurally real query panel (entity select,
* the schema's own filter fields, a date-range select, a Run button,
* a results table) rather than the source's full filter-row builder
* and its separate funnel-step editor. No shipped state below selects
* `activeTab: 'explore'` (none of the five states differ by tab
* selection, and `data` is the real default), so this simplification is
* never exercised by the render-smoke gate; it exists so the tab is not
* simply missing. Documented, not hidden.
* - `useNavigate` (line 2, 243) is dropped: "Einstellungen" (both call
* sites), the empty-state "Zur Installation" button (line 700, which
* really calls `setActiveTab('setup')` and is kept wired, since that is
* local state, not navigation) and the Setup tab's "Konfigurieren"
* button render with their real label and icon but the two that would
* leave the page do nothing, the same convention
* billing-credits/ui/subscription-manager's demo uses for its
* modal-opening buttons.
* - `navigator.clipboard.writeText` (`copyCode`, line 410 to 414) and the
* session-events fetch a row's expand triggers (`fetchSessionEvents`,
* line 335 to 343) are real event handlers kept as-is; neither runs
* during a static render (`renderToString` never fires an `onClick`),
* and no top-level code touches `window` or `document`
* (AUTHORING.md "The exports"). One session (`sessionExpandedId`) is
* pre-expanded by prop in `default` so the events list itself renders in
* at least one frame without simulating a click.
* - `toLocaleString('de-CH', …)` (`formatTimestamp`, `formatRelativeTime`,
* `formatChartDate`, `ChartTooltip`, four call sites) is replaced with
* the same fixed UTC-getter formatting
* billing-credits/ui/subscription-manager's demo uses for its dates, for
* the same reason: a server-rendered locale string can read differently
* once the identical markup hydrates in the browser.
* - `apiBaseUrl` (`import.meta.env.VITE_API_URL`, line 267) is a fixed
* fictional origin (`https://api.example-labs.test`); `trackingSiteId`
* and every session/campaign/contact value below are invented, not
* copied from any real workspace (AUTHORING.md house rules).
*
* What the source itself never renders, reproduced as measured, not fixed
* (Gotchas): `TrackingStats.event_counts`, `.page_views`, `.recent_events`
* and `.recent_contacts` (line 78 to 95) are fetched and typed but no JSX
* anywhere reads them; `deleteContact` (line 416 to 427) is declared and
* never called from any button; `pixelIds` (line 252, populated by
* `fetchTrackingConfig`, line 279 to 285) is set and never read again, a
* leftover from the client-side Meta/Google/LinkedIn/Bing pixel removal the
* comment above `pixelAttrs` documents (line 429 to 433, itself always an
* empty string); `DashboardInsights.top_pages` (line 124 to 127) is typed
* and fetched but no JSX reads it either. All four are typed on the props
* below for shape-fidelity and left genuinely empty in the sample data
* rather than populated for a card that will never show them.
*/
import React, { useState } from 'react';
import {
Activity,
AlertTriangle,
BarChart2,
Calendar,
Check,
CheckCircle,
ChevronDown,
ChevronRight,
Clock,
Code,
Copy,
Database,
ExternalLink,
Eye,
Globe,
Info,
Lightbulb,
Mail,
Megaphone,
Monitor,
MousePointer,
PieChart as PieChartIcon,
RefreshCw,
Search,
Settings,
Shield,
Smartphone,
Tablet,
Target,
Timer,
TrendingDown,
TrendingUp,
Users,
} from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { AZSymbol, Badge, Button, Card, Divider, Heading, Spinner, Text } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';
/* ---- shapes (TrackingDashboard.tsx lines 72 to 236, unchanged) ---------- */
export interface TrackingStats {
totals: { visitors: number; contacts: number; events: number };
/** Fetched and typed; no JSX in the source reads any of these four (Gotchas). */
event_counts: Record<string, number>;
page_views: Record<string, number>;
recent_events: Array<{ event: string; email: string | null; anonymous_id: string; event_data: Record<string, any>; context: Record<string, any>; site_id: string; timestamp: string }>;
recent_contacts: Array<{ email: string; first_seen: string; properties: Record<string, any>; event_count: number }>;
}
export interface DashboardInsights {
summary: {
total_sessions: number; unique_visitors: number; identified_contacts: number;
total_events: number; page_views: number; form_submissions: number;
identification_rate: number; avg_events_per_session: number;
avg_session_duration: number; avg_session_duration_formatted: string; bounce_rate: number;
};
traffic_sources: {
sources: Array<{ source: string; medium: string; label: string; sessions: number; channel: string }>;
channels: Array<{ channel: string; sessions: number }>;
};
/** Typed and fetched; no JSX in the source reads it (Gotchas). */
top_pages: Array<{ page: string; views: number }>;
engagement_distribution: { counts: { high: number; medium: number; low: number }; percentages: { high: number; medium: number; low: number } };
conversion_funnel: { stages: Array<{ name: string; count: number; percentage: number }> };
device_breakdown: { counts: { mobile: number; tablet: number; desktop: number }; percentages: { mobile: number; tablet: number; desktop: number } };
trends: { current_period: number; previous_period: number; change_percent: number; trend: 'up' | 'down' | 'stable' };
insights: Array<{ type: 'success' | 'warning' | 'info'; title: string; message: string; metric?: number; action?: string }>;
}
export interface TimeSeriesData {
series: Array<{ date: string; events: number; sessions: number; page_views: number; contacts: number }>;
hourly: Array<{ hour: number; label: string; count: number }>;
daily: Array<{ dow: number; label: string; count: number }>;
granularity: string;
days: number;
}
export interface SessionItem {
id: string; anonymous_id: string; source_label: string; landing_page: string | null;
pages_viewed: number; duration_seconds: number; duration_formatted: string;
status: 'identified' | 'anonymous'; contact_email: string | null; utm_campaign: string | null;
has_gclid: boolean; has_fbclid: boolean; has_li_fat_id: boolean;
first_seen: string; last_seen: string;
}
export interface SessionEvent {
event_type: string; page_path: string | null; page_title: string | null;
event_data: Record<string, any>; created_at: string;
}
export interface CampaignItem {
campaign: string; sessions: number; unique_visitors: number; identified_contacts: number;
conversion_rate: number; bounce_rate: number;
has_gclid_count: number; has_fbclid_count: number; has_li_fat_id_count: number;
}
export interface LandingPageItem {
landing_page: string; sessions: number; bounce_rate: number;
avg_duration_seconds: number; avg_duration_formatted: string; identified_count: number;
}
export type DateRange = 1 | 7 | 14 | 30 | 90;
export interface Props {
/** The signed-in tenant, for the shell's account row. */
viewer: ShellUser;
workspace: ShellWorkspace;
/** Seeds the local tab state the source's two effects would otherwise drive (line 313 to 315, 382 to 386). */
activeTab: 'data' | 'setup' | 'insights' | 'explore';
/** The first fetch in flight (line 249, 318 to 321): a single spinner card in the Data tab, tabs already on screen. */
loading?: boolean;
refreshing?: boolean;
/** '' when `/api/scribe/config` never returned a `site_id` (line 251, 276 to 277) — not just "still loading" (Gotchas). */
trackingSiteId?: string;
dateRange?: DateRange;
/** Null only while `loading`; a genuinely empty workspace still gets a `totals.events === 0` object, not null (Application logic). */
stats: TrackingStats | null;
/** Independently nullable: `fetchStats`'s own `.catch(() => ({ data: null }))` on this one call (line 305, `error` state). */
insights: DashboardInsights | null;
timeseries: TimeSeriesData | null;
sessions?: SessionItem[];
sessionsTotal?: number;
sessionsPage?: number;
/** Pre-resolved so the expanded row renders without simulating `fetchSessionEvents`'s click-triggered fetch. */
sessionExpandedId?: string | null;
sessionEventsById?: Record<string, SessionEvent[]>;
campaigns?: CampaignItem[];
landingPages?: LandingPageItem[];
}
/* ---- sample data (fictional; house rules, AUTHORING.md) ----------------- */
const viewer: ShellUser = { name: 'Jonas Frei', email: 'jonas@example.com', isAdmin: false };
const workspace: ShellWorkspace = { id: 'ws_2', name: 'Studio Lindenhof', slug: 'studio-lindenhof' };
const DATE_RANGES: { label: string; value: DateRange }[] = [
{ label: 'Heute', value: 1 },
{ label: '7 Tage', value: 7 },
{ label: '14 Tage', value: 14 },
{ label: '30 Tage', value: 30 },
{ label: '90 Tage', value: 90 },
];
const CHANNEL_COLORS: Record<string, string> = {
Direct: '#6844C4',
Organic: '#22c55e',
Paid: '#f59e0b',
Social: '#3b82f6',
Email: '#ec4899',
Referral: '#8b5cf6',
};
const emptyStats: TrackingStats = {
totals: { visitors: 0, contacts: 0, events: 0 },
event_counts: {}, page_views: {}, recent_events: [], recent_contacts: [],
};
const populatedStats: TrackingStats = {
totals: { visitors: 842, contacts: 96, events: 5310 },
event_counts: {}, page_views: {}, recent_events: [], recent_contacts: [],
};
const populatedInsights: DashboardInsights = {
summary: {
total_sessions: 1024, unique_visitors: 842, identified_contacts: 96,
total_events: 5310, page_views: 3120, form_submissions: 58,
identification_rate: 11.4, avg_events_per_session: 5.2,
avg_session_duration: 142, avg_session_duration_formatted: '2m 22s', bounce_rate: 38.6,
},
traffic_sources: {
sources: [
{ source: 'google', medium: 'organic', label: 'google / organic', sessions: 356, channel: 'Organic' },
{ source: '(direct)', medium: '(none)', label: '(direct) / (none)', sessions: 240, channel: 'Direct' },
{ source: 'google', medium: 'cpc', label: 'google / cpc', sessions: 188, channel: 'Paid' },
{ source: 'linkedin', medium: 'social', label: 'linkedin / social', sessions: 140, channel: 'Social' },
{ source: 'newsletter', medium: 'email', label: 'newsletter / email', sessions: 100, channel: 'Email' },
],
channels: [
{ channel: 'Organic', sessions: 356 },
{ channel: 'Direct', sessions: 240 },
{ channel: 'Paid', sessions: 188 },
{ channel: 'Social', sessions: 140 },
{ channel: 'Email', sessions: 100 },
],
},
top_pages: [],
engagement_distribution: { counts: { high: 312, medium: 498, low: 214 }, percentages: { high: 30, medium: 49, low: 21 } },
conversion_funnel: {
stages: [
{ name: 'Besucher', count: 842, percentage: 100 },
{ name: 'Seite angesehen (2+)', count: 511, percentage: 61 },
{ name: 'Formular gestartet', count: 140, percentage: 17 },
{ name: 'Formular abgeschickt', count: 96, percentage: 11 },
],
},
device_breakdown: { counts: { mobile: 460, tablet: 84, desktop: 480 }, percentages: { mobile: 45, tablet: 8, desktop: 47 } },
trends: { current_period: 1024, previous_period: 861, change_percent: 18.9, trend: 'up' },
insights: [
{ type: 'success', title: 'Formular-Rate steigt', message: 'Die Formular-Absendungen sind diese Woche um 24% gestiegen, vor allem über die Landing Page /webinar.', metric: 24, action: 'Kampagne mit mehr Budget verlängern' },
{ type: 'warning', title: 'Hohe Bounce Rate auf /preise', message: 'Fast zwei Drittel der Besucher verlassen /preise ohne weitere Interaktion.', metric: 64 },
{ type: 'info', title: 'Mobile überholt Desktop', message: 'Mobile Sessions liegen jetzt bei 45%, ein neuer Höchststand für diesen Workspace.' },
],
};
const timeseries7d: TimeSeriesData = {
granularity: 'day', days: 7,
series: [
{ date: '2026-09-06T00:00:00Z', sessions: 118, page_views: 340, contacts: 9, events: 560 },
{ date: '2026-09-07T00:00:00Z', sessions: 132, page_views: 372, contacts: 11, events: 601 },
{ date: '2026-09-08T00:00:00Z', sessions: 96, page_views: 260, contacts: 6, events: 420 },
{ date: '2026-09-09T00:00:00Z', sessions: 151, page_views: 410, contacts: 14, events: 688 },
{ date: '2026-09-10T00:00:00Z', sessions: 163, page_views: 455, contacts: 18, events: 742 },
{ date: '2026-09-11T00:00:00Z', sessions: 140, page_views: 398, contacts: 15, events: 655 },
{ date: '2026-09-12T00:00:00Z', sessions: 224, page_views: 601, contacts: 23, events: 980 },
],
hourly: Array.from({ length: 24 }, (_, h) => ({
hour: h, label: String(h).padStart(2, '0'),
count: h < 6 ? 4 : h < 9 ? 40 : h < 12 ? 95 : h < 14 ? 120 : h < 18 ? 150 : h < 21 ? 88 : 22,
})),
daily: [
{ dow: 1, label: 'Mo', count: 210 }, { dow: 2, label: 'Di', count: 244 }, { dow: 3, label: 'Mi', count: 198 },
{ dow: 4, label: 'Do', count: 266 }, { dow: 5, label: 'Fr', count: 302 }, { dow: 6, label: 'Sa', count: 140 },
{ dow: 0, label: 'So', count: 96 },
],
};
const sampleSessions: SessionItem[] = [
{ id: 'sess_1', anonymous_id: 'anon_1', source_label: 'google / organic', landing_page: '/wissen/website-tracking-dsgvo', pages_viewed: 4, duration_seconds: 212, duration_formatted: '3m 32s', status: 'identified', contact_email: 'anna.berger@example.com', utm_campaign: null, has_gclid: false, has_fbclid: false, has_li_fat_id: false, first_seen: '2026-09-12T07:40:00Z', last_seen: '2026-09-12T07:43:32Z' },
{ id: 'sess_2', anonymous_id: 'anon_2', source_label: 'linkedin / social', landing_page: '/preise', pages_viewed: 1, duration_seconds: 18, duration_formatted: '18s', status: 'anonymous', contact_email: null, utm_campaign: 'launch-q3', has_gclid: false, has_fbclid: false, has_li_fat_id: true, first_seen: '2026-09-12T06:55:00Z', last_seen: '2026-09-12T06:55:18Z' },
{ id: 'sess_3', anonymous_id: 'anon_3', source_label: 'google / cpc', landing_page: '/webinar', pages_viewed: 6, duration_seconds: 401, duration_formatted: '6m 41s', status: 'identified', contact_email: 'marc.huber@example.com', utm_campaign: 'webinar-herbst', has_gclid: true, has_fbclid: false, has_li_fat_id: false, first_seen: '2026-09-11T18:10:00Z', last_seen: '2026-09-11T18:16:41Z' },
{ id: 'sess_4', anonymous_id: 'anon_4', source_label: '(direct) / (none)', landing_page: '/', pages_viewed: 2, duration_seconds: 64, duration_formatted: '1m 4s', status: 'anonymous', contact_email: null, utm_campaign: null, has_gclid: false, has_fbclid: false, has_li_fat_id: false, first_seen: '2026-09-11T14:02:00Z', last_seen: '2026-09-11T14:03:04Z' },
];
const sampleSessionEvents: Record<string, SessionEvent[]> = {
sess_1: [
{ event_type: 'page_view', page_path: '/wissen/website-tracking-dsgvo', page_title: 'Website-Tracking und DSGVO', event_data: {}, created_at: '2026-09-12T07:40:00Z' },
{ event_type: 'page_view', page_path: '/wissen/scribe-vs-ga4', page_title: 'Scribe vs. GA4', event_data: {}, created_at: '2026-09-12T07:41:20Z' },
{ event_type: 'form_submitted', page_path: '/wissen/scribe-vs-ga4', page_title: null, event_data: { form_id: 'newsletter' }, created_at: '2026-09-12T07:42:50Z' },
{ event_type: 'button_clicked', page_path: '/wissen/scribe-vs-ga4', page_title: null, event_data: { button_id: 'cta-demo' }, created_at: '2026-09-12T07:43:10Z' },
],
};
const sampleCampaigns: CampaignItem[] = [
{ campaign: 'webinar-herbst', sessions: 214, unique_visitors: 198, identified_contacts: 31, conversion_rate: 14.5, bounce_rate: 29.4, has_gclid_count: 140, has_fbclid_count: 0, has_li_fat_id_count: 0 },
{ campaign: 'launch-q3', sessions: 166, unique_visitors: 150, identified_contacts: 9, conversion_rate: 5.4, bounce_rate: 61.2, has_gclid_count: 0, has_fbclid_count: 0, has_li_fat_id_count: 166 },
{ campaign: 'retargeting-preise', sessions: 88, unique_visitors: 80, identified_contacts: 3, conversion_rate: 0, bounce_rate: 74.9, has_gclid_count: 0, has_fbclid_count: 88, has_li_fat_id_count: 0 },
];
const sampleLandingPages: LandingPageItem[] = [
{ landing_page: '/webinar', sessions: 214, bounce_rate: 29.4, avg_duration_seconds: 268, avg_duration_formatted: '4m 28s', identified_count: 31 },
{ landing_page: '/', sessions: 198, bounce_rate: 44.1, avg_duration_seconds: 96, avg_duration_formatted: '1m 36s', identified_count: 12 },
{ landing_page: '/preise', sessions: 150, bounce_rate: 74.9, avg_duration_seconds: 31, avg_duration_formatted: '31s', identified_count: 3 },
{ landing_page: '/wissen/website-tracking-dsgvo', sessions: 92, bounce_rate: 33.7, avg_duration_seconds: 211, avg_duration_formatted: '3m 31s', identified_count: 18 },
];
const base: Props = {
viewer, workspace,
activeTab: 'data',
loading: false,
refreshing: false,
trackingSiteId: 'site_7f2c94ab',
dateRange: 7,
stats: populatedStats,
insights: populatedInsights,
timeseries: timeseries7d,
sessions: sampleSessions,
sessionsTotal: 214,
sessionsPage: 1,
sessionExpandedId: 'sess_1',
sessionEventsById: sampleSessionEvents,
campaigns: sampleCampaigns,
landingPages: sampleLandingPages,
};
export const states = defineStates<Props>({
'default': {
label: 'Data tab, populated: stats, timeseries and insights all loaded for the active 7-day range',
props: base,
height: 2600, viewport: 1440,
},
'loading': {
label: 'First fetch in flight (line 249, 318 to 321): the shell and tabs are already on screen, one spinner card where the metrics will be',
props: { ...base, activeTab: 'data', loading: true, trackingSiteId: '', stats: null, insights: null, timeseries: null, sessions: [], sessionsTotal: 0, sessionExpandedId: null, campaigns: [], landingPages: [] },
height: 700, viewport: 1440,
},
'empty': {
label: 'Fresh workspace: zero events, the tab effect surfaces Setup, but the tracking script is already installed and ready to copy',
props: { ...base, activeTab: 'setup', trackingSiteId: 'site_2b6e10cd', stats: emptyStats, insights: null, timeseries: null, sessions: [], sessionsTotal: 0, sessionExpandedId: null, campaigns: [], landingPages: [] },
height: 1750, viewport: 1440,
},
'error': {
label: "Insights read failed on its own catch (line 305) while stats, timeseries, sessions and campaigns all loaded: the traffic-source card is simply absent, top metrics fall back to stats",
props: { ...base, activeTab: 'data', insights: null, sessionExpandedId: null },
height: 2200, viewport: 1440,
},
'setup': {
label: 'No site_id ever returned by /api/scribe/config (line 276 to 277): the Setup tab is primary and the site-ID card never resolves past its pulsing placeholder',
props: { ...base, activeTab: 'setup', trackingSiteId: '', stats: emptyStats, insights: null, timeseries: null, sessions: [], sessionsTotal: 0, sessionExpandedId: null, campaigns: [], landingPages: [] },
height: 1750, viewport: 1440,
},
});
/* ---- date formatting (fixed UTC table, see header comment) -------------- */
const MONTHS_DE_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
const DOW_DE_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
function formatTimestamp(ts: string): string {
const d = new Date(ts);
const p2 = (n: number) => String(n).padStart(2, '0');
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${String(d.getUTCFullYear()).slice(-2)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
}
function formatChartDate(dateStr: string, dateRange: DateRange): string {
const d = new Date(dateStr);
const p2 = (n: number) => String(n).padStart(2, '0');
if (dateRange <= 1) return `${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.`;
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
function truncatePath(path: string | null, maxLen = 40): string {
if (!path) return '-';
if (path.length <= maxLen) return path;
return path.substring(0, maxLen) + '...';
}
/* ---- inline-SVG chart rebuilds (recharts is not importable, header comment) */
function MiniAreaChart({ series, dateRange }: { series: TimeSeriesData['series']; dateRange: DateRange }) {
const W = 640;
const H = 220;
const max = Math.max(1, ...series.flatMap((d) => [d.sessions, d.page_views, d.contacts]));
const stepX = series.length > 1 ? W / (series.length - 1) : W;
const points = (key: 'sessions' | 'page_views' | 'contacts') =>
series.map((d, i) => `${i * stepX},${H - (d[key] / max) * H}`).join(' ');
const series1 = points('sessions');
const series2 = points('page_views');
const series3 = points('contacts');
const legend: Array<[string, string, string]> = [
['sessions', 'Sessions', '#6844C4'],
['page_views', 'Seitenaufrufe', '#3b82f6'],
['contacts', 'Kontakte', '#22c55e'],
];
return (
<div>
<svg viewBox={`0 0 ${W} ${H + 8}`} width="100%" height={288} preserveAspectRatio="none" role="img" aria-label="Besucher und Events">
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
<line key={f} x1={0} x2={W} y1={H - H * f} y2={H - H * f} stroke="#e7e5e4" strokeDasharray="3 3" strokeWidth={1} />
))}
<polyline points={series2} fill="none" stroke="#3b82f6" strokeWidth={2} />
<polyline points={series3} fill="none" stroke="#22c55e" strokeWidth={2} />
<polyline points={series1} fill="none" stroke="#6844C4" strokeWidth={2.5} />
{series.map((d, i) => (
<text key={d.date} x={i * stepX} y={H + 8} textAnchor="middle" fontSize={10} fill="#71717a">
{formatChartDate(d.date, dateRange)}
</text>
))}
</svg>
<div className="flex items-center gap-4 justify-center mt-1">
{legend.map(([key, label, color]) => (
<span key={key} className="flex items-center gap-1.5 text-xs text-ink-60">
<span className="w-2.5 h-2.5 rounded-full" style={{ background: color }} />
{label}
</span>
))}
</div>
</div>
);
}
function MiniBarChart({ data, color, everyNthLabel = 1 }: { data: Array<{ label: string; count: number }>; color: string; everyNthLabel?: number }) {
const W = 300;
const H = 140;
const max = Math.max(1, ...data.map((d) => d.count));
const barW = W / data.length;
return (
<svg viewBox={`0 0 ${W} ${H + 16}`} width="100%" height={192} preserveAspectRatio="none" role="img">
{[0, 0.5, 1].map((f) => (
<line key={f} x1={0} x2={W} y1={H - H * f} y2={H - H * f} stroke="#e7e5e4" strokeDasharray="3 3" strokeWidth={1} />
))}
{data.map((d, i) => {
const h = (d.count / max) * H;
return (
<g key={`${d.label}-${i}`}>
<rect x={i * barW + barW * 0.15} y={H - h} width={barW * 0.7} height={h} fill={color} rx={2} />
{i % everyNthLabel === 0 && (
<text x={i * barW + barW / 2} y={H + 12} textAnchor="middle" fontSize={9} fill="#71717a">{d.label}</text>
)}
</g>
);
})}
</svg>
);
}
function MiniDonutChart({ data }: { data: Array<{ channel: string; sessions: number }> }) {
const total = data.reduce((s, d) => s + d.sessions, 0) || 1;
const cx = 90, cy = 90, r = 62, strokeW = 32;
const circumference = 2 * Math.PI * r;
let offset = 0;
return (
<div className="flex items-center gap-6">
<svg viewBox="0 0 180 180" width={180} height={180} role="img" aria-label="Traffic-Kanäle">
{data.map((d) => {
const dash = (d.sessions / total) * circumference;
const el = (
<circle
key={d.channel}
cx={cx} cy={cy} r={r} fill="none"
stroke={CHANNEL_COLORS[d.channel] || '#52525b'}
strokeWidth={strokeW}
strokeDasharray={`${dash} ${circumference - dash}`}
strokeDashoffset={-offset}
transform={`rotate(-90 ${cx} ${cy})`}
/>
);
offset += dash;
return el;
})}
</svg>
<ul className="space-y-1.5 flex-1">
{data.map((d) => (
<li key={d.channel} className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-ink-60">
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ background: CHANNEL_COLORS[d.channel] || '#52525b' }} />
{d.channel}
</span>
<span className="text-ink-60">{((d.sessions / total) * 100).toFixed(0)}%</span>
</li>
))}
</ul>
</div>
);
}
/* ---- setup-tab code samples (TrackingDashboard.tsx lines 434 to 458) ----- */
const apiBaseUrl = 'https://api.example-labs.test';
function buildTrackingScript(siteId: string): string {
return `<!-- Alchemy Labs Tracking Script -->
<script>
(function(w,d,s,l,i){
w[l]=w[l]||[];
var f=d.getElementsByTagName(s)[0],
j=d.createElement(s);
j.async=true;
j.src='${apiBaseUrl}/static/scribe.js';
j.setAttribute('data-site-id', '${siteId}');
j.setAttribute('data-api-url', '${apiBaseUrl}');
f.parentNode.insertBefore(j,f);
})(window,document,'script','AlchemyScribe');
</script>`;
}
const identifyExample = `// Identifiziere einen Benutzer (z.B. nach Formular-Absendung)
window.AlchemyScribe.identify('email@example.com', {
name: 'Max Mustermann',
company: 'Musterfirma AG'
});`;
const trackEventExample = `// Tracke ein benutzerdefiniertes Event
window.AlchemyScribe.track('button_clicked', {
button_id: 'cta-hero',
page: window.location.pathname
});`;
function CodeBlock({ code, id, copiedId, onCopy }: { code: string; id: string; copiedId: string | null; onCopy: (code: string, id: string) => void }) {
return (
<div className="relative group">
<pre className="bg-zinc-900 text-stone-300 p-4 rounded-xl text-sm overflow-x-auto font-mono border border-zinc-800">
<code>{code}</code>
</pre>
<button
onClick={() => onCopy(code, id)}
className="absolute top-3 right-3 p-2 rounded-lg bg-zinc-700 hover:bg-zinc-600 transition-colors opacity-0 group-hover:opacity-100"
title="Kopieren"
>
{copiedId === id ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-stone-400" />}
</button>
</div>
);
}
/* ---- the Explore tab (ScribeExplore.tsx shell, condensed builder below) - */
function Explore() {
// ScribeExplore fetches its own schema on mount; a demo has no network
// (AUTHORING.md "The exports"), so this frame skips straight to the
// ready state, the only one of ScribeExplore's own three branches that
// has anything besides a spinner or a retry card to show.
const [mode, setMode] = useState<'query' | 'funnel'>('query');
return (
<div className="space-y-6">
<div className="flex gap-2">
<button
type="button"
onClick={() => setMode('query')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm border transition-colors ${mode === 'query' ? 'bg-[#6844C4]/10 border-[#6844C4] text-[#6844C4] font-medium' : 'border-line bg-white text-ink-60 hover:text-ink hover:bg-stone-50'}`}
>
<Database className="w-4 h-4" />
Abfrage
</button>
<button
type="button"
onClick={() => setMode('funnel')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm border transition-colors ${mode === 'funnel' ? 'bg-[#6844C4]/10 border-[#6844C4] text-[#6844C4] font-medium' : 'border-line bg-white text-ink-60 hover:text-ink hover:bg-stone-50'}`}
>
<Target className="w-4 h-4" />
Funnel
</button>
</div>
{mode === 'query' ? (
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4">Segment abfragen</Heading>
<div className="grid sm:grid-cols-3 gap-3 mb-4">
<div>
<Text size="xs" muted className="mb-1">Entität</Text>
<div className="h-10 rounded-lg border border-line bg-white px-3 flex items-center text-sm">Kontakte</div>
</div>
<div>
<Text size="xs" muted className="mb-1">Filter</Text>
<div className="h-10 rounded-lg border border-line bg-white px-3 flex items-center text-sm text-ink-60">source = google</div>
</div>
<div>
<Text size="xs" muted className="mb-1">Zeitraum</Text>
<div className="h-10 rounded-lg border border-line bg-white px-3 flex items-center text-sm">7 Tage</div>
</div>
</div>
<Button variant="primary" size="sm">
<Search className="w-4 h-4" />
Ausführen
</Button>
<Divider className="my-6" />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line text-ink-60">
<th className="text-left py-2 px-3 font-medium">E-Mail</th>
<th className="text-left py-2 px-3 font-medium">Quelle</th>
<th className="text-right py-2 px-3 font-medium">Events</th>
</tr>
</thead>
<tbody>
{[
{ email: 'anna.berger@example.com', source: 'google / organic', events: 12 },
{ email: 'marc.huber@example.com', source: 'google / cpc', events: 7 },
].map((row) => (
<tr key={row.email} className="border-b border-line">
<td className="py-2 px-3">{row.email}</td>
<td className="py-2 px-3 text-ink-60">{row.source}</td>
<td className="py-2 px-3 text-right">{row.events}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : (
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4">Funnel-Schritte</Heading>
<div className="space-y-2">
{['Landing Page besucht', 'Formular gestartet', 'Formular abgeschickt'].map((step, idx) => (
<div key={step} className="flex items-center gap-3 p-3 rounded-lg bg-stone-100">
<Badge variant="secondary" size="sm">{idx + 1}</Badge>
<Text size="sm">{step}</Text>
</div>
))}
</div>
</Card>
)}
</div>
);
}
/* ---- the page ------------------------------------------------------------ */
function TrackingDashboardPage({
activeTab: activeTabInitial,
loading = false,
refreshing = false,
trackingSiteId = '',
dateRange = 7,
stats,
insights,
timeseries,
sessions = [],
sessionsTotal = 0,
sessionsPage = 1,
sessionExpandedId = null,
sessionEventsById = {},
campaigns = [],
landingPages = [],
}: Omit<Props, 'viewer' | 'workspace'>) {
const [activeTab, setActiveTab] = useState(activeTabInitial);
const [showDatePicker, setShowDatePicker] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedSession, setExpandedSession] = useState<string | null>(sessionExpandedId);
const copyCode = (code: string, id: string) => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const hasData = !!stats && stats.totals.events > 0;
const totalSessionsPages = Math.ceil(sessionsTotal / 15) || 0;
const peakHour = timeseries?.hourly.reduce((a, b) => (b.count > a.count ? b : a), timeseries.hourly[0]) ?? null;
const peakDay = timeseries?.daily.reduce((a, b) => (b.count > a.count ? b : a), timeseries.daily[0]) ?? null;
return (
<div className="max-w-6xl mx-auto p-6 pb-20">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<Badge variant="primary" className="mb-2">Analytics</Badge>
<Heading level={1} className="mb-2 flex items-center gap-3">
<BarChart2 className="w-8 h-8 text-[#6844C4]" />
Tracking & Analytics
</Heading>
<Text muted>Verfolge Besucher, erfasse Leads und verstehe deine Marketing-Performance.</Text>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowDatePicker(!showDatePicker)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-line bg-white hover:bg-stone-50 text-sm text-ink transition-colors"
>
<Calendar className="w-4 h-4 text-[#6844C4]" />
{DATE_RANGES.find((r) => r.value === dateRange)?.label}
<ChevronDown className="w-3 h-3" />
</button>
{showDatePicker && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowDatePicker(false)} />
<div className="absolute right-0 top-full mt-1 bg-white border border-line rounded-lg shadow-xl z-20 py-1 min-w-[140px]">
{DATE_RANGES.map((range) => (
<button
key={range.value}
onClick={() => setShowDatePicker(false)}
className={`w-full text-left px-4 py-2 text-sm transition-colors ${range.value === dateRange ? 'bg-[#6844C4]/10 text-[#6844C4]' : 'text-ink hover:bg-stone-50'}`}
>
{range.label}
</button>
))}
</div>
</>
)}
</div>
<Button variant="outline" disabled={refreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Aktualisieren
</Button>
<Button variant="outline">
<Settings className="w-4 h-4 mr-2" />
Einstellungen
</Button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-6 border-b border-line pb-2">
<button
onClick={() => setActiveTab('data')}
className={`px-4 py-2 rounded-t-lg font-medium transition-colors flex items-center gap-2 ${activeTab === 'data' ? 'bg-[#6844C4]/20 text-[#6844C4] border-b-2 border-[#6844C4]' : 'text-ink-60 hover:text-ink hover:bg-stone-100'}`}
>
<Activity className="w-4 h-4" />
Live-Daten
{hasData && (
<span className="ml-1 px-2 py-0.5 text-xs rounded-full bg-[#6844C4]/30">
{insights?.summary?.total_sessions ?? stats?.totals?.events ?? 0}
</span>
)}
</button>
<button
onClick={() => setActiveTab('insights')}
className={`px-4 py-2 rounded-t-lg font-medium transition-colors flex items-center gap-2 ${activeTab === 'insights' ? 'bg-[#6844C4]/20 text-[#6844C4] border-b-2 border-[#6844C4]' : 'text-ink-60 hover:text-ink hover:bg-stone-100'}`}
>
<Lightbulb className="w-4 h-4" />
Insights
</button>
<button
onClick={() => setActiveTab('explore')}
className={`px-4 py-2 rounded-t-lg font-medium transition-colors flex items-center gap-2 ${activeTab === 'explore' ? 'bg-[#6844C4]/20 text-[#6844C4] border-b-2 border-[#6844C4]' : 'text-ink-60 hover:text-ink hover:bg-stone-100'}`}
>
<Search className="w-4 h-4" />
Explore
</button>
<button
onClick={() => setActiveTab('setup')}
className={`px-4 py-2 rounded-t-lg font-medium transition-colors flex items-center gap-2 ${activeTab === 'setup' ? 'bg-[#6844C4]/20 text-[#6844C4] border-b-2 border-[#6844C4]' : 'text-ink-60 hover:text-ink hover:bg-stone-100'}`}
>
<Code className="w-4 h-4" />
Installation
</button>
</div>
{/* DATA TAB */}
{activeTab === 'data' && (
<div className="space-y-6">
{loading ? (
<Card variant="glass" padding="lg">
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-8 h-8 text-[#6844C4] animate-spin" />
</div>
</Card>
) : !hasData ? (
<Card variant="glass" padding="lg">
<div className="text-center py-12">
<Activity className="w-16 h-16 text-stone-300 mx-auto mb-4" />
<Heading level={3} className="mb-2">Noch keine Daten</Heading>
<Text muted className="mb-6">Installiere das Tracking-Script auf deiner Website, um Daten zu erfassen.</Text>
<Button onClick={() => setActiveTab('setup')}>
<Code className="w-4 h-4 mr-2" />
Zur Installation
</Button>
</div>
</Card>
) : (
<>
{/* Top metrics */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card variant="glass" padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[#6844C4]/20 flex items-center justify-center">
<Activity className="w-5 h-5 text-[#6844C4]" />
</div>
<div>
<Text size="xl" weight="bold">{insights?.summary?.total_sessions ?? stats?.totals?.visitors ?? 0}</Text>
<Text size="sm" muted>Sessions</Text>
</div>
</div>
</Card>
<Card variant="glass" padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/20 flex items-center justify-center">
<Users className="w-5 h-5 text-blue-500" />
</div>
<div>
<Text size="xl" weight="bold">{insights?.summary?.unique_visitors ?? 0}</Text>
<Text size="sm" muted>Besucher</Text>
</div>
</div>
</Card>
<Card variant="glass" padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-green-500/20 flex items-center justify-center">
<Mail className="w-5 h-5 text-green-500" />
</div>
<div>
<Text size="xl" weight="bold">{insights?.summary?.identified_contacts ?? stats?.totals?.contacts ?? 0}</Text>
<Text size="sm" muted>Kontakte</Text>
</div>
</div>
</Card>
<Card variant="glass" padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center">
<Timer className="w-5 h-5 text-amber-500" />
</div>
<div>
<Text size="xl" weight="bold">{insights?.summary?.avg_session_duration_formatted ?? '0s'}</Text>
<Text size="sm" muted>Avg. Dauer</Text>
</div>
</div>
</Card>
<Card variant="glass" padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
<ExternalLink className="w-5 h-5 text-red-500" />
</div>
<div>
<Text size="xl" weight="bold">{insights?.summary?.bounce_rate ?? 0}%</Text>
<Text size="sm" muted>Bounce Rate</Text>
</div>
</div>
</Card>
</div>
{/* Time series */}
{timeseries && timeseries.series.length > 0 && (
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#6844C4]" />
Besucher & Events
<Text size="xs" muted className="ml-auto">{DATE_RANGES.find((r) => r.value === dateRange)?.label}</Text>
</Heading>
<MiniAreaChart series={timeseries.series} dateRange={dateRange} />
</Card>
)}
{/* Hourly & daily */}
{timeseries && (
<div className="grid md:grid-cols-2 gap-6">
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-1 flex items-center gap-2">
<Clock className="w-5 h-5 text-amber-500" />
Wann kommen Besucher?
</Heading>
{peakHour && <Text size="xs" muted className="mb-4">Hauptzeit: {peakHour.label} Uhr ({peakHour.count} Events)</Text>}
<MiniBarChart data={timeseries.hourly} color="#f59e0b" everyNthLabel={3} />
</Card>
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-1 flex items-center gap-2">
<Calendar className="w-5 h-5 text-[#6844C4]" />
Wochentage
</Heading>
{peakDay && <Text size="xs" muted className="mb-4">Aktivster Tag: {peakDay.label} ({peakDay.count} Events)</Text>}
<MiniBarChart data={timeseries.daily} color="#6844C4" />
</Card>
</div>
)}
{/* Traffic sources */}
{insights?.traffic_sources && (insights.traffic_sources.channels.length > 0 || insights.traffic_sources.sources.length > 0) && (
<div className="grid md:grid-cols-2 gap-6">
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<PieChartIcon className="w-5 h-5 text-[#6844C4]" />
Traffic-Kanäle
</Heading>
{insights.traffic_sources.channels.length > 0 ? (
<MiniDonutChart data={insights.traffic_sources.channels} />
) : (
<Text muted>Keine Daten verfügbar</Text>
)}
</Card>
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<Globe className="w-5 h-5 text-[#6844C4]" />
Traffic-Quellen
</Heading>
{insights.traffic_sources.sources.length === 0 ? (
<Text muted>Keine Daten verfügbar</Text>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
<div className="flex items-center justify-between px-3 py-1 text-xs text-ink-40">
<span className="flex-1">Quelle / Medium</span>
<span className="w-20 text-right">Sessions</span>
<span className="w-20 text-right">Kanal</span>
</div>
{insights.traffic_sources.sources.map((source, idx) => (
<div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-stone-100">
<Text size="sm" className="flex-1 truncate">{source.label}</Text>
<Text size="sm" weight="semibold" className="w-20 text-right">{source.sessions}</Text>
<Badge variant="secondary" size="sm" className="w-20 text-center ml-2">{source.channel}</Badge>
</div>
))}
</div>
)}
</Card>
</div>
)}
{/* Campaigns */}
{campaigns.length > 0 && (
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<Megaphone className="w-5 h-5 text-amber-500" />
Campaign Performance
</Heading>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line text-ink-60">
<th className="text-left py-2 px-3 font-medium">Campaign</th>
<th className="text-right py-2 px-3 font-medium">Sessions</th>
<th className="text-right py-2 px-3 font-medium">Besucher</th>
<th className="text-right py-2 px-3 font-medium">Kontakte</th>
<th className="text-right py-2 px-3 font-medium">Conv. Rate</th>
<th className="text-right py-2 px-3 font-medium">Bounce</th>
<th className="text-center py-2 px-3 font-medium">Plattformen</th>
</tr>
</thead>
<tbody>
{campaigns.map((c, idx) => (
<tr key={idx} className="border-b border-line hover:bg-stone-50">
<td className="py-2.5 px-3"><Text size="sm" weight="medium">{c.campaign}</Text></td>
<td className="py-2.5 px-3 text-right">{c.sessions}</td>
<td className="py-2.5 px-3 text-right">{c.unique_visitors}</td>
<td className="py-2.5 px-3 text-right"><span className="text-green-600">{c.identified_contacts}</span></td>
<td className="py-2.5 px-3 text-right">
<span className={c.conversion_rate > 0 ? 'text-green-600' : 'text-ink-40'}>{c.conversion_rate.toFixed(1)}%</span>
</td>
<td className="py-2.5 px-3 text-right">
<span className={c.bounce_rate > 70 ? 'text-red-500' : 'text-ink'}>{c.bounce_rate.toFixed(1)}%</span>
</td>
<td className="py-2.5 px-3 text-center">
<div className="flex items-center justify-center gap-1">
{c.has_gclid_count > 0 && <Badge variant="secondary" size="sm">G</Badge>}
{c.has_fbclid_count > 0 && <Badge variant="secondary" size="sm">M</Badge>}
{c.has_li_fat_id_count > 0 && <Badge variant="secondary" size="sm">Li</Badge>}
{c.has_gclid_count === 0 && c.has_fbclid_count === 0 && c.has_li_fat_id_count === 0 && <span className="text-ink-40">-</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Sessions */}
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<Activity className="w-5 h-5 text-[#6844C4]" />
Sessions
{sessionsTotal > 0 && <Text size="xs" muted className="ml-auto">{sessionsTotal} gesamt</Text>}
</Heading>
{sessions.length === 0 ? (
<Text muted className="py-4">Keine Sessions in diesem Zeitraum.</Text>
) : (
<>
<div className="space-y-1">
<div className="hidden md:grid grid-cols-[24px_1fr_1fr_60px_70px_80px_100px] gap-3 px-3 py-1.5 text-xs text-ink-40">
<span></span>
<span>Quelle</span>
<span>Landing Page</span>
<span className="text-right">Seiten</span>
<span className="text-right">Dauer</span>
<span className="text-center">Status</span>
<span className="text-right">Zeit</span>
</div>
{sessions.map((session) => (
<div key={session.id}>
<button
onClick={() => setExpandedSession(expandedSession === session.id ? null : session.id)}
className="w-full grid grid-cols-[24px_1fr_1fr_60px_70px_80px_100px] gap-3 items-center px-3 py-2.5 rounded-lg bg-stone-100 hover:bg-stone-200 transition-colors text-left"
>
<ChevronRight className={`w-4 h-4 text-ink-40 transition-transform ${expandedSession === session.id ? 'rotate-90' : ''}`} />
<div className="truncate">
<Text size="sm" className="truncate">{session.source_label}</Text>
{session.utm_campaign && <Text size="xs" muted className="truncate">{session.utm_campaign}</Text>}
</div>
<Text size="xs" muted className="font-mono truncate">{truncatePath(session.landing_page, 35)}</Text>
<Text size="sm" className="text-right">{session.pages_viewed}</Text>
<Text size="sm" className="text-right">{session.duration_formatted || formatDuration(session.duration_seconds)}</Text>
<div className="text-center">
{session.status === 'identified' ? (
<Badge variant="primary" size="sm">{session.contact_email?.split('@')[0] || 'ID'}</Badge>
) : (
<Badge variant="secondary" size="sm">Anonym</Badge>
)}
</div>
<Text size="xs" muted className="text-right" title={formatTimestamp(session.first_seen)}>{formatTimestamp(session.first_seen)}</Text>
</button>
{expandedSession === session.id && (
<div className="ml-8 mr-2 mt-1 mb-2 p-3 rounded-lg bg-stone-50 border border-line">
{!sessionEventsById[session.id] ? (
<div className="flex items-center gap-2 py-2">
<RefreshCw className="w-4 h-4 text-ink-40 animate-spin" />
<Text size="xs" muted>Lade Events...</Text>
</div>
) : sessionEventsById[session.id].length === 0 ? (
<Text size="xs" muted>Keine Events in dieser Session.</Text>
) : (
<div className="space-y-1">
{sessionEventsById[session.id].map((ev, idx) => (
<div key={idx} className="flex items-center gap-3 py-1 text-xs">
<div className="w-5 h-5 rounded bg-stone-100 flex items-center justify-center flex-shrink-0">
{ev.event_type === 'page_view' ? (
<Eye className="w-3 h-3 text-blue-600" />
) : ev.event_type.includes('click') ? (
<MousePointer className="w-3 h-3 text-amber-600" />
) : (
<Activity className="w-3 h-3 text-ink-60" />
)}
</div>
<Badge variant="secondary" size="sm">{ev.event_type}</Badge>
<span className="text-ink-60 font-mono truncate flex-1">{ev.page_path || ev.page_title || '-'}</span>
<span className="text-ink-40 flex-shrink-0">{formatTimestamp(ev.created_at)}</span>
</div>
))}
</div>
)}
{session.contact_email && (
<div className="mt-2 pt-2 border-t border-line">
<Text size="xs" muted className="flex items-center gap-1"><Mail className="w-3 h-3" /> {session.contact_email}</Text>
</div>
)}
<div className="mt-1 flex gap-2">
{session.has_gclid && <Badge variant="secondary" size="sm">Google Ads</Badge>}
{session.has_fbclid && <Badge variant="secondary" size="sm">Meta</Badge>}
{session.has_li_fat_id && <Badge variant="secondary" size="sm">LinkedIn</Badge>}
</div>
</div>
)}
</div>
))}
</div>
{totalSessionsPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4 pt-4 border-t border-line">
<Button variant="ghost" size="sm" disabled={sessionsPage <= 1}>Zurück</Button>
<Text size="sm" muted>Seite {sessionsPage} von {totalSessionsPages}</Text>
<Button variant="ghost" size="sm" disabled={sessionsPage >= totalSessionsPages}>Weiter</Button>
</div>
)}
</>
)}
</Card>
{/* Landing pages */}
{landingPages.length > 0 && (
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<Globe className="w-5 h-5 text-[#6844C4]" />
Landing Pages
</Heading>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line text-ink-60">
<th className="text-left py-2 px-3 font-medium">Seite</th>
<th className="text-right py-2 px-3 font-medium">Sessions</th>
<th className="text-right py-2 px-3 font-medium">Bounce Rate</th>
<th className="text-right py-2 px-3 font-medium">Avg. Dauer</th>
<th className="text-right py-2 px-3 font-medium">Kontakte</th>
</tr>
</thead>
<tbody>
{landingPages.slice(0, 15).map((lp, idx) => {
const maxSessions = Math.max(...landingPages.map((l) => l.sessions));
const pct = maxSessions > 0 ? (lp.sessions / maxSessions) * 100 : 0;
return (
<tr key={idx} className="border-b border-line hover:bg-stone-50 relative">
<td className="py-2.5 px-3 relative">
<div className="absolute inset-y-0 left-0 bg-[#6844C4]/5 rounded" style={{ width: `${pct}%` }} />
<Text size="sm" className="font-mono relative z-10 truncate max-w-xs block">{truncatePath(lp.landing_page, 50)}</Text>
</td>
<td className="py-2.5 px-3 text-right">{lp.sessions}</td>
<td className="py-2.5 px-3 text-right">
<span className={lp.bounce_rate > 70 ? 'text-red-500' : 'text-ink'}>{lp.bounce_rate.toFixed(1)}%</span>
</td>
<td className="py-2.5 px-3 text-right">{lp.avg_duration_formatted || formatDuration(Math.round(lp.avg_duration_seconds))}</td>
<td className="py-2.5 px-3 text-right">
<span className={lp.identified_count > 0 ? 'text-green-600' : 'text-ink-40'}>{lp.identified_count}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
</>
)}
</div>
)}
{/* EXPLORE TAB */}
{activeTab === 'explore' && <Explore />}
{/* SETUP TAB */}
{activeTab === 'setup' && (
<div className="space-y-6">
<div className="grid md:grid-cols-3 gap-4">
<Card variant="glass" padding="md">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-[#6844C4]/20 flex items-center justify-center flex-shrink-0">
<Globe className="w-5 h-5 text-[#6844C4]" />
</div>
<div>
<Text weight="semibold" className="mb-1">Marketing Attribution</Text>
<Text size="sm" muted>UTM-Parameter, Referrer, Landing Pages</Text>
</div>
</div>
</Card>
<Card variant="glass" padding="md">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-green-500/20 flex items-center justify-center flex-shrink-0">
<AZSymbol className="w-5 h-5 text-green-500" />
</div>
<div>
<Text weight="semibold" className="mb-1">Conversion Tracking</Text>
<Text size="sm" muted>Events an Meta & Google senden</Text>
</div>
</div>
</Card>
<Card variant="glass" padding="md">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<Shield className="w-5 h-5 text-blue-500" />
</div>
<div>
<Text weight="semibold" className="mb-1">DSGVO-Konform</Text>
<Text size="sm" muted>First-Party Tracking, kein Cookie-Banner nötig</Text>
</div>
</div>
</Card>
</div>
<Card variant="glass" padding="lg" className="border-green-500/30">
<div className="flex items-center gap-3 mb-2">
<Check className="w-5 h-5 text-green-500" />
<Text weight="semibold" className="text-green-700">Deine Site-ID</Text>
</div>
<div className="flex items-center gap-3">
<code className="flex-1 bg-stone-100 text-ink px-4 py-2 rounded-lg font-mono text-sm">
{trackingSiteId || <span className="text-ink-40 animate-pulse">Lade Site-ID...</span>}
</code>
<Button variant="ghost" size="sm" onClick={() => copyCode(trackingSiteId, 'site-id')} disabled={!trackingSiteId}>
{copiedId === 'site-id' ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</Card>
<Card variant="glass" padding="lg">
<Heading level={2} className="mb-4 flex items-center gap-2">
<Code className="w-6 h-6 text-[#6844C4]" />
Installation
</Heading>
<Text className="mb-4">
Füge das Tracking-Script im <code className="bg-stone-100 text-ink px-2 py-0.5 rounded text-sm"><head></code> deiner Website ein:
</Text>
{trackingSiteId ? (
<CodeBlock code={buildTrackingScript(trackingSiteId)} id="tracking-script" copiedId={copiedId} onCopy={copyCode} />
) : (
<div className="bg-zinc-900 p-4 rounded-xl border border-zinc-800">
<div className="space-y-2 animate-pulse">
<div className="h-4 bg-zinc-800 rounded w-3/4" />
<div className="h-4 bg-zinc-800 rounded w-1/2" />
<div className="h-4 bg-zinc-800 rounded w-2/3" />
</div>
</div>
)}
</Card>
<Card variant="glass" padding="lg">
<Heading level={2} className="mb-6">JavaScript API</Heading>
<div className="mb-8">
<Heading level={3} className="mb-3">identify() - Benutzer identifizieren</Heading>
<Text className="mb-4">Verknüpfe einen anonymen Besucher mit einer E-Mail-Adresse (z.B. nach Formular-Absendung).</Text>
<CodeBlock code={identifyExample} id="identify-example" copiedId={copiedId} onCopy={copyCode} />
</div>
<Divider className="my-8" />
<div>
<Heading level={3} className="mb-3">track() - Events erfassen</Heading>
<Text className="mb-4">Tracke benutzerdefinierte Events wie Button-Klicks, Downloads oder andere Aktionen.</Text>
<CodeBlock code={trackEventExample} id="track-example" copiedId={copiedId} onCopy={copyCode} />
</div>
</Card>
<Card variant="glass" padding="lg">
<div className="flex items-center justify-between">
<div>
<Heading level={3} className="mb-2">Meta & Google Conversions</Heading>
<Text muted>Konfiguriere deine Pixel-IDs, um Conversions automatisch zu senden.</Text>
</div>
<Button variant="outline">
<Settings className="w-4 h-4 mr-2" />
Konfigurieren
</Button>
</div>
</Card>
</div>
)}
{/* INSIGHTS TAB */}
{activeTab === 'insights' && (
<div className="space-y-6">
{!insights ? (
<Card variant="glass" padding="lg">
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-8 h-8 text-[#6844C4] animate-spin" />
</div>
</Card>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card variant="glass" padding="md">
<div className="text-center">
<Text size="xl" weight="bold" className="text-[#6844C4]">{insights.summary.identification_rate}%</Text>
<Text size="sm" muted>Identifikationsrate</Text>
</div>
</Card>
<Card variant="glass" padding="md">
<div className="text-center">
<Text size="xl" weight="bold" className="text-green-500">{insights.summary.form_submissions}</Text>
<Text size="sm" muted>Formular-Absendungen</Text>
</div>
</Card>
<Card variant="glass" padding="md">
<div className="text-center">
<Text size="xl" weight="bold" className="text-blue-500">{insights.summary.avg_events_per_session.toFixed(1)}</Text>
<Text size="sm" muted>Events pro Session</Text>
</div>
</Card>
<Card variant="glass" padding="md">
<div className="flex items-center justify-center gap-2">
{insights.trends.trend === 'up' ? (
<TrendingUp className="w-5 h-5 text-green-500" />
) : insights.trends.trend === 'down' ? (
<TrendingDown className="w-5 h-5 text-red-500" />
) : (
<Activity className="w-5 h-5 text-ink-40" />
)}
<Text size="xl" weight="bold" className={insights.trends.trend === 'up' ? 'text-green-500' : insights.trends.trend === 'down' ? 'text-red-500' : 'text-ink-40'}>
{insights.trends.change_percent > 0 ? '+' : ''}{insights.trends.change_percent}%
</Text>
</div>
<Text size="sm" muted className="text-center">vs. Vormonat</Text>
</Card>
</div>
{insights.insights && insights.insights.length > 0 && (
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-amber-500" />
Empfehlungen
</Heading>
<div className="space-y-3">
{insights.insights.map((insight, idx) => (
<div key={idx} className={`p-4 rounded-lg border ${insight.type === 'success' ? 'bg-green-500/10 border-green-500/30' : insight.type === 'warning' ? 'bg-amber-500/10 border-amber-500/30' : 'bg-blue-500/10 border-blue-500/30'}`}>
<div className="flex items-start gap-3">
{insight.type === 'success' ? (
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
) : insight.type === 'warning' ? (
<AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
) : (
<Info className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
)}
<div>
<Text weight="semibold" className="mb-1">{insight.title}</Text>
<Text size="sm" muted>{insight.message}</Text>
{insight.action && <Text size="sm" className="mt-2 text-[#6844C4]">{insight.action}</Text>}
</div>
</div>
</div>
))}
</div>
</Card>
)}
<div className="grid md:grid-cols-2 gap-6">
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<Target className="w-5 h-5 text-green-500" />
Conversion Funnel
</Heading>
{insights.conversion_funnel.stages.length === 0 ? (
<Text muted>Keine Daten verfügbar</Text>
) : (
<div className="space-y-3">
{insights.conversion_funnel.stages.map((stage, idx) => (
<div key={idx}>
<div className="flex items-center justify-between mb-1">
<Text size="sm">{stage.name}</Text>
<Text size="sm" muted>{stage.count} ({stage.percentage}%)</Text>
</div>
<div className="h-2 bg-stone-200 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-[#6844C4] to-green-500 rounded-full transition-all" style={{ width: `${stage.percentage}%` }} />
</div>
</div>
))}
</div>
)}
</Card>
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<Activity className="w-5 h-5 text-amber-500" />
Engagement-Verteilung
</Heading>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-green-500/10">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<Text size="sm">Hoch engagiert</Text>
</div>
<Text size="sm" weight="semibold">{insights.engagement_distribution.counts.high} ({insights.engagement_distribution.percentages.high}%)</Text>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-amber-500/10">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-500" />
<Text size="sm">Mittel engagiert</Text>
</div>
<Text size="sm" weight="semibold">{insights.engagement_distribution.counts.medium} ({insights.engagement_distribution.percentages.medium}%)</Text>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-500/10">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-zinc-500" />
<Text size="sm">Niedrig engagiert</Text>
</div>
<Text size="sm" weight="semibold">{insights.engagement_distribution.counts.low} ({insights.engagement_distribution.percentages.low}%)</Text>
</div>
</div>
</Card>
</div>
<Card variant="glass" padding="lg">
<Heading level={3} className="mb-4 flex items-center gap-2">
<Monitor className="w-5 h-5 text-blue-500" />
Geräte
</Heading>
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-stone-100">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-blue-500" />
<Text size="sm">Desktop</Text>
</div>
<Text size="sm" weight="semibold">{insights.device_breakdown.counts.desktop} ({insights.device_breakdown.percentages.desktop}%)</Text>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-stone-100">
<div className="flex items-center gap-2">
<Smartphone className="w-4 h-4 text-green-500" />
<Text size="sm">Mobile</Text>
</div>
<Text size="sm" weight="semibold">{insights.device_breakdown.counts.mobile} ({insights.device_breakdown.percentages.mobile}%)</Text>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-stone-100">
<div className="flex items-center gap-2">
<Tablet className="w-4 h-4 text-amber-500" />
<Text size="sm">Tablet</Text>
</div>
<Text size="sm" weight="semibold">{insights.device_breakdown.counts.tablet} ({insights.device_breakdown.percentages.tablet}%)</Text>
</div>
</div>
</Card>
</>
)}
</div>
)}
</div>
);
}
export default function Demo({ viewer, workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
return (
<Shell domains={DOMAINS} user={viewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/tracking" hasTour={false} hideChrome={galleryMode}>
<TrackingDashboardPage {...page} />
</Shell>
);
}
Component tree, from the recipe
<Route /tracking> PrivateRoute (children)
Navigation = AppShell
TrackingDashboard
Header Badge "Analytics" + Heading "Tracking & Analytics" + Text
date-range dropdown (Heute | 7/14/30/90 Tage) + Button "Aktualisieren" (refreshing) + Button "Einstellungen" -> /tracking/settings
Tabs Live-Daten (unread-count badge when hasData) | Insights | Explore | Installation
[tab=data]
[loading] Card > RefreshCw spin (before stats resolve, line 686 to 691)
[!hasData] Card: Activity icon, "Noch keine Daten", Button "Zur Installation" -> setActiveTab('setup') (line 692 to 705)
[hasData] 5x metric Card (Sessions, Besucher, Kontakte, Avg. Dauer, Bounce Rate; each insights-with-stats-fallback)
[timeseries.series.length>0] AreaChart Card: sessions/page_views/contacts (recharts, line 768 to 820)
[timeseries] BarChart Card x2: hourly, daily distribution (line 823 to 870)
[insights.traffic_sources] PieChart Card (channels) + table Card (sources) (line 873 to 940)
[campaigns.length>0] table Card: Campaign Performance (line 943 to 999)
Sessions Card: expandable rows -> session events list, pagination (line 1002 to 1132, always rendered)
[landingPages.length>0] table Card: Landing Pages with proportional bar (line 1135 to 1181)
[tab=explore] ScribeExplore
[!schema && !schemaError] Card > RefreshCw spin
[schemaError] Card: AlertTriangle, retry Button
[schema] mode toggle (Abfrage | Funnel) -> ScribeQueryBuilder + QueryResultsPanel | FunnelBuilder
[tab=setup] 3x feature Card (Attribution, Conversion Tracking, DSGVO) + Site-ID Card (skeleton until resolved)
+ Installation Card (CodeBlock or pulsing skeleton) + JS API Card (identify/track examples) + Pixel/Conversions CTA Card
[tab=insights] [!insights] Card > RefreshCw spin
[insights] 4x metric Card + Empfehlungen Card (insights.insights) + Conversion Funnel Card + Engagement Card + Device Card The rendering is the live rebuild tracking-dashboard/demo.tsx, built from @prima-modules/alchemy-ui and rendered by the docs host as one frame per state. The page's five independent reads are props (loading, trackingSiteId, stats, insights, timeseries, sessions/sessionsTotal, campaigns, landingPages); activeTab is a prop that seeds local state (Data model); the tree is the frontmatter's. The console is rendered inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx), on /tracking, so "Insights" is the active rail entry (nav.tsx, DOMAINS, id insights, matching 62ba4fc's own nav.tsx line 190 to 203).
States (rendering.states, in order): default (Data tab, fully populated: five metric cards, the time-series and hourly/daily charts, the traffic-source pie and table, a three-row campaign table, four sessions with one pre-expanded into its own event list, a four-row landing-page table); loading (the shell and tab bar already on screen, header badges suppressed since stats is still null, a single spinner card where the Data tab's body will be, trackingSiteId still ''); empty (a fresh workspace: stats.totals.events === 0 so the tab effect has surfaced Setup, but trackingSiteId is already populated because the tenant registered a site before installing the snippet, so the install code block is real and copyable, not the pulsing skeleton); error (insights: null while stats, timeseries, sessions and campaigns all loaded normally; the Data tab's own .catch boundary in action: the metric cards fall back to stats.totals, the traffic-source card is simply absent since insights?.traffic_sources is falsy, and nothing red appears anywhere, per the Application logic invariant that a failed read degrades its own section only); setup (trackingSiteId: '' that never resolves, not merely mid-fetch; the same pulsing "Lade Site-ID..." placeholder loading would show, but here it is permanent, the genuine "no site_id configured yet" case the task brief asks for, and a real, measured gap: nothing on the page tells these two apart, Gotchas). No state selects activeTab: 'insights' or 'explore'; Explore's own three sub-states (schema loading, schema error, ready) and the Insights tab's own !insights spinner are real, coded branches (kept in the demo for a reader to select via the activeTab prop) but outside the five states the gate renders, a genuine, documented gap, not an invented sixth or seventh state (matching the convention subscription-manager's Frontend section sets for its own isNearLimit 80-to-95 range).
Tokens and classes as the source uses them: Card glass for every top-level section, padding="md" for the five-across metric row and the three Setup feature cards, padding="lg" everywhere else; the brand purple #6844C4 used directly as a hex literal throughout (icons, active-tab underline, chart strokes, the traffic-quellen highlight bar), not a token; the same "hardcoded hex, not --brand-*" pattern worth flagging alongside dark:-dead classes (none of which this page actually carries, unlike subscription-manager; every class here is a plain, undecorated Tailwind utility, so there is nothing to drop for the light-only docs host). Badge primary (the "Analytics" pill, the identified-session pill), secondary (channel pills, ad-platform pills), success/warning/info variants keyed off insight.type on the Insights tab.
Responsive: max-w-6xl overall (wider than subscription-manager's max-w-4xl, this page has tables); the metric row is grid-cols-2 under md, grid-cols-5 at md and up; every chart pair (timeseries/hourly-daily/traffic-sources) stacks to one column below md; the sessions list's header row is hidden below md (the row grid itself keeps its six fixed-width columns at every size, so a narrow viewport compresses rather than reflows, a genuine responsive gap the source carries, reproduced not fixed). Empty state: empty, the "Noch keine Daten" card with its "Zur Installation" CTA. Error state: error, per the Frontend states description above; there is no visually distinct error card anywhere in the source (Application logic invariant). Loading state: loading, the one spinner card; a dateRange change or a manual refresh shows no loading indicator anywhere (refreshing only spins the header button's icon), so the tables and charts appear to update instantly or not at all with no in-between frame; not demoed as a sixth state, since it is a difference of milliseconds in the real app, not a distinct render.
What the demo deliberately does not render: recharts' AreaChart/BarChart/PieChart (rebuilt as inline SVG, MiniAreaChart/MiniBarChart/MiniDonutChart, header comment); the hover Tooltip on any of the three charts, since a static frame has no hover; navigator.clipboard.writeText's actual clipboard write (the handler runs, copiedId still flips, but nothing leaves the page); the window.location.href-style navigation useNavigate would perform on "Einstellungen"/"Konfigurieren" (inert buttons, same convention as subscription-manager's un-mounted SubscriptionModal triggers); ScribeQueryBuilder, QueryResultsPanel and FunnelBuilder (965 lines together, none importable, condensed into one representative query panel and one representative funnel-step list inside Explore, demo header comment); the setInterval(fetchStats, 60000) poll (a demo is a single static frame, not a running page). All are documented in the demo's own header comment.
Security & tenancy
What the browser may hold: S2's two tokens only. The fetched stats, insights, sessions, campaigns and landing pages are the tenant's own (scoped server-side, Application logic) and stay in memory, not persisted; import.meta.env.VITE_API_URL is the only baked value (used to build the install snippet's src/data-api-url, themselves rendered as page content, not sent anywhere). Origin and CSRF: the api singleton's Bearer header, same as S2. Surface gating: every route this page calls except /config's own inner Depends(get_current_user) quirk (API surface) sits under the same Depends(get_current_user) gate every other Labs page uses; POST /api/scribe/event itself (the beacon ingest this dashboard reads the output of, not a call this page makes) is surface_exempt("public", ...) and carries none of this page's auth, by design (event-ingestion-and-sessions's own Security and tenancy).
Tenancy here is resolved per-request through require_site_id_or_admin, not through currentWorkspaceId: a workspace switch in the shell rail has no effect on which site's data this page shows unless that switch also changes which site_id(s) the signed-in user's account resolves to server-side; the same kind of surprise subscription-manager's Gotchas documents for its own per-user (not per-workspace) tenancy, here per-site-membership instead. Hidden by CSS versus enforced: nothing on this page renders another tenant's identifiers; every table and card is already scoped by the server response it reads. Attacker-controlled input the page sends: site_id as an optional query param on six of the eight routes, defended by require_site_id_or_admin re-deriving the caller's actual allowed site set server-side rather than trusting the param for a non-admin caller (walked in full in event-ingestion-and-sessions). What the page renders from the server unescaped: session.landing_page, session.source_label, campaign.campaign, landingPage.landing_page and every event's page_path/page_title are all rendered as plain React children; React escapes every string and no dangerouslySetInnerHTML appears anywhere in this file.
Tests
- Colocated with the demo:
tracking-dashboard/demo.test.tsx(vitest, jsdom,@testing-library/react),afterEach(cleanup)per test-layout: the five metric cards readinsights.summary.*indefaultand fall back tostats.totals.*inerror; the traffic-source card is absent inerrorand present indefault;emptyrenders the Setup tab with a real code block,setuprenders the Setup tab with the pulsing site-ID placeholder;loadingrenders exactly one spinner and no metric cards; no state's markup contains an access- or refresh-token shape. - The kit's render smoke:
node tooling/verify/render-smoke.mjs analytics-tracking/ui/tracking-dashboardrenders all five states withrenderToStringandfetchstubbed; verified 2026-09-12, 0 failures (Acceptance checks). - Labs at 62ba4fc has no component test of
TrackingDashboard.tsxorScribeExplore.tsx(grep -rl "TrackingDashboard\|ScribeExplore" frontend/src --include='*.test.*'is empty) and no Playwright spec for/tracking. Backend coverage of the eight routes this page calls belongs totracking-insights-dashboard's and the other fivebackend_modulesrecipes' own suites, not to this page. - What a new app adds: a jsdom test that a total
/statsfailure and a genuine zero-event workspace render the identical Data tab body, the gap Application logic step 2 documents; a test that/insights/dashboardrejecting still renders the metric cards fromstatsalone; a Playwright spec against a seeded workspace that installs the real snippet and asserts the Data tab eventually replaces "Noch keine Daten" with real numbers, which would catch thetrackingSiteId-empty-forever ambiguity this recipe's Gotchas names.
Deploy & ops
Build target: the Labs Vite bundle (S2), a lazy chunk (App.tsx, lazy(() => import('./pages/TrackingDashboard'))). Env baked at build: VITE_API_URL, S2's own. CSP and CORS: nothing of its own; all eight routes answer on the API origin under the SPA's existing allowance. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate here directly; the schema these routes read (tracking_sites, tracking_sessions, tracking_events, tracking_contacts and the warm-cache tables) belongs to the six backend_modules recipes. Argus: a synthetic tenant session calling GET /api/scribe/stats returning 200 with a totals object is this page's liveness; the hourly warm-cache job warm_scribe_reports (tracking_router.py 2834 onward) is tracking-insights-dashboard's own ops concern, not this page's.
Gotchas
- 2026-09-12 (measured at 62ba4fc):
TrackingStats.event_counts,.page_views,.recent_eventsand.recent_contacts(lines 78 to 95) are fetched and fully typed but no JSX in this 1517-line file reads any of the four; the same class of dead-field-on-a-live-fetch subscription-manager'sCardContentimport documents, here on the response shape itself rather than an unused import. - 2026-09-12 (measured at 62ba4fc):
deleteContact(416 to 427) is declared, confirms, callsDELETE /api/scribe/contact/{email}and refetches stats on success; a complete, working handler; but no button anywhere in the file calls it. Whatever UI used to listrecent_contacts(the field above) and offer a delete action appears to have been removed without removing the handler that served it. - 2026-09-12 (measured at 62ba4fc):
pixelIds(state, line 252; populated byfetchTrackingConfig, 279 to 285, frommeta_pixel_id/google_ads_id/google_ads_label/linkedin_partner_id/bing_uet_tag_id) is set once and never read again.pixelAttrs(429 to 433); the array that would have spliced those ids into the tracking snippet asdata-*attributes; is hardcoded to an empty array with a comment explaining why: client-side ad tags were removed fromscribe.js, ad-platform ids now live in server-side config for CAPI forwarding only (conversion-events-and-consent-gate). BothpixelIdsand the dead splice point are leftovers of that removal that nobody deleted. - 2026-09-12 (measured at 62ba4fc):
DashboardInsights.top_pages(124 to 127) is fetched (_compute_insights_reporton the backend genuinely returns it) and typed but, like the fourTrackingStatsfields above, never rendered; a tenant's actual top-pages-by-view list is computed and shipped to the browser every time this tab loads and then discarded. - 2026-09-12 (measured at 62ba4fc): there is no dedicated error UI anywhere in this file; no red card, no toast, nothing; unlike
billing-credits/ui/subscription-manager's top-level error card for the same class of failure. A total/statsread failure (loadingresolvesfalse,statsstaysnull) renders pixel-identical to a correctly-loading, genuinely empty workspace's Data tab (!hasDatais true either way), distinguishable only by the Setup-tab auto-redirect not firing on anullstats(Application logic step 2 to 3), which a tenant has no way to notice from the screen alone. - 2026-09-12 (measured at 62ba4fc): the site-ID card's "Lade Site-ID..." placeholder (1240) and the Installation card's pulsing skeleton (1259 to 1265) are identical whether
trackingSiteIdis mid-fetch or has genuinely never been set (/api/scribe/confignever returning asite_idat all, a real, reachable case pertracking-settings-and-site-registration's own notes on that route). Nothing on this page distinguishes "still loading" from "no site was ever provisioned", thesetupstate's whole reason for existing separately fromloadingin this recipe. - 2026-09-12 (measured at 62ba4fc):
#6844C4(the brand purple) is a hardcoded hex literal at well over a dozen call sites; icon fills, the active-tab underline, three chart stroke colors, the traffic-quellen highlight bar background; never a CSS custom property or a Tailwind token; a rebrand touches every one of those sites by hand. - 2026-09-12 (measured at 62ba4fc):
formatRelativeTime(492 to 505) is defined and used for every session/event timestamp on the Data tab; it is not used anywhere on the Insights tab, which has no timestamps of its own to format, so this is not a bug, only worth noting sinceformatTimestamp(its own fallback past 7 days) is the one actually reused inside the demo's fixed-UTC rebuild (demo header comment).
Reference implementation
Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the repo root:
frontend/src/pages/TrackingDashboard.tsx(1517 lines): interfaces (72 to 236), component state (242 to 267), the six fetchers (273 to 361), the two tab-driving effects and the mount effect with its poll (363 to 386),handleRefresh/toggleSessionExpand/copyCode/deleteContact(388 to 427), the tracking-snippet and JS-API code samples (434 to 458),CodeBlock(460 to 477), the four date/duration formatters andChartTooltip(481 to 547),peakHour/peakDay(549 to 559), the header and tab bar (567 to 679), the Data tab (684 to 1185), the Explore tab mount (1190), the Setup tab (1195 to 1303), the Insights tab (1308 to 1511).frontend/src/components/scribe/ScribeExplore.tsx(166 lines): schema fetch (34 to 47), the query/funnel mode toggle and its three render branches (84 to 163).frontend/src/components/scribe/ScribeQueryBuilder.tsx(339 lines),QueryResultsPanel.tsx(280 lines),FunnelBuilder.tsx(346 lines): not walked line by line here, condensed in the demo (Frontend).frontend/src/components/Navigation.tsx: the delegator toAppShell, unchanged from subscription-manager's reference.frontend/src/components/nav.tsx: theinsightsdomain, lines 190 to 203.frontend/src/App.tsx: the lazy import and the/trackingroute, line 460 to 466.frontend/src/services/api.ts,services/scribeService.ts: the singleton and the Explore tab's query/funnel/export client functions.backend/api/routers/tracking_router.py:get_tracking_config(270 to 314),get_tracking_stats(1680 to 1706),_compute_insights_report/get_dashboard_insights(2824 to 2921),get_tracking_timeseries(2408 to 2452),get_sessions_list(2454 to 2593),get_session_events(2594 to 2643),get_campaign_performance(2644 to 2738),get_landing_page_performance(2739 to 2823),delete_tracking_contact(1988 to 2075),require_site_id_or_admin(177 to 209).backend/app_fastapi.py: this router's mount and surface gate (referenced, not line-anchored here; seeevent-ingestion-and-sessions's Reference implementation for the exact lines).- The demo:
modules/analytics-tracking/ui/tracking-dashboard/demo.tsx; the shell it renders in:modules/ui-design-system/ui/appshell-navigation/demo.tsxandnav.tsx.
Deviations of the reference from this recipe, all dated 2026-09-12: the five dead fields/functions (event_counts, page_views, recent_events, recent_contacts, top_pages, deleteContact, pixelIds); no error UI anywhere on the page; the permanently-empty vs. still-loading trackingSiteId ambiguity; hardcoded #6844C4 hex throughout.
Acceptance checks
From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, a tenant session with a registered site and seeded events), or from a new app built to this recipe:
grep -c "console.error" src/pages/TrackingDashboard.tsxprints7at 62ba4fc (every fetcher butdeleteContact, whichalerts instead);grep -n "recent_events\|recent_contacts\|top_pages" src/pages/TrackingDashboard.tsx src/components/scribe/ScribeExplore.tsxmatches only the interface declarations, never a JSX usage.grep -n "api\.\(get\|delete\)(" src/pages/TrackingDashboard.tsxprints exactly eight lines naming/api/scribe/config,/api/scribe/stats,/api/scribe/insights/dashboard,/api/scribe/timeseries,/api/scribe/sessions,/api/scribe/sessions/${sessionId}/events,/api/scribe/campaigns,/api/scribe/landing-pages,/api/scribe/contact/${...}.- Browser walk-through, signed in as a tenant with a registered site and events in the last 7 days: (a) open
/tracking, observe the shell and tab bar paint first, then a single spinner where the metric row will be, then five populated cards; (b) unset the payment... n/a for this page; instead, block/api/scribe/insights/dashboardat the network layer and reload: observe the five metric cards still populate fromstats, the traffic-source card is simply absent, and the Insights tab spins forever, all with no error banner anywhere (theerrorstate's real-world equivalent); (c) expand a session row, observe its events list load once, collapse and re-expand it, observe no second network call; (d) switch the date range to "90 Tage", observe the charts and tables update with no loading indicator anywhere but the header's refresh icon (which does not even spin, since this was the dropdown, not the button). - Demo gate:
node tooling/verify/render-smoke.mjs analytics-tracking/ui/tracking-dashboardrenders all five states withrenderToString, 0 failures, 2026-09-12. - Verifier:
node tooling/verify/compare.mjs analytics-tracking/ui/tracking-dashboard --state default --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json(a tenant session with a registered site and 7 days of seeded events) writes a report withpage_errors: [];mismatch_ratiois read for the header, tab bar and metric row only, since the app's charts, sessions, campaigns and landing pages reflect the harness's seeded account and the demo's are sample data.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.