Prima Get an API key

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
  • alchemy_labs/frontend/src/pages/TrackingDashboard.tsx
  • alchemy_labs/frontend/src/components/scribe/ScribeExplore.tsx
  • alchemy_labs/frontend/src/components/scribe/ScribeQueryBuilder.tsx
  • alchemy_labs/frontend/src/components/scribe/QueryResultsPanel.tsx
  • alchemy_labs/frontend/src/components/scribe/FunnelBuilder.tsx
  • alchemy_labs/frontend/src/components/Navigation.tsx
  • alchemy_labs/frontend/src/components/nav.tsx
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/frontend/src/services/scribeService.ts
  • alchemy_labs/backend/api/routers/tracking_router.py
  • alchemy_labs/backend/app_fastapi.py
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)RouteAuth / tenancyNotes
api.get('/api/scribe/config') (275)GET /api/scribe/config (tracking_router.py 270 to 314)Depends(get_current_user), WorkspaceIDsite_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_adminhourly-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)samerun 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)samegranularity 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)samepage_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)sameonly 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)samedeleteContact 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

  1. TrackingDashboard mounts with loading: true, activeTab: 'data'; one useEffect (363 to 372) fires all six independent reads (fetchTrackingConfig, fetchStats, fetchTimeseries, fetchSessions, fetchCampaigns, fetchLandingPages) plus a 60-second setInterval(fetchStats, 60000) that re-polls /stats and /insights/dashboard for the life of the mount. While loading, the header and tab bar are already on screen; only the Data tab's body is a single spinner card (686 to 691).
  2. fetchStats (301 to 322) runs /stats and /insights/dashboard in one Promise.all, but the second call is wrapped in its own .catch(() => ({ data: null })) (305): a refusal or throw from /insights/dashboard alone never rejects the Promise.all and never touches stats. If /stats itself throws, the whole try block's catch fires and neither stats nor insights is touched (316 to 317); loading/refreshing still resolve to false in the finally (318 to 321), so a total /stats failure renders identically to a genuinely empty workspace's Data tab (!hasData, since stats is falsy either way), except it does not trigger the setup-tab redirect below, because that effect's guard is if (stats && ...); stats stays null, not an {totals: {events: 0}} object, so the guard is false and activeTab never 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.
  3. 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, inside fetchStats itself (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 to data while still at zero events is not fought back to setup by the 60-second poll; that guard only re-triggers off a stats response, not off activeTab changing on its own.
  4. dateRange changing (374 to 380) refetches timeseries, stats+insights, sessions (reset to page 1) and both tables; it does not refetch trackingSiteId (config is fetched once, on mount, only).
  5. handleRefresh (388 to 395) is the same five-call batch as the dateRange effect, manually triggered, with refreshing driving the header button's spin.
  6. Session expand/collapse (toggleSessionExpand, 397 to 404): collapsing never discards the fetched events (sessionEvents keyed by id, kept for the life of the mount); expanding a session already in sessionEvents fetches nothing (336).
  7. copyCode (410 to 414) writes to the clipboard and flips copiedId for 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 fixed formatTimestamp past 7 days.
  8. ScribeExplore (its own file) fetches getScribeQuerySchema() once on mount, independent of everything above; its own mode toggle (query/funnel) and its handleRun/handleExportCsv are entirely local to the Explore tab and untouched by dateRange, activeTab switching 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 two useMemos 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's site_id query param, if the client ever sent one, is not trusted over the server's own resolution (tracking_router.py 177 to 209, referenced here, walked in event-ingestion-and-sessions's Security and tenancy).
  • A read failing degrades its own section, never the page: the independent .catch on /insights/dashboard (Application logic step 2) and the plain console.error on 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 null one: the effect's stats && 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 a dateRange change (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.

Data tab, populated: stats, timeseries and insights all loaded for the active 7-day range default server rendered, hydrated in view
Analytics

Tracking & Analytics

Verfolge Besucher, erfasse Leads und verstehe deine Marketing-Performance.

1024

Sessions

842

Besucher

96

Kontakte

2m 22s

Avg. Dauer

38.6%

Bounce Rate

Besucher & Events

7 Tage

06.09.07.09.08.09.09.09.10.09.11.09.12.09.
SessionsSeitenaufrufeKontakte

Wann kommen Besucher?

Hauptzeit: 14 Uhr (150 Events)

0003060912151821

Wochentage

Aktivster Tag: Fr (302 Events)

MoDiMiDoFrSaSo

Traffic-Kanäle

  • Organic35%
  • Direct23%
  • Paid18%
  • Social14%
  • Email10%

Traffic-Quellen

Quelle / MediumSessionsKanal

google / organic

356

Organic

(direct) / (none)

240

Direct

google / cpc

188

Paid

linkedin / social

140

Social

newsletter / email

100

Email

Campaign Performance

CampaignSessionsBesucherKontakteConv. RateBouncePlattformen

webinar-herbst

2141983114.5%29.4%
G

launch-q3

16615095.4%61.2%
Li

retargeting-preise

888030.0%74.9%
M

Sessions

214 gesamt

page_view/wissen/website-tracking-dsgvo12.09.26 07:40
page_view/wissen/scribe-vs-ga412.09.26 07:41
form_submitted/wissen/scribe-vs-ga412.09.26 07:42
button_clicked/wissen/scribe-vs-ga412.09.26 07:43

Seite 1 von 15

Landing Pages

SeiteSessionsBounce RateAvg. DauerKontakte

/webinar

21429.4%4m 28s31

/

19844.1%1m 36s12

/preise

15074.9%31s3

/wissen/website-tracking-dsgvo

9233.7%3m 31s18
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 loading server rendered, hydrated in view
Analytics

Tracking & Analytics

Verfolge Besucher, erfasse Leads und verstehe deine Marketing-Performance.

Fresh workspace: zero events, the tab effect surfaces Setup, but the tracking script is already installed and ready to copy empty server rendered, hydrated in view
Analytics

Tracking & Analytics

Verfolge Besucher, erfasse Leads und verstehe deine Marketing-Performance.

Marketing Attribution

UTM-Parameter, Referrer, Landing Pages

Conversion Tracking

Events an Meta & Google senden

DSGVO-Konform

First-Party Tracking, kein Cookie-Banner nötig

Deine Site-ID

site_2b6e10cd

Installation

Füge das Tracking-Script im <head> deiner Website ein:

<!-- 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='https://api.example-labs.test/static/scribe.js';
    j.setAttribute('data-site-id', 'site_2b6e10cd');
    j.setAttribute('data-api-url', 'https://api.example-labs.test');
    f.parentNode.insertBefore(j,f);
  })(window,document,'script','AlchemyScribe');
</script>

JavaScript API

identify() - Benutzer identifizieren

Verknüpfe einen anonymen Besucher mit einer E-Mail-Adresse (z.B. nach Formular-Absendung).

// Identifiziere einen Benutzer (z.B. nach Formular-Absendung)
window.AlchemyScribe.identify('[email protected]', {
  name: 'Max Mustermann',
  company: 'Musterfirma AG'
});

track() - Events erfassen

Tracke benutzerdefinierte Events wie Button-Klicks, Downloads oder andere Aktionen.

// Tracke ein benutzerdefiniertes Event
window.AlchemyScribe.track('button_clicked', {
  button_id: 'cta-hero',
  page: window.location.pathname
});

Meta & Google Conversions

Konfiguriere deine Pixel-IDs, um Conversions automatisch zu senden.

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 error server rendered, hydrated in view
Analytics

Tracking & Analytics

Verfolge Besucher, erfasse Leads und verstehe deine Marketing-Performance.

842

Sessions

0

Besucher

96

Kontakte

0s

Avg. Dauer

0%

Bounce Rate

Besucher & Events

7 Tage

06.09.07.09.08.09.09.09.10.09.11.09.12.09.
SessionsSeitenaufrufeKontakte

Wann kommen Besucher?

Hauptzeit: 14 Uhr (150 Events)

0003060912151821

Wochentage

Aktivster Tag: Fr (302 Events)

MoDiMiDoFrSaSo

Campaign Performance

CampaignSessionsBesucherKontakteConv. RateBouncePlattformen

webinar-herbst

2141983114.5%29.4%
G

launch-q3

16615095.4%61.2%
Li

retargeting-preise

888030.0%74.9%
M

Sessions

214 gesamt

Seite 1 von 15

Landing Pages

SeiteSessionsBounce RateAvg. DauerKontakte

/webinar

21429.4%4m 28s31

/

19844.1%1m 36s12

/preise

15074.9%31s3

/wissen/website-tracking-dsgvo

9233.7%3m 31s18
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 setup server rendered, hydrated in view
Analytics

Tracking & Analytics

Verfolge Besucher, erfasse Leads und verstehe deine Marketing-Performance.

Marketing Attribution

UTM-Parameter, Referrer, Landing Pages

Conversion Tracking

Events an Meta & Google senden

DSGVO-Konform

First-Party Tracking, kein Cookie-Banner nötig

Deine Site-ID

Lade Site-ID...

Installation

Füge das Tracking-Script im <head> deiner Website ein:

JavaScript API

identify() - Benutzer identifizieren

Verknüpfe einen anonymen Besucher mit einer E-Mail-Adresse (z.B. nach Formular-Absendung).

// Identifiziere einen Benutzer (z.B. nach Formular-Absendung)
window.AlchemyScribe.identify('[email protected]', {
  name: 'Max Mustermann',
  company: 'Musterfirma AG'
});

track() - Events erfassen

Tracke benutzerdefinierte Events wie Button-Klicks, Downloads oder andere Aktionen.

// Tracke ein benutzerdefiniertes Event
window.AlchemyScribe.track('button_clicked', {
  button_id: 'cta-hero',
  page: window.location.pathname
});

Meta & Google Conversions

Konfiguriere deine Pixel-IDs, um Conversions automatisch zu senden.

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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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 protected]', {
  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: '[email protected]', source: 'google / organic', events: 12 },
                  { email: '[email protected]', 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">&lt;head&gt;</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
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

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 read insights.summary.* in default and fall back to stats.totals.* in error; the traffic-source card is absent in error and present in default; empty renders the Setup tab with a real code block, setup renders the Setup tab with the pulsing site-ID placeholder; loading renders 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-dashboard renders all five states with renderToString and fetch stubbed; verified 2026-09-12, 0 failures (Acceptance checks).
  • Labs at 62ba4fc has no component test of TrackingDashboard.tsx or ScribeExplore.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 to tracking-insights-dashboard's and the other five backend_modules recipes' own suites, not to this page.
  • What a new app adds: a jsdom test that a total /stats failure and a genuine zero-event workspace render the identical Data tab body, the gap Application logic step 2 documents; a test that /insights/dashboard rejecting still renders the metric cards from stats alone; 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 the trackingSiteId-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_events and .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's CardContent import 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, calls DELETE /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 list recent_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 by fetchTrackingConfig, 279 to 285, from meta_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 as data-* attributes; is hardcoded to an empty array with a comment explaining why: client-side ad tags were removed from scribe.js, ad-platform ids now live in server-side config for CAPI forwarding only (conversion-events-and-consent-gate). Both pixelIds and 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_report on the backend genuinely returns it) and typed but, like the four TrackingStats fields 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 /stats read failure (loading resolves false, stats stays null) renders pixel-identical to a correctly-loading, genuinely empty workspace's Data tab (!hasData is true either way), distinguishable only by the Setup-tab auto-redirect not firing on a null stats (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 trackingSiteId is mid-fetch or has genuinely never been set (/api/scribe/config never returning a site_id at all, a real, reachable case per tracking-settings-and-site-registration's own notes on that route). Nothing on this page distinguishes "still loading" from "no site was ever provisioned", the setup state's whole reason for existing separately from loading in 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 since formatTimestamp (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 and ChartTooltip (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 to AppShell, unchanged from subscription-manager's reference.
  • frontend/src/components/nav.tsx: the insights domain, lines 190 to 203.
  • frontend/src/App.tsx: the lazy import and the /tracking route, 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; see event-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.tsx and nav.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:

  1. grep -c "console.error" src/pages/TrackingDashboard.tsx prints 7 at 62ba4fc (every fetcher but deleteContact, which alerts instead); grep -n "recent_events\|recent_contacts\|top_pages" src/pages/TrackingDashboard.tsx src/components/scribe/ScribeExplore.tsx matches only the interface declarations, never a JSX usage.
  2. grep -n "api\.\(get\|delete\)(" src/pages/TrackingDashboard.tsx prints 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/${...}.
  3. 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/dashboard at the network layer and reload: observe the five metric cards still populate from stats, the traffic-source card is simply absent, and the Insights tab spins forever, all with no error banner anywhere (the error state'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).
  4. Demo gate: node tooling/verify/render-smoke.mjs analytics-tracking/ui/tracking-dashboard renders all five states with renderToString, 0 failures, 2026-09-12.
  5. 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 with page_errors: []; mismatch_ratio is 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.