From 70990e0d3450ec01eee1d077816cc647ba2fb372 Mon Sep 17 00:00:00 2001 From: Bruce Denham Date: Thu, 30 Apr 2026 08:26:20 -0500 Subject: [PATCH 1/8] first wip --- .env.example | 8 + .github/workflows/preview-on-pages.yml | 3 + analytics/setup.sql | 155 +++++++++ astro.config.mjs | 10 + package.json | 2 + pnpm-lock.yaml | 232 ++++++++++++++ src/components/Analytics/Dashboard.tsx | 367 +++++++++++++++++++++ src/env.d.ts | 5 + src/pages/analytics/index.astro | 424 +++++++++++++++++++++++++ src/scripts/analytics.ts | 227 +++++++++++++ 10 files changed, 1433 insertions(+) create mode 100644 .env.example create mode 100644 analytics/setup.sql create mode 100644 src/components/Analytics/Dashboard.tsx create mode 100644 src/pages/analytics/index.astro create mode 100644 src/scripts/analytics.ts diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..55578f65e --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Custom Analytics — Supabase connection +# Create a free project at https://supabase.com, run analytics/setup.sql in the +# Supabase SQL Editor, then copy the values from Project Settings > API. +# +# The PUBLIC_ prefix exposes these values to the browser at runtime. +# The anon key is safe to expose — Supabase RLS policies control what it can access. +PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co +PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... diff --git a/.github/workflows/preview-on-pages.yml b/.github/workflows/preview-on-pages.yml index 3a9019c00..c97fb882f 100644 --- a/.github/workflows/preview-on-pages.yml +++ b/.github/workflows/preview-on-pages.yml @@ -34,6 +34,9 @@ jobs: run: pnpm install - name: Build with Astro id: build + env: + PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }} + PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.PUBLIC_SUPABASE_ANON_KEY }} run: NODE_ENV=github VITE_GITHUB_BASE_PATH=${{ steps.pages.outputs.base_path }} SKIP_LINK_VALIDATION=true pnpm astro build \ --site "${{ steps.pages.outputs.origin }}" - name: Upload static files as artifact diff --git a/analytics/setup.sql b/analytics/setup.sql new file mode 100644 index 000000000..063d8b327 --- /dev/null +++ b/analytics/setup.sql @@ -0,0 +1,155 @@ +-- ============================================================================= +-- Custom Analytics — Supabase Setup +-- ============================================================================= +-- Run this entire file in the Supabase SQL Editor once, after creating your +-- free project at https://supabase.com. Copy the Project URL and anon key +-- from Project Settings > API and add them to your .env file. +-- ============================================================================= + + +-- --------------------------------------------------------------------------- +-- 1. Tables +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS page_views ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + session_id TEXT NOT NULL, + visitor_id TEXT NOT NULL, + url TEXT NOT NULL, + referrer TEXT, + title TEXT, + timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL, + duration_seconds INTEGER, + is_bounce BOOLEAN DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_pv_timestamp ON page_views (timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_pv_url ON page_views (url); +CREATE INDEX IF NOT EXISTS idx_pv_session ON page_views (session_id); +CREATE INDEX IF NOT EXISTS idx_pv_visitor ON page_views (visitor_id); + + +CREATE TABLE IF NOT EXISTS events ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + session_id TEXT NOT NULL, + visitor_id TEXT NOT NULL, + url TEXT NOT NULL, + event_type TEXT NOT NULL, -- click | scroll | form | custom + event_name TEXT, + event_data JSONB, + timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ev_timestamp ON events (timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_ev_type ON events (event_type); + + +-- --------------------------------------------------------------------------- +-- 2. Row Level Security +-- --------------------------------------------------------------------------- + +ALTER TABLE page_views ENABLE ROW LEVEL SECURITY; +ALTER TABLE events ENABLE ROW LEVEL SECURITY; + +-- Tracking script inserts page views and later patches duration/bounce +CREATE POLICY "anon_insert_page_views" ON page_views + FOR INSERT TO anon WITH CHECK (true); + +CREATE POLICY "anon_update_page_views" ON page_views + FOR UPDATE TO anon USING (true) WITH CHECK (true); + +CREATE POLICY "anon_select_page_views" ON page_views + FOR SELECT TO anon USING (true); + +-- Tracking script inserts events +CREATE POLICY "anon_insert_events" ON events + FOR INSERT TO anon WITH CHECK (true); + +CREATE POLICY "anon_select_events" ON events + FOR SELECT TO anon USING (true); + + +-- --------------------------------------------------------------------------- +-- 3. Aggregation Views +-- --------------------------------------------------------------------------- + +-- Daily summary — used for the trend chart and summary card totals +CREATE OR REPLACE VIEW analytics_summary AS +SELECT + (timestamp::DATE) AS day, + COUNT(DISTINCT session_id) AS visits, + COUNT(*) AS page_views, + COUNT(DISTINCT visitor_id) AS unique_visitors, + COALESCE( + ROUND(AVG(duration_seconds)::NUMERIC, 0), 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * COUNT(*) FILTER (WHERE is_bounce = TRUE) + / NULLIF(COUNT(DISTINCT session_id), 0), + 1 + ), + 0 + ) AS bounce_rate_pct +FROM page_views +GROUP BY timestamp::DATE +ORDER BY day DESC; + + +-- Top pages by view count +CREATE OR REPLACE VIEW top_pages AS +SELECT + url, + COUNT(*) AS page_views, + COUNT(DISTINCT visitor_id) AS unique_visitors, + COALESCE( + ROUND(AVG(duration_seconds)::NUMERIC, 0), 0 + )::INTEGER AS avg_duration_seconds +FROM page_views +GROUP BY url +ORDER BY page_views DESC +LIMIT 50; + + +-- 30-day totals for summary cards — accurate unique counts across the window +CREATE OR REPLACE VIEW analytics_totals_30d AS +SELECT + COUNT(DISTINCT session_id) AS visits, + COUNT(*) AS page_views, + COUNT(DISTINCT visitor_id) AS unique_visitors, + COALESCE( + ROUND(AVG(duration_seconds)::NUMERIC, 0), 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * COUNT(*) FILTER (WHERE is_bounce = TRUE) + / NULLIF(COUNT(DISTINCT session_id), 0), + 1 + ), + 0 + ) AS bounce_rate_pct +FROM page_views +WHERE timestamp >= NOW() - INTERVAL '30 days'; + + +-- Event breakdown by type and name +CREATE OR REPLACE VIEW events_summary AS +SELECT + event_type, + event_name, + COUNT(*) AS event_count +FROM events +GROUP BY event_type, event_name +ORDER BY event_count DESC; + + +-- --------------------------------------------------------------------------- +-- 4. Grant SELECT on views to the anon role +-- --------------------------------------------------------------------------- + +GRANT SELECT ON analytics_summary TO anon; +GRANT SELECT ON analytics_totals_30d TO anon; +GRANT SELECT ON top_pages TO anon; +GRANT SELECT ON events_summary TO anon; diff --git a/astro.config.mjs b/astro.config.mjs index 92940c844..b1ac9fd50 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -80,6 +80,16 @@ async function config() { }, }, }, + { + name: 'custom-analytics', + hooks: { + 'astro:config:setup': ({ injectScript }) => { + // Tracks page views, clicks, scroll depth, and form interactions on every page. + // Dynamic import so analytics loads after the main page bundle without blocking render. + injectScript('page', `void import('/src/scripts/analytics.ts');`); + }, + }, + }, starlight({ editLink: { baseUrl: 'https://github.com/commerce-docs/microsite-commerce-storefront/edit/release/', diff --git a/package.json b/package.json index 6867c3615..b7d3db971 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@graphiql/react": "0.28.2", "@graphiql/toolkit": "0.11.1", "@playform/compress": "^0.2.2", + "@supabase/supabase-js": "^2.105.1", "@types/hast": "^3.0.4", "@types/lodash": "^4.17.21", "@types/react": "19.2.14", @@ -126,6 +127,7 @@ "prettier-plugin-astro": "^0.14.1", "react": "19.2.4", "react-dom": "19.2.4", + "recharts": "^3.8.1", "regenerator-runtime": "^0.14.1", "rehype": "^13.0.2", "sharp": "^0.34.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bad24dee..d713c8e57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@playform/compress': specifier: ^0.2.2 version: 0.2.3(@types/node@25.6.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3) + '@supabase/supabase-js': + specifier: ^2.105.1 + version: 2.105.1 '@types/hast': specifier: ^3.0.4 version: 3.0.4 @@ -175,6 +178,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) regenerator-runtime: specifier: ^0.14.1 version: 0.14.1 @@ -1572,6 +1578,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1730,6 +1747,12 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -1739,6 +1762,33 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@supabase/auth-js@2.105.1': + resolution: {integrity: sha512-zc4s8Xg4truwE1Q4Q8M8oUVDARMd05pKh73NyQsMbYU1HDdDN2iiKzena/yu+yJze3WrD4c092FdckPiK1rLQw==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.105.1': + resolution: {integrity: sha512-dTk1e7oE51VGc1lS2S0J0NLo0Wp4JYChj74ArJKbIWgoWuFwO0wcJYjeyOV3AAEpKst8/LQWUZOUKO1tRXBrpA==} + engines: {node: '>=20.0.0'} + + '@supabase/phoenix@0.4.1': + resolution: {integrity: sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==} + + '@supabase/postgrest-js@2.105.1': + resolution: {integrity: sha512-6SbtsoWC55xfsm7gbfLqvF+yIwTQEbjt+jFGf4klDpwSnUy17Hv5x0Dq52oqwTQlw6Ta0h1D5gTP0/pApqNojA==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.105.1': + resolution: {integrity: sha512-3X3cUEl5cJ4lRQHr1hXHx0b98OaL97RRO2vrRZ98FD91JV/MquZHhrGJSv/+IkOnjF6E2e0RUOxE8P3Zi035ow==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.105.1': + resolution: {integrity: sha512-owfdCNH5ikXXDusjzsgU6LavEBqGUoueOnL/9XIucld70/WJ/rbqp89K//c9QPICDNuegsmpoeasydDAiucLKQ==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.105.1': + resolution: {integrity: sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==} + engines: {node: '>=20.0.0'} + '@tanstack/react-virtual@3.13.23': resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} peerDependencies: @@ -1977,6 +2027,12 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.58.2': resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2650,6 +2706,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2831,6 +2890,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -3260,6 +3322,10 @@ packages: i18next@23.16.8: resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3279,6 +3345,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -4138,6 +4210,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -4196,6 +4280,14 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + recma-build-jsx@1.0.0: resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} @@ -4214,6 +4306,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} @@ -4287,6 +4387,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -4849,6 +4952,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@6.4.2: resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6602,6 +6708,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.3.0(rollup@4.60.1)': @@ -6720,6 +6838,10 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@storybook/global@5.0.0': {} '@storybook/icons@2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -6727,6 +6849,46 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@supabase/auth-js@2.105.1': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.105.1': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.1': {} + + '@supabase/postgrest-js@2.105.1': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.105.1': + dependencies: + '@supabase/phoenix': 0.4.1 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/storage-js@2.105.1': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.105.1': + dependencies: + '@supabase/auth-js': 2.105.1 + '@supabase/functions-js': 2.105.1 + '@supabase/postgrest-js': 2.105.1 + '@supabase/realtime-js': 2.105.1 + '@supabase/storage-js': 2.105.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/virtual-core': 3.13.23 @@ -7005,6 +7167,12 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.0 + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7833,6 +8001,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: @@ -7977,6 +8147,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.46.1: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -8659,6 +8831,8 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 + iceberg-js@0.8.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -8671,6 +8845,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.4: {} + import-meta-resolve@4.2.0: {} imurmurhash@0.1.4: {} @@ -9780,6 +9958,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -9834,6 +10021,26 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.46.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 17.0.2 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -9868,6 +10075,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + regenerator-runtime@0.14.1: {} regex-recursion@6.0.2: @@ -9989,6 +10202,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.1.1: {} + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -10629,6 +10844,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@6.4.2(@types/node@25.6.0)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3): dependencies: esbuild: 0.25.12 diff --git a/src/components/Analytics/Dashboard.tsx b/src/components/Analytics/Dashboard.tsx new file mode 100644 index 000000000..00b90ba5b --- /dev/null +++ b/src/components/Analytics/Dashboard.tsx @@ -0,0 +1,367 @@ +import { createClient } from '@supabase/supabase-js'; +import { useEffect, useState } from 'react'; +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DailySummary { + day: string; + visits: number; + page_views: number; + unique_visitors: number; + avg_duration_seconds: number; + bounce_rate_pct: number; +} + +interface Totals { + visits: number; + page_views: number; + unique_visitors: number; + avg_duration_seconds: number; + bounce_rate_pct: number; +} + +interface TopPage { + url: string; + page_views: number; + unique_visitors: number; + avg_duration_seconds: number; +} + +interface EventRow { + event_type: string; + event_name: string | null; + event_count: number; +} + +// --------------------------------------------------------------------------- +// Supabase client — env vars are replaced at build time by Vite +// --------------------------------------------------------------------------- + +const supabase = createClient( + import.meta.env.PUBLIC_SUPABASE_URL as string, + import.meta.env.PUBLIC_SUPABASE_ANON_KEY as string +); + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function fmtDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return s > 0 ? `${m}m ${s}s` : `${m}m`; +} + +function fmtShortDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function truncateUrl(url: string, max = 60): string { + try { + const u = new URL(url); + const path = u.pathname + (u.search ? u.search : ''); + return path.length > max ? path.slice(0, max) + '…' : path; + } catch { + return url.length > max ? url.slice(0, max) + '…' : url; + } +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function StatCard({ + label, + value, + sub, +}: { + label: string; + value: string | number; + sub?: string; +}) { + return ( +
+ {label} + {value} + {sub && {sub}} +
+ ); +} + +function SectionHeader({ title }: { title: string }) { + return

{title}

; +} + +function LoadingPlaceholder({ rows = 3 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ ))} +
+ ); +} + +function ErrorBanner({ message }: { message: string }) { + return

{message}

; +} + +// --------------------------------------------------------------------------- +// Main Dashboard +// --------------------------------------------------------------------------- + +export default function Dashboard() { + const [totals, setTotals] = useState(null); + const [chartData, setChartData] = useState([]); + const [topPages, setTopPages] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + const [totalsRes, chartRes, pagesRes, eventsRes] = await Promise.all([ + supabase.from('analytics_totals_30d').select('*').single(), + supabase + .from('analytics_summary') + .select('*') + .order('day', { ascending: true }) + .limit(30), + supabase.from('top_pages').select('*').limit(20), + supabase.from('events_summary').select('*').limit(30), + ]); + + if (totalsRes.error) throw totalsRes.error; + if (chartRes.error) throw chartRes.error; + if (pagesRes.error) throw pagesRes.error; + if (eventsRes.error) throw eventsRes.error; + + setTotals(totalsRes.data as Totals); + setChartData((chartRes.data ?? []) as DailySummary[]); + setTopPages((pagesRes.data ?? []) as TopPage[]); + setEvents((eventsRes.data ?? []) as EventRow[]); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to load analytics data.' + ); + } finally { + setLoading(false); + } + } + + load(); + }, []); + + const isConfigured = + import.meta.env.PUBLIC_SUPABASE_URL && + import.meta.env.PUBLIC_SUPABASE_ANON_KEY; + + if (!isConfigured) { + return ( +
+

+ Analytics not configured. Add{' '} + PUBLIC_SUPABASE_URL and{' '} + PUBLIC_SUPABASE_ANON_KEY to your{' '} + .env file, then restart the dev server. +

+
+ ); + } + + return ( +
+ {/* ---- Summary Cards ---- */} + + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : error ? ( + + ) : totals ? ( +
+ + + + + +
+ ) : null} + + {/* ---- Daily Trend Chart ---- */} + + + {loading ? ( +
+ ) : chartData.length === 0 ? ( +

No data yet. Visit a few pages to see the trend.

+ ) : ( +
+ + + + + + fmtShortDate(String(v))} + contentStyle={{ + background: 'var(--chart-tooltip-bg)', + border: '1px solid var(--chart-grid)', + borderRadius: '6px', + fontSize: '13px', + }} + /> + + + + + + +
+ )} + + {/* ---- Top Pages ---- */} + + + {loading ? ( + + ) : topPages.length === 0 ? ( +

No page view data yet.

+ ) : ( +
+ + + + + + + + + + + {topPages.map((page) => ( + + + + + + + ))} + +
PageViewsUnique visitorsAvg. time
+ + {truncateUrl(page.url)} + + {page.page_views.toLocaleString()} + {page.unique_visitors.toLocaleString()} + + {fmtDuration(page.avg_duration_seconds)} +
+
+ )} + + {/* ---- Events Breakdown ---- */} + + + {loading ? ( + + ) : events.length === 0 ? ( +

No events recorded yet.

+ ) : ( +
+ + + + + + + + + + {events.map((ev, i) => ( + + + + + + ))} + +
TypeNameCount
+ + {ev.event_type} + + {ev.event_name ?? '—'}{ev.event_count.toLocaleString()}
+
+ )} +
+ ); +} diff --git a/src/env.d.ts b/src/env.d.ts index acef35f17..d18dfbc7d 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,2 +1,7 @@ /// /// + +interface ImportMetaEnv { + readonly PUBLIC_SUPABASE_URL: string; + readonly PUBLIC_SUPABASE_ANON_KEY: string; +} diff --git a/src/pages/analytics/index.astro b/src/pages/analytics/index.astro new file mode 100644 index 000000000..2151bdfdf --- /dev/null +++ b/src/pages/analytics/index.astro @@ -0,0 +1,424 @@ +--- +import Dashboard from '../../components/Analytics/Dashboard.tsx'; +--- + + + + + + + Analytics — Commerce Storefront Docs + + + + + + + + + + + +
+ +
+ + +
+

+ Data stored in Supabase. Visitor IDs are random UUIDs — no personal + data is collected. +

+
+ + + + diff --git a/src/scripts/analytics.ts b/src/scripts/analytics.ts new file mode 100644 index 000000000..beaa4fed6 --- /dev/null +++ b/src/scripts/analytics.ts @@ -0,0 +1,227 @@ +/** + * Custom Analytics Tracking Script + * + * Captures page views, time on page, bounce rate, clicks, scroll depth, + * and form interactions, then writes them to Supabase via the REST API. + * + * Injected on every page via the Astro integration in astro.config.mjs. + * No cookies are set. Visitor IDs are random UUIDs stored in localStorage. + */ + +const SUPABASE_URL = import.meta.env.PUBLIC_SUPABASE_URL as string | undefined; +const SUPABASE_KEY = import.meta.env.PUBLIC_SUPABASE_ANON_KEY as string | undefined; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isBot(): boolean { + if (typeof navigator === 'undefined') return true; + if (navigator.webdriver) return true; + return /bot|crawl|slurp|spider|mediapartners|prerender|headlesschrome/i.test( + navigator.userAgent + ); +} + +function uuid(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback for older browsers in non-secure contexts + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +function getIds(): { sessionId: string; visitorId: string } { + let sessionId = sessionStorage.getItem('_a_sid'); + if (!sessionId) { + sessionId = uuid(); + sessionStorage.setItem('_a_sid', sessionId); + } + + let visitorId = localStorage.getItem('_a_vid'); + if (!visitorId) { + visitorId = uuid(); + localStorage.setItem('_a_vid', visitorId); + } + + return { sessionId, visitorId }; +} + +// --------------------------------------------------------------------------- +// Supabase REST calls +// --------------------------------------------------------------------------- + +const supabaseHeaders = (): Record => ({ + apikey: SUPABASE_KEY!, + Authorization: `Bearer ${SUPABASE_KEY}`, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', +}); + +async function dbInsert( + table: string, + data: Record +): Promise { + try { + await fetch(`${SUPABASE_URL}/rest/v1/${table}`, { + method: 'POST', + headers: supabaseHeaders(), + body: JSON.stringify(data), + }); + } catch { + // Analytics must never break the page + } +} + +async function dbPatch( + table: string, + filter: string, + data: Record +): Promise { + try { + await fetch(`${SUPABASE_URL}/rest/v1/${table}?${filter}`, { + method: 'PATCH', + headers: supabaseHeaders(), + body: JSON.stringify(data), + // keepalive ensures the request completes even after page navigation + keepalive: true, + }); + } catch { + // Analytics must never break the page + } +} + +// --------------------------------------------------------------------------- +// Main init +// --------------------------------------------------------------------------- + +(function init() { + if (!SUPABASE_URL || !SUPABASE_KEY) return; + if (isBot()) return; + + const { sessionId, visitorId } = getIds(); + const pageViewId = uuid(); + const pageLoadTime = Date.now(); + + // Track whether the visitor navigates to another page (not a bounce) + let navigated = false; + + // ---- Page view ---- + dbInsert('page_views', { + id: pageViewId, + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + referrer: document.referrer || null, + title: document.title, + }); + + // ---- Duration + bounce on unload ---- + window.addEventListener('pagehide', () => { + const duration = Math.round((Date.now() - pageLoadTime) / 1000); + dbPatch('page_views', `id=eq.${pageViewId}`, { + duration_seconds: duration, + is_bounce: !navigated, + }); + }); + + // ---- Detect in-page navigation (marks session as non-bounce) ---- + document.addEventListener( + 'click', + (e) => { + const anchor = (e.target as Element).closest('a'); + if (anchor?.href) { + const dest = new URL(anchor.href, location.href); + if (dest.hostname === location.hostname) { + navigated = true; + } + } + }, + { capture: true } + ); + + // ---- Click events ---- + document.addEventListener('click', (e) => { + const target = e.target as Element; + const link = target.closest('a'); + const button = target.closest('button'); + const el = link ?? button; + if (!el) return; + + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'click', + event_name: link ? 'link_click' : 'button_click', + event_data: { + text: el.textContent?.trim().slice(0, 100) ?? null, + href: link?.getAttribute('href') ?? null, + }, + }); + }); + + // ---- Scroll depth ---- + const scrollThresholds = [25, 50, 75, 100] as const; + const firedDepths = new Set(); + + const onScroll = () => { + const docHeight = + document.documentElement.scrollHeight - window.innerHeight; + if (docHeight <= 0) return; + + const pct = Math.round((window.scrollY / docHeight) * 100); + + for (const threshold of scrollThresholds) { + if (pct >= threshold && !firedDepths.has(threshold)) { + firedDepths.add(threshold); + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'scroll', + event_name: `scroll_${threshold}pct`, + event_data: { depth_pct: threshold }, + }); + } + } + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + + // ---- Form interactions ---- + document.addEventListener('focusin', (e) => { + const el = e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) return; + + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'form', + event_name: 'field_focus', + event_data: { + field_type: (el as HTMLInputElement).type ?? el.tagName.toLowerCase(), + field_name: el.name || el.id || null, + }, + }); + }); + + document.addEventListener('submit', (e) => { + const form = e.target as HTMLFormElement; + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'form', + event_name: 'form_submit', + event_data: { + form_id: form.id || null, + form_action: form.action || null, + }, + }); + }); +})(); From b1e470a8c31992e0d74adc5625c5377fc1974d96 Mon Sep 17 00:00:00 2001 From: Bruce Denham Date: Tue, 5 May 2026 18:55:36 -0500 Subject: [PATCH 2/8] WIP custome analytics --- .env.example | 2 +- analytics/setup.sql | 155 --------- astro.config.mjs | 3 +- .../Analytics => analytics}/Dashboard.tsx | 125 +++++++- src/analytics/README.md | 13 + src/analytics/setup.sql | 295 ++++++++++++++++++ .../analytics.ts => analytics/tracker.ts} | 73 ++++- .../docs/boilerplate/customizing-blocks.mdx | 2 +- src/pages/analytics/index.astro | 36 ++- tsconfig.json | 1 + 10 files changed, 541 insertions(+), 164 deletions(-) delete mode 100644 analytics/setup.sql rename src/{components/Analytics => analytics}/Dashboard.tsx (71%) create mode 100644 src/analytics/README.md create mode 100644 src/analytics/setup.sql rename src/{scripts/analytics.ts => analytics/tracker.ts} (73%) diff --git a/.env.example b/.env.example index 55578f65e..6411585da 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Custom Analytics — Supabase connection -# Create a free project at https://supabase.com, run analytics/setup.sql in the +# Create a free project at https://supabase.com, run src/analytics/setup.sql in the # Supabase SQL Editor, then copy the values from Project Settings > API. # # The PUBLIC_ prefix exposes these values to the browser at runtime. diff --git a/analytics/setup.sql b/analytics/setup.sql deleted file mode 100644 index 063d8b327..000000000 --- a/analytics/setup.sql +++ /dev/null @@ -1,155 +0,0 @@ --- ============================================================================= --- Custom Analytics — Supabase Setup --- ============================================================================= --- Run this entire file in the Supabase SQL Editor once, after creating your --- free project at https://supabase.com. Copy the Project URL and anon key --- from Project Settings > API and add them to your .env file. --- ============================================================================= - - --- --------------------------------------------------------------------------- --- 1. Tables --- --------------------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS page_views ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - session_id TEXT NOT NULL, - visitor_id TEXT NOT NULL, - url TEXT NOT NULL, - referrer TEXT, - title TEXT, - timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL, - duration_seconds INTEGER, - is_bounce BOOLEAN DEFAULT FALSE -); - -CREATE INDEX IF NOT EXISTS idx_pv_timestamp ON page_views (timestamp DESC); -CREATE INDEX IF NOT EXISTS idx_pv_url ON page_views (url); -CREATE INDEX IF NOT EXISTS idx_pv_session ON page_views (session_id); -CREATE INDEX IF NOT EXISTS idx_pv_visitor ON page_views (visitor_id); - - -CREATE TABLE IF NOT EXISTS events ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - session_id TEXT NOT NULL, - visitor_id TEXT NOT NULL, - url TEXT NOT NULL, - event_type TEXT NOT NULL, -- click | scroll | form | custom - event_name TEXT, - event_data JSONB, - timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_ev_timestamp ON events (timestamp DESC); -CREATE INDEX IF NOT EXISTS idx_ev_type ON events (event_type); - - --- --------------------------------------------------------------------------- --- 2. Row Level Security --- --------------------------------------------------------------------------- - -ALTER TABLE page_views ENABLE ROW LEVEL SECURITY; -ALTER TABLE events ENABLE ROW LEVEL SECURITY; - --- Tracking script inserts page views and later patches duration/bounce -CREATE POLICY "anon_insert_page_views" ON page_views - FOR INSERT TO anon WITH CHECK (true); - -CREATE POLICY "anon_update_page_views" ON page_views - FOR UPDATE TO anon USING (true) WITH CHECK (true); - -CREATE POLICY "anon_select_page_views" ON page_views - FOR SELECT TO anon USING (true); - --- Tracking script inserts events -CREATE POLICY "anon_insert_events" ON events - FOR INSERT TO anon WITH CHECK (true); - -CREATE POLICY "anon_select_events" ON events - FOR SELECT TO anon USING (true); - - --- --------------------------------------------------------------------------- --- 3. Aggregation Views --- --------------------------------------------------------------------------- - --- Daily summary — used for the trend chart and summary card totals -CREATE OR REPLACE VIEW analytics_summary AS -SELECT - (timestamp::DATE) AS day, - COUNT(DISTINCT session_id) AS visits, - COUNT(*) AS page_views, - COUNT(DISTINCT visitor_id) AS unique_visitors, - COALESCE( - ROUND(AVG(duration_seconds)::NUMERIC, 0), 0 - )::INTEGER AS avg_duration_seconds, - COALESCE( - ROUND( - 100.0 - * COUNT(*) FILTER (WHERE is_bounce = TRUE) - / NULLIF(COUNT(DISTINCT session_id), 0), - 1 - ), - 0 - ) AS bounce_rate_pct -FROM page_views -GROUP BY timestamp::DATE -ORDER BY day DESC; - - --- Top pages by view count -CREATE OR REPLACE VIEW top_pages AS -SELECT - url, - COUNT(*) AS page_views, - COUNT(DISTINCT visitor_id) AS unique_visitors, - COALESCE( - ROUND(AVG(duration_seconds)::NUMERIC, 0), 0 - )::INTEGER AS avg_duration_seconds -FROM page_views -GROUP BY url -ORDER BY page_views DESC -LIMIT 50; - - --- 30-day totals for summary cards — accurate unique counts across the window -CREATE OR REPLACE VIEW analytics_totals_30d AS -SELECT - COUNT(DISTINCT session_id) AS visits, - COUNT(*) AS page_views, - COUNT(DISTINCT visitor_id) AS unique_visitors, - COALESCE( - ROUND(AVG(duration_seconds)::NUMERIC, 0), 0 - )::INTEGER AS avg_duration_seconds, - COALESCE( - ROUND( - 100.0 - * COUNT(*) FILTER (WHERE is_bounce = TRUE) - / NULLIF(COUNT(DISTINCT session_id), 0), - 1 - ), - 0 - ) AS bounce_rate_pct -FROM page_views -WHERE timestamp >= NOW() - INTERVAL '30 days'; - - --- Event breakdown by type and name -CREATE OR REPLACE VIEW events_summary AS -SELECT - event_type, - event_name, - COUNT(*) AS event_count -FROM events -GROUP BY event_type, event_name -ORDER BY event_count DESC; - - --- --------------------------------------------------------------------------- --- 4. Grant SELECT on views to the anon role --- --------------------------------------------------------------------------- - -GRANT SELECT ON analytics_summary TO anon; -GRANT SELECT ON analytics_totals_30d TO anon; -GRANT SELECT ON top_pages TO anon; -GRANT SELECT ON events_summary TO anon; diff --git a/astro.config.mjs b/astro.config.mjs index b1ac9fd50..d1a6c6e08 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -86,7 +86,8 @@ async function config() { 'astro:config:setup': ({ injectScript }) => { // Tracks page views, clicks, scroll depth, and form interactions on every page. // Dynamic import so analytics loads after the main page bundle without blocking render. - injectScript('page', `void import('/src/scripts/analytics.ts');`); + // Source: src/analytics/tracker.ts (see src/analytics/README.md). + injectScript('page', `void import('/src/analytics/tracker.ts');`); }, }, }, diff --git a/src/components/Analytics/Dashboard.tsx b/src/analytics/Dashboard.tsx similarity index 71% rename from src/components/Analytics/Dashboard.tsx rename to src/analytics/Dashboard.tsx index 00b90ba5b..19824c55a 100644 --- a/src/components/Analytics/Dashboard.tsx +++ b/src/analytics/Dashboard.tsx @@ -45,6 +45,20 @@ interface EventRow { event_count: number; } +interface EngagementRow { + avg_pages_per_session: number; + sessions_with_2plus_pages: number; + sessions_total: number; + returning_visitors: number; + unique_visitors_30d: number; +} + +interface OutcomeRow { + outcome_key: string; + category: string | null; + clicks: number; +} + // --------------------------------------------------------------------------- // Supabase client — env vars are replaced at build time by Vite // --------------------------------------------------------------------------- @@ -80,6 +94,19 @@ function truncateUrl(url: string, max = 60): string { } } +function outcomeLabel(key: string): string { + const labels: Record = { + commerce_boilerplate_repo: 'Commerce boilerplate (GitHub)', + adobe_commerce_github: 'Adobe Commerce org (GitHub)', + commerce_docs_github: 'Commerce docs (GitHub)', + da_live: 'DA.live', + developer_adobe: 'developer.adobe.com', + experience_league: 'Experience League', + aem_live: 'AEM.live docs', + }; + return labels[key] ?? key.replace(/_/g, ' '); +} + // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- @@ -129,13 +156,22 @@ export default function Dashboard() { const [chartData, setChartData] = useState([]); const [topPages, setTopPages] = useState([]); const [events, setEvents] = useState([]); + const [engagement, setEngagement] = useState(null); + const [outcomes, setOutcomes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function load() { try { - const [totalsRes, chartRes, pagesRes, eventsRes] = await Promise.all([ + const [ + totalsRes, + chartRes, + pagesRes, + eventsRes, + engagementRes, + outcomesRes, + ] = await Promise.all([ supabase.from('analytics_totals_30d').select('*').single(), supabase .from('analytics_summary') @@ -144,17 +180,23 @@ export default function Dashboard() { .limit(30), supabase.from('top_pages').select('*').limit(20), supabase.from('events_summary').select('*').limit(30), + supabase.from('management_engagement_30d').select('*').single(), + supabase.from('outcome_clicks_30d').select('*').limit(25), ]); if (totalsRes.error) throw totalsRes.error; if (chartRes.error) throw chartRes.error; if (pagesRes.error) throw pagesRes.error; if (eventsRes.error) throw eventsRes.error; + if (engagementRes.error) throw engagementRes.error; + if (outcomesRes.error) throw outcomesRes.error; setTotals(totalsRes.data as Totals); setChartData((chartRes.data ?? []) as DailySummary[]); setTopPages((pagesRes.data ?? []) as TopPage[]); setEvents((eventsRes.data ?? []) as EventRow[]); + setEngagement(engagementRes.data as EngagementRow); + setOutcomes((outcomesRes.data ?? []) as OutcomeRow[]); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to load analytics data.' @@ -220,6 +262,87 @@ export default function Dashboard() {
) : null} + {/* ---- Management: outcome clicks ---- */} + +

+ Clicks on high-value outbound links (GitHub boilerplate, DA.live, Adobe + docs). Use this with top pages to show docs driving the next step. +

+ + {loading ? ( + + ) : outcomes.length === 0 ? ( +

+ No outcome clicks yet. Follow a few outbound links from the docs. +

+ ) : ( +
+ + + + + + + + + + {outcomes.map((row) => ( + + + + + + ))} + +
DestinationCategoryClicks
{outcomeLabel(row.outcome_key)} + {row.category ?? '—'} + {Number(row.clicks).toLocaleString()}
+
+ )} + + {/* ---- Management: engagement depth ---- */} + +

+ Avg pages per visit, multi-page sessions, and visitors who came back in + more than one session. +

+ + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : engagement ? ( +
+ + + 0 + ? `${Math.round( + (100 * Number(engagement.returning_visitors ?? 0)) / + Number(engagement.unique_visitors_30d) + )}% of unique visitors` + : undefined + } + /> + +
+ ) : null} + {/* ---- Daily Trend Chart ---- */} diff --git a/src/analytics/README.md b/src/analytics/README.md new file mode 100644 index 000000000..f8b4ce48f --- /dev/null +++ b/src/analytics/README.md @@ -0,0 +1,13 @@ +# Site analytics (Supabase) + +This folder holds everything for the optional docs-site analytics dashboard (traffic, outcomes, and engagement). + +| File | Role | +|------|------| +| `setup.sql` | Run once in the Supabase SQL Editor (tables, RLS, views, grants). | +| `tracker.ts` | Injected on every page from `astro.config.mjs` — records page views and events. | +| `Dashboard.tsx` | React UI for `/analytics/` (loaded from `src/pages/analytics/index.astro`). | + +Environment: set `PUBLIC_SUPABASE_URL` and `PUBLIC_SUPABASE_ANON_KEY` in `.env` (see `.env.example`). + +Import alias: `@analytics/Dashboard` (see `tsconfig.json`). diff --git a/src/analytics/setup.sql b/src/analytics/setup.sql new file mode 100644 index 000000000..197cdb3af --- /dev/null +++ b/src/analytics/setup.sql @@ -0,0 +1,295 @@ +-- ============================================================================= +-- Custom Analytics — Supabase Setup +-- ============================================================================= +-- Run this entire file in the Supabase SQL Editor once, after creating your +-- free project at https://supabase.com. Copy the Project URL and anon key +-- from Project Settings > API and add them to your .env file. +-- +-- Upgrades: if you already ran this script earlier, re-run the views and +-- grants section from "Event breakdown" (events_summary) through the end +-- so management_engagement_30d and outcome_clicks_30d exist and +-- events_summary excludes outcome (and legacy web_vital rows). +-- ============================================================================= + + +-- --------------------------------------------------------------------------- +-- 1. Tables +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS page_views ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + session_id TEXT NOT NULL, + visitor_id TEXT NOT NULL, + url TEXT NOT NULL, + referrer TEXT, + title TEXT, + timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL, + duration_seconds INTEGER, + is_bounce BOOLEAN DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_pv_timestamp ON page_views (timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_pv_url ON page_views (url); +CREATE INDEX IF NOT EXISTS idx_pv_session ON page_views (session_id); +CREATE INDEX IF NOT EXISTS idx_pv_visitor ON page_views (visitor_id); + + +CREATE TABLE IF NOT EXISTS events ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + session_id TEXT NOT NULL, + visitor_id TEXT NOT NULL, + url TEXT NOT NULL, + event_type TEXT NOT NULL, -- click | scroll | form | custom + event_name TEXT, + event_data JSONB, + timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ev_timestamp ON events (timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_ev_type ON events (event_type); + + +-- --------------------------------------------------------------------------- +-- 2. Row Level Security +-- --------------------------------------------------------------------------- + +ALTER TABLE page_views ENABLE ROW LEVEL SECURITY; +ALTER TABLE events ENABLE ROW LEVEL SECURITY; + +-- page_views policies +DROP POLICY IF EXISTS "anon_insert_page_views" ON page_views; +DROP POLICY IF EXISTS "anon_update_page_views" ON page_views; +DROP POLICY IF EXISTS "anon_select_page_views" ON page_views; + +CREATE POLICY "anon_insert_page_views" ON page_views + FOR INSERT TO anon WITH CHECK (true); + +CREATE POLICY "anon_update_page_views" ON page_views + FOR UPDATE TO anon USING (true) WITH CHECK (true); + +CREATE POLICY "anon_select_page_views" ON page_views + FOR SELECT TO anon USING (true); + +-- events policies +DROP POLICY IF EXISTS "anon_insert_events" ON events; +DROP POLICY IF EXISTS "anon_select_events" ON events; + +CREATE POLICY "anon_insert_events" ON events + FOR INSERT TO anon WITH CHECK (true); + +CREATE POLICY "anon_select_events" ON events + FOR SELECT TO anon USING (true); + + +-- --------------------------------------------------------------------------- +-- 3. Aggregation Views +-- --------------------------------------------------------------------------- + +-- Daily summary — used for the trend chart and summary card totals +-- Bounce rate = share of sessions that had exactly one page view that day +-- (not per-page is_bounce flags, which would exceed 100% for multi-page sessions) +-- Use BIGINT for counts so CREATE OR REPLACE VIEW matches prior column types (SUM() is numeric by default). +CREATE OR REPLACE VIEW analytics_summary AS +WITH session_day AS ( + SELECT + session_id, + (timestamp::DATE) AS day, + COUNT(*) AS pv_count + FROM page_views + GROUP BY session_id, (timestamp::DATE) +), +day_sessions AS ( + SELECT + day, + COUNT(*)::BIGINT AS visits, + SUM(pv_count)::BIGINT AS page_views, + COUNT(*) FILTER (WHERE pv_count = 1)::BIGINT AS bounced_sessions + FROM session_day + GROUP BY day +), +unique_per_day AS ( + SELECT + (timestamp::DATE) AS day, + COUNT(DISTINCT visitor_id) AS unique_visitors + FROM page_views + GROUP BY (timestamp::DATE) +), +duration_per_day AS ( + SELECT + (timestamp::DATE) AS day, + AVG(duration_seconds)::NUMERIC AS avg_duration_seconds + FROM page_views + WHERE duration_seconds IS NOT NULL + GROUP BY (timestamp::DATE) +) +SELECT + ds.day, + ds.visits, + ds.page_views, + COALESCE(upd.unique_visitors, 0)::BIGINT AS unique_visitors, + COALESCE( + ROUND(dpd.avg_duration_seconds, 0), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 * ds.bounced_sessions::NUMERIC / NULLIF(ds.visits, 0), + 1 + ), + 0 + ) AS bounce_rate_pct +FROM day_sessions ds +LEFT JOIN unique_per_day upd ON upd.day = ds.day +LEFT JOIN duration_per_day dpd ON dpd.day = ds.day +ORDER BY ds.day DESC; + + +-- Top pages by view count +CREATE OR REPLACE VIEW top_pages AS +SELECT + url, + COUNT(*) AS page_views, + COUNT(DISTINCT visitor_id) AS unique_visitors, + COALESCE( + ROUND(AVG(duration_seconds)::NUMERIC, 0), 0 + )::INTEGER AS avg_duration_seconds +FROM page_views +GROUP BY url +ORDER BY page_views DESC +LIMIT 50; + + +-- 30-day totals for summary cards — accurate unique counts across the window +-- Bounce rate = sessions with exactly one page view in the window / all sessions +CREATE OR REPLACE VIEW analytics_totals_30d AS +WITH windowed AS ( + SELECT * + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' +), +session_pv AS ( + SELECT session_id, COUNT(*) AS pv_count + FROM windowed + GROUP BY session_id +) +SELECT + (SELECT COUNT(DISTINCT session_id) FROM windowed) AS visits, + (SELECT COUNT(*) FROM windowed) AS page_views, + (SELECT COUNT(DISTINCT visitor_id) FROM windowed) AS unique_visitors, + COALESCE( + ( + SELECT ROUND(AVG(duration_seconds)::NUMERIC, 0) + FROM windowed + WHERE duration_seconds IS NOT NULL + ), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * (SELECT COUNT(*) FROM session_pv WHERE pv_count = 1)::NUMERIC + / NULLIF((SELECT COUNT(*) FROM session_pv), 0), + 1 + ), + 0 + ) AS bounce_rate_pct; + + +-- Event breakdown by type and name (excludes outcome and legacy web_vital rows) +CREATE OR REPLACE VIEW events_summary AS +SELECT + event_type, + event_name, + COUNT(*) AS event_count +FROM events +WHERE event_type NOT IN ('web_vital', 'outcome') +GROUP BY event_type, event_name +ORDER BY event_count DESC; + + +-- Engagement depth (last 30 days): pages per session and returning visitors +CREATE OR REPLACE VIEW management_engagement_30d AS +SELECT + COALESCE( + ( + SELECT ROUND(AVG(cnt)::numeric, 2) + FROM ( + SELECT session_id, COUNT(*)::bigint AS cnt + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + GROUP BY session_id + ) q + ), + 0 + ) AS avg_pages_per_session, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT session_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + GROUP BY session_id + HAVING COUNT(*) >= 2 + ) m + ), + 0 + ) AS sessions_with_2plus_pages, + COALESCE( + ( + SELECT COUNT(DISTINCT session_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + ), + 0 + ) AS sessions_total, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT visitor_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + GROUP BY visitor_id + HAVING COUNT(DISTINCT session_id) > 1 + ) r + ), + 0 + ) AS returning_visitors, + COALESCE( + ( + SELECT COUNT(DISTINCT visitor_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + ), + 0 + ) AS unique_visitors_30d; + + +-- High-value outbound clicks (outcome events), last 30 days +CREATE OR REPLACE VIEW outcome_clicks_30d AS +SELECT + event_name AS outcome_key, + MAX(event_data->>'category') AS category, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'outcome' + AND timestamp >= NOW() - INTERVAL '30 days' +GROUP BY event_name +ORDER BY clicks DESC; + + +-- Drop Web Vitals aggregate view if it exists (tracker no longer sends web_vital events) +DROP VIEW IF EXISTS web_vitals_avg_30d; + + +-- --------------------------------------------------------------------------- +-- 4. Grant SELECT on views to the anon role +-- --------------------------------------------------------------------------- + +GRANT SELECT ON analytics_summary TO anon; +GRANT SELECT ON analytics_totals_30d TO anon; +GRANT SELECT ON top_pages TO anon; +GRANT SELECT ON events_summary TO anon; +GRANT SELECT ON management_engagement_30d TO anon; +GRANT SELECT ON outcome_clicks_30d TO anon; diff --git a/src/scripts/analytics.ts b/src/analytics/tracker.ts similarity index 73% rename from src/scripts/analytics.ts rename to src/analytics/tracker.ts index beaa4fed6..9c2849a76 100644 --- a/src/scripts/analytics.ts +++ b/src/analytics/tracker.ts @@ -2,7 +2,8 @@ * Custom Analytics Tracking Script * * Captures page views, time on page, bounce rate, clicks, scroll depth, - * and form interactions, then writes them to Supabase via the REST API. + * form interactions, and high-value outbound (outcome) clicks, then writes + * them to Supabase via the REST API. * * Injected on every page via the Astro integration in astro.config.mjs. * No cookies are set. Visitor IDs are random UUIDs stored in localStorage. @@ -76,6 +77,47 @@ async function dbInsert( } } +/** High-value outbound destinations for management reporting (first match wins). */ +function classifyOutcomeLink(url: URL): { name: string; category: string } | null { + const host = url.hostname.toLowerCase(); + const path = url.pathname.toLowerCase(); + + if (host.endsWith('github.com')) { + if (path.includes('hlxsites/aem-boilerplate-commerce')) { + return { name: 'commerce_boilerplate_repo', category: 'repository' }; + } + if (path.includes('adobe-commerce')) { + return { name: 'adobe_commerce_github', category: 'repository' }; + } + if (path.includes('commerce-docs')) { + return { name: 'commerce_docs_github', category: 'repository' }; + } + return null; + } + + if (host === 'da.live' || host.endsWith('.da.live')) { + return { name: 'da_live', category: 'authoring_tools' }; + } + + if (host === 'developer.adobe.com') { + return { name: 'developer_adobe', category: 'adobe_docs' }; + } + + if (host === 'experienceleague.adobe.com') { + return { name: 'experience_league', category: 'adobe_docs' }; + } + + if (host === 'www.aem.live' || host === 'aem.live') { + return { name: 'aem_live', category: 'edge_docs' }; + } + + return null; +} + +function isExternalUrl(url: URL): boolean { + return url.origin !== location.origin; +} + async function dbPatch( table: string, filter: string, @@ -124,6 +166,7 @@ async function dbPatch( const duration = Math.round((Date.now() - pageLoadTime) / 1000); dbPatch('page_views', `id=eq.${pageViewId}`, { duration_seconds: duration, + // Per-page "no internal click before leave" — dashboards use session-level bounce in SQL is_bounce: !navigated, }); }); @@ -143,7 +186,7 @@ async function dbPatch( { capture: true } ); - // ---- Click events ---- + // ---- Click events (generic + tagged outcome links for management reporting) ---- document.addEventListener('click', (e) => { const target = e.target as Element; const link = target.closest('a'); @@ -151,6 +194,32 @@ async function dbPatch( const el = link ?? button; if (!el) return; + if (link?.href) { + try { + const dest = new URL(link.href, location.href); + if (isExternalUrl(dest)) { + const outcome = classifyOutcomeLink(dest); + if (outcome) { + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'outcome', + event_name: outcome.name, + event_data: { + category: outcome.category, + href: dest.href.slice(0, 500), + link_text: el.textContent?.trim().slice(0, 100) ?? null, + }, + }); + return; + } + } + } catch { + // ignore bad href + } + } + dbInsert('events', { session_id: sessionId, visitor_id: visitorId, diff --git a/src/content/docs/boilerplate/customizing-blocks.mdx b/src/content/docs/boilerplate/customizing-blocks.mdx index 65894ea8b..69d82a7b1 100644 --- a/src/content/docs/boilerplate/customizing-blocks.mdx +++ b/src/content/docs/boilerplate/customizing-blocks.mdx @@ -276,7 +276,7 @@ $emptyCart.appendChild(emptyState); Track custom events for analytics: ```javascript -// Add to your block file or scripts/analytics.js +// Add to your block file or your storefront analytics integration import { events } from '@dropins/tools/event-bus.js'; events.on('cart/data', (cartData) => { diff --git a/src/pages/analytics/index.astro b/src/pages/analytics/index.astro index 2151bdfdf..90fcc45ef 100644 --- a/src/pages/analytics/index.astro +++ b/src/pages/analytics/index.astro @@ -1,5 +1,5 @@ --- -import Dashboard from '../../components/Analytics/Dashboard.tsx'; +import Dashboard from '@analytics/Dashboard'; --- @@ -80,6 +80,8 @@ import Dashboard from '../../components/Analytics/Dashboard.tsx'; --badge-form-color: #854d0e; --badge-custom-bg: #f3e8ff; --badge-custom-color: #6b21a8; + --badge-outcome-bg: #e0f2fe; + --badge-outcome-color: #0369a1; --chart-grid: #e5e7eb; --chart-label: #9ca3af; --chart-tooltip-bg: #ffffff; @@ -105,6 +107,8 @@ import Dashboard from '../../components/Analytics/Dashboard.tsx'; --badge-form-color: #fde047; --badge-custom-bg: #2e1065; --badge-custom-color: #d8b4fe; + --badge-outcome-bg: #0c4a6e; + --badge-outcome-color: #7dd3fc; --chart-grid: #334155; --chart-label: #64748b; --chart-tooltip-bg: #1e293b; @@ -206,10 +210,22 @@ import Dashboard from '../../components/Analytics/Dashboard.tsx'; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--text-muted); + color: var(--text); margin-top: 1rem; } + .section-intro { + font-size: 0.875rem; + color: var(--text-muted); + margin: -0.5rem 0 0; + max-width: 52rem; + line-height: 1.5; + } + + .muted { + color: var(--text-muted); + } + /* ---- Summary cards ---- */ .cards-grid { display: grid; @@ -217,6 +233,11 @@ import Dashboard from '../../components/Analytics/Dashboard.tsx'; gap: 1rem; } + /* Engagement row: slightly wider second column for the long label */ + .cards-grid--engagement { + grid-template-columns: minmax(0, 1fr) minmax(10rem, 1.22fr) minmax(0, 1fr) minmax(0, 1fr); + } + .stat-card { background: var(--bg-surface); border: 1px solid var(--border); @@ -283,7 +304,7 @@ import Dashboard from '../../components/Analytics/Dashboard.tsx'; .data-table th { background: var(--bg); - color: var(--text-muted); + color: var(--text); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; @@ -348,6 +369,11 @@ import Dashboard from '../../components/Analytics/Dashboard.tsx'; color: var(--badge-custom-color); } + .event-badge--outcome { + background: var(--badge-outcome-bg); + color: var(--badge-outcome-color); + } + /* ---- Loading & empty states ---- */ .loading-placeholder { display: flex; @@ -410,6 +436,10 @@ import Dashboard from '../../components/Analytics/Dashboard.tsx'; grid-template-columns: 1fr 1fr; } + .cards-grid--engagement { + grid-template-columns: 1fr 1fr; + } + .stat-value { font-size: 1.375rem; } diff --git a/tsconfig.json b/tsconfig.json index b60869fd8..0d6d0626c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "@data/*": ["./src/data/*"], "@styles/*": ["./src/styles/*"], "@utils/*": ["./src/utils/*"], + "@analytics/*": ["./src/analytics/*"], "@images/*": ["./public/images/*"], "@storybook/*": ["./public/storybook-static/*"], }, From d123ed251fa8b3666049bc1a599ba1443b7a690c Mon Sep 17 00:00:00 2001 From: Bruce Denham Date: Tue, 5 May 2026 19:04:55 -0500 Subject: [PATCH 3/8] Potential fix for pull request finding 'CodeQL / Insecure randomness' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/analytics/tracker.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/analytics/tracker.ts b/src/analytics/tracker.ts index 9c2849a76..9948b234c 100644 --- a/src/analytics/tracker.ts +++ b/src/analytics/tracker.ts @@ -28,11 +28,25 @@ function uuid(): string { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } - // Fallback for older browsers in non-secure contexts - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); + + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + + // RFC 4122 version 4 + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20 + )}-${hex.slice(20)}`; + } + + // Last-resort non-cryptographic fallback in very old environments. + // Avoid Math.random to prevent predictable PRNG use in security checks. + return `legacy-${Date.now().toString(36)}-${performance.now().toString(36).replace('.', '')}`; } function getIds(): { sessionId: string; visitorId: string } { From 467fc88994345678d85d02d817b4d86e5bf18cac Mon Sep 17 00:00:00 2001 From: Bruce Denham Date: Tue, 5 May 2026 19:05:09 -0500 Subject: [PATCH 4/8] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/analytics/tracker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analytics/tracker.ts b/src/analytics/tracker.ts index 9948b234c..f546018f3 100644 --- a/src/analytics/tracker.ts +++ b/src/analytics/tracker.ts @@ -96,7 +96,7 @@ function classifyOutcomeLink(url: URL): { name: string; category: string } | nul const host = url.hostname.toLowerCase(); const path = url.pathname.toLowerCase(); - if (host.endsWith('github.com')) { + if (host === 'github.com' || host.endsWith('.github.com')) { if (path.includes('hlxsites/aem-boilerplate-commerce')) { return { name: 'commerce_boilerplate_repo', category: 'repository' }; } From 4d58f872d43c751f34a909eb734b4ca76329c5a8 Mon Sep 17 00:00:00 2001 From: Bruce Denham Date: Wed, 6 May 2026 20:12:46 -0500 Subject: [PATCH 5/8] fixed capture issues and SQL security --- src/analytics/Dashboard.tsx | 105 +++++++++++++----------- src/analytics/setup.sql | 134 +++++++----------------------- src/analytics/tracker.ts | 140 +++++++++++++------------------- src/pages/analytics/index.astro | 5 ++ 4 files changed, 148 insertions(+), 236 deletions(-) diff --git a/src/analytics/Dashboard.tsx b/src/analytics/Dashboard.tsx index 19824c55a..cb6b881f7 100644 --- a/src/analytics/Dashboard.tsx +++ b/src/analytics/Dashboard.tsx @@ -53,16 +53,11 @@ interface EngagementRow { unique_visitors_30d: number; } -interface OutcomeRow { - outcome_key: string; - category: string | null; +interface ExternalLinkRow { + href: string; clicks: number; } -// --------------------------------------------------------------------------- -// Supabase client — env vars are replaced at build time by Vite -// --------------------------------------------------------------------------- - const supabase = createClient( import.meta.env.PUBLIC_SUPABASE_URL as string, import.meta.env.PUBLIC_SUPABASE_ANON_KEY as string @@ -94,17 +89,19 @@ function truncateUrl(url: string, max = 60): string { } } -function outcomeLabel(key: string): string { - const labels: Record = { - commerce_boilerplate_repo: 'Commerce boilerplate (GitHub)', - adobe_commerce_github: 'Adobe Commerce org (GitHub)', - commerce_docs_github: 'Commerce docs (GitHub)', - da_live: 'DA.live', - developer_adobe: 'developer.adobe.com', - experience_league: 'Experience League', - aem_live: 'AEM.live docs', - }; - return labels[key] ?? key.replace(/_/g, ' '); +function truncateHref(url: string, max = 72): string { + if (url.length <= max) return url; + return url.slice(0, max) + '…'; +} + +/** Same route rule as tracker.ts — exclude standalone `/analytics` dashboard URLs. */ +function isAnalyticsDashboardUrl(url: string): boolean { + try { + const path = new URL(url).pathname.replace(/\/+$/, '') || '/'; + return path.endsWith('/analytics'); + } catch { + return false; + } } // --------------------------------------------------------------------------- @@ -157,7 +154,7 @@ export default function Dashboard() { const [topPages, setTopPages] = useState([]); const [events, setEvents] = useState([]); const [engagement, setEngagement] = useState(null); - const [outcomes, setOutcomes] = useState([]); + const [externalLinks, setExternalLinks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -170,18 +167,18 @@ export default function Dashboard() { pagesRes, eventsRes, engagementRes, - outcomesRes, + externalLinksRes, ] = await Promise.all([ supabase.from('analytics_totals_30d').select('*').single(), supabase .from('analytics_summary') .select('*') - .order('day', { ascending: true }) + .order('day', { ascending: false }) .limit(30), - supabase.from('top_pages').select('*').limit(20), + supabase.from('top_pages').select('*').limit(40), supabase.from('events_summary').select('*').limit(30), supabase.from('management_engagement_30d').select('*').single(), - supabase.from('outcome_clicks_30d').select('*').limit(25), + supabase.from('external_link_clicks_30d').select('*').limit(100), ]); if (totalsRes.error) throw totalsRes.error; @@ -189,14 +186,19 @@ export default function Dashboard() { if (pagesRes.error) throw pagesRes.error; if (eventsRes.error) throw eventsRes.error; if (engagementRes.error) throw engagementRes.error; - if (outcomesRes.error) throw outcomesRes.error; + if (externalLinksRes.error) throw externalLinksRes.error; setTotals(totalsRes.data as Totals); - setChartData((chartRes.data ?? []) as DailySummary[]); - setTopPages((pagesRes.data ?? []) as TopPage[]); + setChartData( + ([...(chartRes.data ?? [])] as DailySummary[]).reverse() + ); + const pages = (pagesRes.data ?? []) as TopPage[]; + setTopPages( + pages.filter((p) => !isAnalyticsDashboardUrl(p.url)).slice(0, 20) + ); setEvents((eventsRes.data ?? []) as EventRow[]); setEngagement(engagementRes.data as EngagementRow); - setOutcomes((outcomesRes.data ?? []) as OutcomeRow[]); + setExternalLinks((externalLinksRes.data ?? []) as ExternalLinkRow[]); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to load analytics data.' @@ -228,7 +230,6 @@ export default function Dashboard() { return (
- {/* ---- Summary Cards ---- */} {loading ? ( @@ -262,37 +263,45 @@ export default function Dashboard() {
) : null} - {/* ---- Management: outcome clicks ---- */} - +

- Clicks on high-value outbound links (GitHub boilerplate, DA.live, Adobe - docs). Use this with top pages to show docs driving the next step. + Every click that leaves this site to another origin. Compare with top + pages to see which topics send traffic outward.

{loading ? ( - ) : outcomes.length === 0 ? ( + ) : externalLinks.length === 0 ? (

- No outcome clicks yet. Follow a few outbound links from the docs. + No external link clicks yet. Open a few outbound links from the docs, + then refresh this page.

) : (
- - + - {outcomes.map((row) => ( - - + {externalLinks.map((row) => ( + + - ))} @@ -300,7 +309,6 @@ export default function Dashboard() { )} - {/* ---- Management: engagement depth ---- */}

Avg pages per visit, multi-page sessions, and visitors who came back in @@ -321,7 +329,9 @@ export default function Dashboard() { /> ) : null} - {/* ---- Daily Trend Chart ---- */} - + {loading ? (

) : chartData.length === 0 ? ( -

No data yet. Visit a few pages to see the trend.

+

+ No data yet. Visit a few pages to see the trend. +

) : (
@@ -412,7 +423,6 @@ export default function Dashboard() {
)} - {/* ---- Top Pages ---- */} {loading ? ( @@ -452,7 +462,6 @@ export default function Dashboard() {
)} - {/* ---- Events Breakdown ---- */} {loading ? ( diff --git a/src/analytics/setup.sql b/src/analytics/setup.sql index 197cdb3af..d9e89d8f0 100644 --- a/src/analytics/setup.sql +++ b/src/analytics/setup.sql @@ -1,86 +1,3 @@ --- ============================================================================= --- Custom Analytics — Supabase Setup --- ============================================================================= --- Run this entire file in the Supabase SQL Editor once, after creating your --- free project at https://supabase.com. Copy the Project URL and anon key --- from Project Settings > API and add them to your .env file. --- --- Upgrades: if you already ran this script earlier, re-run the views and --- grants section from "Event breakdown" (events_summary) through the end --- so management_engagement_30d and outcome_clicks_30d exist and --- events_summary excludes outcome (and legacy web_vital rows). --- ============================================================================= - - --- --------------------------------------------------------------------------- --- 1. Tables --- --------------------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS page_views ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - session_id TEXT NOT NULL, - visitor_id TEXT NOT NULL, - url TEXT NOT NULL, - referrer TEXT, - title TEXT, - timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL, - duration_seconds INTEGER, - is_bounce BOOLEAN DEFAULT FALSE -); - -CREATE INDEX IF NOT EXISTS idx_pv_timestamp ON page_views (timestamp DESC); -CREATE INDEX IF NOT EXISTS idx_pv_url ON page_views (url); -CREATE INDEX IF NOT EXISTS idx_pv_session ON page_views (session_id); -CREATE INDEX IF NOT EXISTS idx_pv_visitor ON page_views (visitor_id); - - -CREATE TABLE IF NOT EXISTS events ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - session_id TEXT NOT NULL, - visitor_id TEXT NOT NULL, - url TEXT NOT NULL, - event_type TEXT NOT NULL, -- click | scroll | form | custom - event_name TEXT, - event_data JSONB, - timestamp TIMESTAMPTZ DEFAULT NOW() NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_ev_timestamp ON events (timestamp DESC); -CREATE INDEX IF NOT EXISTS idx_ev_type ON events (event_type); - - --- --------------------------------------------------------------------------- --- 2. Row Level Security --- --------------------------------------------------------------------------- - -ALTER TABLE page_views ENABLE ROW LEVEL SECURITY; -ALTER TABLE events ENABLE ROW LEVEL SECURITY; - --- page_views policies -DROP POLICY IF EXISTS "anon_insert_page_views" ON page_views; -DROP POLICY IF EXISTS "anon_update_page_views" ON page_views; -DROP POLICY IF EXISTS "anon_select_page_views" ON page_views; - -CREATE POLICY "anon_insert_page_views" ON page_views - FOR INSERT TO anon WITH CHECK (true); - -CREATE POLICY "anon_update_page_views" ON page_views - FOR UPDATE TO anon USING (true) WITH CHECK (true); - -CREATE POLICY "anon_select_page_views" ON page_views - FOR SELECT TO anon USING (true); - --- events policies -DROP POLICY IF EXISTS "anon_insert_events" ON events; -DROP POLICY IF EXISTS "anon_select_events" ON events; - -CREATE POLICY "anon_insert_events" ON events - FOR INSERT TO anon WITH CHECK (true); - -CREATE POLICY "anon_select_events" ON events - FOR SELECT TO anon USING (true); - - -- --------------------------------------------------------------------------- -- 3. Aggregation Views -- --------------------------------------------------------------------------- @@ -89,7 +6,8 @@ CREATE POLICY "anon_select_events" ON events -- Bounce rate = share of sessions that had exactly one page view that day -- (not per-page is_bounce flags, which would exceed 100% for multi-page sessions) -- Use BIGINT for counts so CREATE OR REPLACE VIEW matches prior column types (SUM() is numeric by default). -CREATE OR REPLACE VIEW analytics_summary AS +CREATE OR REPLACE VIEW analytics_summary +WITH (security_invoker = true) AS WITH session_day AS ( SELECT session_id, @@ -145,7 +63,8 @@ ORDER BY ds.day DESC; -- Top pages by view count -CREATE OR REPLACE VIEW top_pages AS +CREATE OR REPLACE VIEW top_pages +WITH (security_invoker = true) AS SELECT url, COUNT(*) AS page_views, @@ -161,7 +80,8 @@ LIMIT 50; -- 30-day totals for summary cards — accurate unique counts across the window -- Bounce rate = sessions with exactly one page view in the window / all sessions -CREATE OR REPLACE VIEW analytics_totals_30d AS +CREATE OR REPLACE VIEW analytics_totals_30d +WITH (security_invoker = true) AS WITH windowed AS ( SELECT * FROM page_views @@ -195,20 +115,22 @@ SELECT ) AS bounce_rate_pct; --- Event breakdown by type and name (excludes outcome and legacy web_vital rows) -CREATE OR REPLACE VIEW events_summary AS +-- Event breakdown by type and name (excludes outcome, external_link, and legacy web_vital rows) +CREATE OR REPLACE VIEW events_summary +WITH (security_invoker = true) AS SELECT event_type, event_name, COUNT(*) AS event_count FROM events -WHERE event_type NOT IN ('web_vital', 'outcome') +WHERE event_type NOT IN ('web_vital', 'outcome', 'external_link') GROUP BY event_type, event_name ORDER BY event_count DESC; -- Engagement depth (last 30 days): pages per session and returning visitors -CREATE OR REPLACE VIEW management_engagement_30d AS +CREATE OR REPLACE VIEW management_engagement_30d +WITH (security_invoker = true) AS SELECT COALESCE( ( @@ -266,8 +188,9 @@ SELECT ) AS unique_visitors_30d; --- High-value outbound clicks (outcome events), last 30 days -CREATE OR REPLACE VIEW outcome_clicks_30d AS +-- High-value outbound clicks (legacy outcome events), last 30 days +CREATE OR REPLACE VIEW outcome_clicks_30d +WITH (security_invoker = true) AS SELECT event_name AS outcome_key, MAX(event_data->>'category') AS category, @@ -279,17 +202,16 @@ GROUP BY event_name ORDER BY clicks DESC; --- Drop Web Vitals aggregate view if it exists (tracker no longer sends web_vital events) -DROP VIEW IF EXISTS web_vitals_avg_30d; - - --- --------------------------------------------------------------------------- --- 4. Grant SELECT on views to the anon role --- --------------------------------------------------------------------------- - -GRANT SELECT ON analytics_summary TO anon; -GRANT SELECT ON analytics_totals_30d TO anon; -GRANT SELECT ON top_pages TO anon; -GRANT SELECT ON events_summary TO anon; -GRANT SELECT ON management_engagement_30d TO anon; -GRANT SELECT ON outcome_clicks_30d TO anon; +-- External link clicks by destination URL, last 30 days (one row per href) +CREATE OR REPLACE VIEW public.external_link_clicks_30d +WITH (security_invoker = true) AS +SELECT + event_data->>'href' AS href, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'external_link' + AND timestamp >= NOW() - INTERVAL '30 days' + AND COALESCE(NULLIF(TRIM(event_data->>'href'), ''), '') <> '' +GROUP BY event_data->>'href' +ORDER BY clicks DESC +LIMIT 100; \ No newline at end of file diff --git a/src/analytics/tracker.ts b/src/analytics/tracker.ts index f546018f3..b682aa9d6 100644 --- a/src/analytics/tracker.ts +++ b/src/analytics/tracker.ts @@ -2,7 +2,7 @@ * Custom Analytics Tracking Script * * Captures page views, time on page, bounce rate, clicks, scroll depth, - * form interactions, and high-value outbound (outcome) clicks, then writes + * form interactions, and external link clicks, then writes * them to Supabase via the REST API. * * Injected on every page via the Astro integration in astro.config.mjs. @@ -24,6 +24,12 @@ function isBot(): boolean { ); } +/** Standalone analytics dashboard (`/analytics`); not doc topics under `/setup/analytics/`. */ +function isAnalyticsDashboardPage(): boolean { + const path = location.pathname.replace(/\/+$/, '') || '/'; + return path.endsWith('/analytics'); +} + function uuid(): string { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); @@ -76,78 +82,36 @@ const supabaseHeaders = (): Record => ({ Prefer: 'return=minimal', }); -async function dbInsert( - table: string, - data: Record -): Promise { - try { - await fetch(`${SUPABASE_URL}/rest/v1/${table}`, { - method: 'POST', - headers: supabaseHeaders(), - body: JSON.stringify(data), - }); - } catch { +function dbInsert(table: string, data: Record): void { + if (!SUPABASE_URL || !SUPABASE_KEY) return; + void fetch(`${SUPABASE_URL}/rest/v1/${table}`, { + method: 'POST', + headers: supabaseHeaders(), + body: JSON.stringify(data), + keepalive: true, + mode: 'cors', + }).catch(() => { // Analytics must never break the page - } -} - -/** High-value outbound destinations for management reporting (first match wins). */ -function classifyOutcomeLink(url: URL): { name: string; category: string } | null { - const host = url.hostname.toLowerCase(); - const path = url.pathname.toLowerCase(); - - if (host === 'github.com' || host.endsWith('.github.com')) { - if (path.includes('hlxsites/aem-boilerplate-commerce')) { - return { name: 'commerce_boilerplate_repo', category: 'repository' }; - } - if (path.includes('adobe-commerce')) { - return { name: 'adobe_commerce_github', category: 'repository' }; - } - if (path.includes('commerce-docs')) { - return { name: 'commerce_docs_github', category: 'repository' }; - } - return null; - } - - if (host === 'da.live' || host.endsWith('.da.live')) { - return { name: 'da_live', category: 'authoring_tools' }; - } - - if (host === 'developer.adobe.com') { - return { name: 'developer_adobe', category: 'adobe_docs' }; - } - - if (host === 'experienceleague.adobe.com') { - return { name: 'experience_league', category: 'adobe_docs' }; - } - - if (host === 'www.aem.live' || host === 'aem.live') { - return { name: 'aem_live', category: 'edge_docs' }; - } - - return null; + }); } function isExternalUrl(url: URL): boolean { return url.origin !== location.origin; } -async function dbPatch( +function dbPatch( table: string, filter: string, data: Record -): Promise { - try { - await fetch(`${SUPABASE_URL}/rest/v1/${table}?${filter}`, { - method: 'PATCH', - headers: supabaseHeaders(), - body: JSON.stringify(data), - // keepalive ensures the request completes even after page navigation - keepalive: true, - }); - } catch { - // Analytics must never break the page - } +): void { + if (!SUPABASE_URL || !SUPABASE_KEY) return; + void fetch(`${SUPABASE_URL}/rest/v1/${table}?${filter}`, { + method: 'PATCH', + headers: supabaseHeaders(), + body: JSON.stringify(data), + keepalive: true, + mode: 'cors', + }).catch(() => {}); } // --------------------------------------------------------------------------- @@ -157,6 +121,7 @@ async function dbPatch( (function init() { if (!SUPABASE_URL || !SUPABASE_KEY) return; if (isBot()) return; + if (isAnalyticsDashboardPage()) return; const { sessionId, visitorId } = getIds(); const pageViewId = uuid(); @@ -200,7 +165,35 @@ async function dbPatch( { capture: true } ); - // ---- Click events (generic + tagged outcome links for management reporting) ---- + // ---- External links: capture phase + sync dbInsert so the request starts before navigation ---- + document.addEventListener( + 'click', + (e) => { + const link = (e.target as Element | null)?.closest('a'); + if (!link?.href) return; + let dest: URL; + try { + dest = new URL(link.href, location.href); + } catch { + return; + } + if (!isExternalUrl(dest)) return; + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'external_link', + event_name: 'click', + event_data: { + href: dest.href.slice(0, 2000), + link_text: link.textContent?.trim().slice(0, 200) ?? null, + }, + }); + }, + true + ); + + // ---- Click events (generic; external links are skipped — recorded above) ---- document.addEventListener('click', (e) => { const target = e.target as Element; const link = target.closest('a'); @@ -211,24 +204,7 @@ async function dbPatch( if (link?.href) { try { const dest = new URL(link.href, location.href); - if (isExternalUrl(dest)) { - const outcome = classifyOutcomeLink(dest); - if (outcome) { - dbInsert('events', { - session_id: sessionId, - visitor_id: visitorId, - url: location.href, - event_type: 'outcome', - event_name: outcome.name, - event_data: { - category: outcome.category, - href: dest.href.slice(0, 500), - link_text: el.textContent?.trim().slice(0, 100) ?? null, - }, - }); - return; - } - } + if (isExternalUrl(dest)) return; } catch { // ignore bad href } diff --git a/src/pages/analytics/index.astro b/src/pages/analytics/index.astro index 90fcc45ef..1017a4f68 100644 --- a/src/pages/analytics/index.astro +++ b/src/pages/analytics/index.astro @@ -311,9 +311,14 @@ import Dashboard from '@analytics/Dashboard'; letter-spacing: 0.04em; padding: 0.625rem 1rem; text-align: left; + vertical-align: middle; border-bottom: 1px solid var(--border); } + .data-table th.num-col { + text-align: right; + } + .data-table td { padding: 0.625rem 1rem; border-bottom: 1px solid var(--border); From 948a5196d82f3226181ac91c3a87cd72001c626c Mon Sep 17 00:00:00 2001 From: Bruce Denham Date: Thu, 7 May 2026 10:45:12 -0500 Subject: [PATCH 6/8] fixed avg time on page --- src/analytics/Dashboard.tsx | 5 +++-- src/analytics/README.md | 2 +- src/analytics/tracker.ts | 34 +++++++++++++++++++++++++++++----- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/analytics/Dashboard.tsx b/src/analytics/Dashboard.tsx index cb6b881f7..875a37fd0 100644 --- a/src/analytics/Dashboard.tsx +++ b/src/analytics/Dashboard.tsx @@ -252,8 +252,9 @@ export default function Dashboard() { value={totals.unique_visitors.toLocaleString()} /> Page
- + diff --git a/src/analytics/README.md b/src/analytics/README.md index f8b4ce48f..d732e66ab 100644 --- a/src/analytics/README.md +++ b/src/analytics/README.md @@ -5,7 +5,7 @@ This folder holds everything for the optional docs-site analytics dashboard (tra | File | Role | |------|------| | `setup.sql` | Run once in the Supabase SQL Editor (tables, RLS, views, grants). | -| `tracker.ts` | Injected on every page from `astro.config.mjs` — records page views and events. | +| `tracker.ts` | Injected on every page from `astro.config.mjs` — records page views, events, and active (foreground-tab) dwell time. | | `Dashboard.tsx` | React UI for `/analytics/` (loaded from `src/pages/analytics/index.astro`). | Environment: set `PUBLIC_SUPABASE_URL` and `PUBLIC_SUPABASE_ANON_KEY` in `.env` (see `.env.example`). diff --git a/src/analytics/tracker.ts b/src/analytics/tracker.ts index b682aa9d6..fd754e10a 100644 --- a/src/analytics/tracker.ts +++ b/src/analytics/tracker.ts @@ -1,8 +1,8 @@ /** * Custom Analytics Tracking Script * - * Captures page views, time on page, bounce rate, clicks, scroll depth, - * form interactions, and external link clicks, then writes + * Captures page views, active time on page (foreground tab only via Page Visibility), + * bounce rate, clicks, scroll depth, form interactions, and external link clicks, then writes * them to Supabase via the REST API. * * Injected on every page via the Astro integration in astro.config.mjs. @@ -125,11 +125,34 @@ function dbPatch( const { sessionId, visitorId } = getIds(); const pageViewId = uuid(); - const pageLoadTime = Date.now(); // Track whether the visitor navigates to another page (not a bounce) let navigated = false; + // ---- Visible dwell time only (ignore background tabs / minimized windows) ---- + let visibleMs = 0; + let visibleStartedAt: number | null = null; + + function pauseVisibleClock() { + if (visibleStartedAt !== null) { + visibleMs += Date.now() - visibleStartedAt; + visibleStartedAt = null; + } + } + + function syncVisibleClock() { + if (document.visibilityState === 'visible') { + if (visibleStartedAt === null) { + visibleStartedAt = Date.now(); + } + } else { + pauseVisibleClock(); + } + } + + document.addEventListener('visibilitychange', syncVisibleClock); + syncVisibleClock(); + // ---- Page view ---- dbInsert('page_views', { id: pageViewId, @@ -140,9 +163,10 @@ function dbPatch( title: document.title, }); - // ---- Duration + bounce on unload ---- + // ---- Active duration + bounce on unload ---- window.addEventListener('pagehide', () => { - const duration = Math.round((Date.now() - pageLoadTime) / 1000); + pauseVisibleClock(); + const duration = Math.round(visibleMs / 1000); dbPatch('page_views', `id=eq.${pageViewId}`, { duration_seconds: duration, // Per-page "no internal click before leave" — dashboards use session-level bounce in SQL From 05d0ab87f23c4096046a9158f3a169e1f825702b Mon Sep 17 00:00:00 2001 From: Bruce Denham Date: Fri, 8 May 2026 13:25:44 -0500 Subject: [PATCH 7/8] multiple fixes and formatting --- src/analytics/Dashboard.tsx | 800 ++++++++++++++++++++++++-------- src/analytics/README.md | 8 +- src/analytics/setup.sql | 333 +++++++++++++ src/pages/analytics/index.astro | 366 +++++++++++---- 4 files changed, 1232 insertions(+), 275 deletions(-) diff --git a/src/analytics/Dashboard.tsx b/src/analytics/Dashboard.tsx index 875a37fd0..b2684b0df 100644 --- a/src/analytics/Dashboard.tsx +++ b/src/analytics/Dashboard.tsx @@ -1,10 +1,12 @@ import { createClient } from '@supabase/supabase-js'; -import { useEffect, useState } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; import { + Area, CartesianGrid, + ComposedChart, Legend, Line, - LineChart, + ReferenceLine, ResponsiveContainer, Tooltip, XAxis, @@ -58,6 +60,8 @@ interface ExternalLinkRow { clicks: number; } +type TrendRange = 7 | 30 | 90 | 365; + const supabase = createClient( import.meta.env.PUBLIC_SUPABASE_URL as string, import.meta.env.PUBLIC_SUPABASE_ANON_KEY as string @@ -94,6 +98,161 @@ function truncateHref(url: string, max = 72): string { return url.slice(0, max) + '…'; } +const TREND_RANGE_OPTIONS: { value: TrendRange; label: string }[] = [ + { value: 7, label: 'Last 7 days' }, + { value: 30, label: 'Last 30 days' }, + { value: 90, label: 'Last 90 days' }, + { value: 365, label: 'Last 365 days' }, +]; + +function totalsViewName(range: TrendRange): string { + switch (range) { + case 7: + return 'analytics_totals_7d'; + case 30: + return 'analytics_totals_30d'; + case 90: + return 'analytics_totals_90d'; + case 365: + return 'analytics_totals_365d'; + } +} + +function engagementViewName(range: TrendRange): string { + switch (range) { + case 7: + return 'management_engagement_7d'; + case 30: + return 'management_engagement_30d'; + case 90: + return 'management_engagement_90d'; + case 365: + return 'management_engagement_365d'; + } +} + +function externalLinksViewName(range: TrendRange): string { + switch (range) { + case 7: + return 'external_link_clicks_7d'; + case 30: + return 'external_link_clicks_30d'; + case 90: + return 'external_link_clicks_90d'; + case 365: + return 'external_link_clicks_365d'; + } +} + +/** UTC `YYYY-MM-DD` for "today" (calendar date in UTC). */ +function utcTodayDateString(): string { + const n = new Date(); + return new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate())) + .toISOString() + .slice(0, 10); +} + +/** + * First UTC calendar date in the chart window (inclusive). + * The window has `rangeDays` days ending today UTC (same idea as "last N days" on the axis). + */ +function chartWindowStartDate(rangeDays: TrendRange): string { + const end = new Date(Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate() + )); + const start = new Date(end); + start.setUTCDate(start.getUTCDate() - (rangeDays - 1)); + return start.toISOString().slice(0, 10); +} + +function normalizeDayKey(day: string): string { + return day.slice(0, 10); +} + +/** One row per UTC calendar day in the window; missing days get zero counts (for a full horizontal axis). */ +function padDailyChartSeries( + rows: DailySummary[], + rangeDays: TrendRange +): DailySummary[] { + const startStr = chartWindowStartDate(rangeDays); + const endStr = utcTodayDateString(); + + const byDay = new Map(); + for (const row of rows) { + byDay.set(normalizeDayKey(row.day), row); + } + + const emptyDay = (day: string): DailySummary => ({ + day, + visits: 0, + page_views: 0, + unique_visitors: 0, + avg_duration_seconds: 0, + bounce_rate_pct: 0, + }); + + const [y0, m0, d0] = startStr.split('-').map(Number); + const [y1, m1, d1] = endStr.split('-').map(Number); + const cur = new Date(Date.UTC(y0, m0 - 1, d0)); + const end = new Date(Date.UTC(y1, m1 - 1, d1)); + const out: DailySummary[] = []; + while (cur.getTime() <= end.getTime()) { + const key = cur.toISOString().slice(0, 10); + out.push(byDay.get(key) ?? emptyDay(key)); + cur.setUTCDate(cur.getUTCDate() + 1); + } + return out; +} + +function isPostgrestLikeError( + err: unknown +): err is { message: string; details?: string; hint?: string; code?: string } { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as { message: unknown }).message === 'string' + ); +} + +/** Turns Supabase/PostgREST errors into readable text for the error banner. */ +function formatAnalyticsLoadError(err: unknown): string { + if (isPostgrestLikeError(err)) { + let s = err.message; + if (err.details) s += ` ${err.details}`; + if (err.hint) s += ` ${err.hint}`; + return s; + } + if (err instanceof Error) return err.message; + return 'Failed to load analytics data.'; +} + +/** True when this index is a strict local maximum (used for highlight dots on page views). */ +function isLocalPeak( + data: DailySummary[], + index: number, + key: 'page_views' | 'visits' | 'unique_visitors' +): boolean { + if (data.length < 3) return false; + if (index <= 0 || index >= data.length - 1) return false; + const v = data[index][key]; + const prev = data[index - 1][key]; + const next = data[index + 1][key]; + return v > prev && v > next; +} + +/** Site root only (`/` or trailing-slash equivalent) — hides unlabeled hits from the Top pages table. */ +function isSiteRootUrl(url: string): boolean { + try { + const path = new URL(url).pathname.replace(/\/+$/, '') || '/'; + return path === '/'; + } catch { + return false; + } +} + /** Same route rule as tracker.ts — exclude standalone `/analytics` dashboard URLs. */ function isAnalyticsDashboardUrl(url: string): boolean { try { @@ -108,24 +267,6 @@ function isAnalyticsDashboardUrl(url: string): boolean { // Sub-components // --------------------------------------------------------------------------- -function StatCard({ - label, - value, - sub, -}: { - label: string; - value: string | number; - sub?: string; -}) { - return ( -
- {label} - {value} - {sub && {sub}} -
- ); -} - function SectionHeader({ title }: { title: string }) { return

{title}

; } @@ -144,11 +285,95 @@ function ErrorBanner({ message }: { message: string }) { return

{message}

; } +function RangeControl({ + value, + onChange, + disabled, +}: { + value: TrendRange; + onChange: (next: TrendRange) => void; + disabled?: boolean; +}) { + return ( +
+ {TREND_RANGE_OPTIONS.map((opt) => ( + + ))} +
+ ); +} + // --------------------------------------------------------------------------- // Main Dashboard // --------------------------------------------------------------------------- -export default function Dashboard() { +interface DashboardProps { + docsHome: string; +} + +function AnalyticsSiteHeader({ + docsHome, + showRange, + trendRange, + onTrendRange, + loading, +}: { + docsHome: string; + showRange: boolean; + trendRange?: TrendRange; + onTrendRange?: (r: TrendRange) => void; + loading?: boolean; +}) { + return ( +
+
+
+

Storefront Analytics

+ {showRange && trendRange != null && onTrendRange ? ( + + ) : null} +
+ + Commerce Storefront Docs + + +
+
+ ); +} + +export default function Dashboard({ docsHome }: DashboardProps) { + const [trendRange, setTrendRange] = useState(7); const [totals, setTotals] = useState(null); const [chartData, setChartData] = useState([]); const [topPages, setTopPages] = useState([]); @@ -158,8 +383,20 @@ export default function Dashboard() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const areaGradientId = `dailyPvGrad-${useId().replace(/:/g, '')}`; + const dailyChartMeta = useMemo(() => { + const n = chartData.length; + if (n === 0) return { meanPv: 0, lastDay: null as string | null }; + return { + meanPv: chartData.reduce((s, d) => s + d.page_views, 0) / n, + lastDay: chartData[n - 1]?.day ?? null, + }; + }, [chartData]); + useEffect(() => { async function load() { + setLoading(true); + setError(null); try { const [ totalsRes, @@ -169,16 +406,17 @@ export default function Dashboard() { engagementRes, externalLinksRes, ] = await Promise.all([ - supabase.from('analytics_totals_30d').select('*').single(), + supabase.from(totalsViewName(trendRange)).select('*').single(), supabase .from('analytics_summary') .select('*') + .gte('day', chartWindowStartDate(trendRange)) .order('day', { ascending: false }) - .limit(30), + .limit(400), supabase.from('top_pages').select('*').limit(40), supabase.from('events_summary').select('*').limit(30), - supabase.from('management_engagement_30d').select('*').single(), - supabase.from('external_link_clicks_30d').select('*').limit(100), + supabase.from(engagementViewName(trendRange)).select('*').single(), + supabase.from(externalLinksViewName(trendRange)).select('*').limit(100), ]); if (totalsRes.error) throw totalsRes.error; @@ -190,26 +428,40 @@ export default function Dashboard() { setTotals(totalsRes.data as Totals); setChartData( - ([...(chartRes.data ?? [])] as DailySummary[]).reverse() + padDailyChartSeries( + ([...(chartRes.data ?? [])] as DailySummary[]).reverse(), + trendRange + ) ); const pages = (pagesRes.data ?? []) as TopPage[]; setTopPages( - pages.filter((p) => !isAnalyticsDashboardUrl(p.url)).slice(0, 20) + pages + .filter( + (p) => + !isAnalyticsDashboardUrl(p.url) && !isSiteRootUrl(p.url) + ) + .slice(0, 20) ); setEvents((eventsRes.data ?? []) as EventRow[]); setEngagement(engagementRes.data as EngagementRow); setExternalLinks((externalLinksRes.data ?? []) as ExternalLinkRow[]); } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to load analytics data.' - ); + let message = formatAnalyticsLoadError(err); + if ( + trendRange !== 30 && + !message.toLowerCase().includes('setup.sql') + ) { + message += + ' For 7-, 90-, or 365-day windows, your Supabase project needs the matching views from `src/analytics/setup.sql` (names ending in `_7d`, `_90d`, or `_365d`). Paste the full file into the Supabase SQL Editor, run it, then reload this page.'; + } + setError(message); } finally { setLoading(false); } } load(); - }, []); + }, [trendRange]); const isConfigured = import.meta.env.PUBLIC_SUPABASE_URL && @@ -217,196 +469,226 @@ export default function Dashboard() { if (!isConfigured) { return ( -
-

- Analytics not configured. Add{' '} - PUBLIC_SUPABASE_URL and{' '} - PUBLIC_SUPABASE_ANON_KEY to your{' '} - .env file, then restart the dev server. -

-
+ <> + +
+
+

+ Storefront Analytics is not configured. Add{' '} + PUBLIC_SUPABASE_URL and{' '} + PUBLIC_SUPABASE_ANON_KEY to your{' '} + .env file, then restart the dev server. +

+
+
+ ); } return ( -
- - - {loading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} -
- ) : error ? ( - - ) : totals ? ( -
- - - - - -
- ) : null} - - -

- Every click that leaves this site to another origin. Compare with top - pages to see which topics send traffic outward. -

- - {loading ? ( - - ) : externalLinks.length === 0 ? ( -

- No external link clicks yet. Open a few outbound links from the docs, - then refresh this page. -

- ) : ( -
-
DestinationCategoryLink Clicks
{outcomeLabel(row.outcome_key)}
- {row.category ?? '—'} + + {truncateHref(row.href)} + + + {Number(row.clicks).toLocaleString()} {Number(row.clicks).toLocaleString()}
Views Unique visitorsAvg. timeAvg. active time
- - - - - - - - {externalLinks.map((row) => ( - - - - - ))} - -
LinkClicks
- - {truncateHref(row.href)} - - - {Number(row.clicks).toLocaleString()} -
-
- )} - - -

- Avg pages per visit, multi-page sessions, and visitors who came back in - more than one session. -

- - {loading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
- ))} -
- ) : engagement ? ( -
- - - 0 - ? `${Math.round( - (100 * Number(engagement.returning_visitors ?? 0)) / - Number(engagement.unique_visitors_30d) - )}% of unique visitors` - : undefined - } - /> - -
- ) : null} - - + <> + +
+
+ + + {loading ? ( +
+ + + + + + + + + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + +
VisitsPage viewsUnique visitorsAvg. active timeBounce rate
+
+
+ ) : error ? ( + + ) : totals ? ( +
+ + + + + + + + + + + + + + + + + + + +
VisitsPage viewsUnique visitorsAvg. active timeBounce rate
+ {totals.visits.toLocaleString()} + + {totals.page_views.toLocaleString()} + + {totals.unique_visitors.toLocaleString()} + + {fmtDuration(totals.avg_duration_seconds)} + + foreground tab only + + + {`${totals.bounce_rate_pct}%`} + + single-page sessions + +
+
+ ) : null} + + {loading ? (
) : chartData.length === 0 ? (

- No data yet. Visit a few pages to see the trend. + No data in this range yet. Visit a few pages, then refresh.

) : ( -
- - + + - + + + + + + + = 90 ? 28 : trendRange <= 7 ? 4 : 8} /> fmtShortDate(String(v))} contentStyle={{ - background: 'var(--chart-tooltip-bg)', - border: '1px solid var(--chart-grid)', - borderRadius: '6px', - fontSize: '13px', + background: 'var(--chart-daily-tooltip-bg)', + border: '1px solid var(--chart-daily-tooltip-border)', + borderRadius: '8px', + fontSize: '12px', + color: 'var(--chart-daily-tooltip-fg)', }} + labelStyle={{ color: 'var(--chart-daily-tooltip-muted)' }} + itemStyle={{ color: 'var(--chart-daily-tooltip-fg)' }} /> - - + { + const { cx, cy, index } = dotProps; + if ( + cx == null || + cy == null || + index == null || + !isLocalPeak(chartData, index, 'page_views') + ) { + return null; + } + return ( + + ); + }} + activeDot={{ r: 5 }} /> @@ -414,17 +696,34 @@ export default function Dashboard() { type="monotone" dataKey="unique_visitors" name="Unique visitors" - stroke="var(--chart-line-3)" - strokeWidth={2} + stroke="var(--chart-daily-unique-line)" + strokeWidth={1.25} dot={false} activeDot={{ r: 4 }} /> - + {dailyChartMeta.meanPv > 0 && + Number.isFinite(dailyChartMeta.meanPv) ? ( + + ) : null} + {dailyChartMeta.lastDay ? ( + + ) : null} +
)} - + {loading ? ( @@ -432,7 +731,7 @@ export default function Dashboard() {

No page view data yet.

) : (
- +
@@ -463,7 +762,120 @@ export default function Dashboard() { )} - + {loading ? ( +
+
Page
+ + + + + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + +
Avg pages / sessionSessions with 2+ pagesReturning visitorsUnique visitors
+
+
+ ) : engagement ? ( +
+ + + + + + + + + + + + + + + + + +
Avg pages / sessionSessions with 2+ pagesReturning visitorsUnique visitors
+ {Number(engagement.avg_pages_per_session ?? 0).toFixed(2)} + + {Number( + engagement.sessions_with_2plus_pages ?? 0 + ).toLocaleString()} + + of{' '} + {Number(engagement.sessions_total ?? 0).toLocaleString()}{' '} + sessions + + + {Number(engagement.returning_visitors ?? 0).toLocaleString()} + {Number(engagement.unique_visitors_30d ?? 0) > 0 ? ( + + {`${Math.round( + (100 * Number(engagement.returning_visitors ?? 0)) / + Number(engagement.unique_visitors_30d) + )}% of unique visitors`} + + ) : null} + + {Number(engagement.unique_visitors_30d ?? 0).toLocaleString()} +
+
+ ) : null} + + + + {loading ? ( + + ) : externalLinks.length === 0 ? ( +

+ No external link clicks yet. Open a few outbound links from the docs, + then refresh this page. +

+ ) : ( +
+ + + + + + + + + {externalLinks.map((row) => ( + + + + + ))} + +
LinkClicks
+ + {truncateHref(row.href)} + + + {Number(row.clicks).toLocaleString()} +
+
+ )} + + {loading ? ( @@ -495,6 +907,8 @@ export default function Dashboard() {
)} -
+
+ + ); } diff --git a/src/analytics/README.md b/src/analytics/README.md index d732e66ab..3e1676654 100644 --- a/src/analytics/README.md +++ b/src/analytics/README.md @@ -4,10 +4,14 @@ This folder holds everything for the optional docs-site analytics dashboard (tra | File | Role | |------|------| -| `setup.sql` | Run once in the Supabase SQL Editor (tables, RLS, views, grants). | +| `setup.sql` | Run in the Supabase SQL Editor (tables, RLS, views, grants). Re-run when this file adds views (for example `analytics_totals_7d`, `analytics_totals_90d` / `analytics_totals_365d`, matching `management_engagement_*` and `external_link_clicks_*` views for each dashboard date range). | | `tracker.ts` | Injected on every page from `astro.config.mjs` — records page views, events, and active (foreground-tab) dwell time. | | `Dashboard.tsx` | React UI for `/analytics/` (loaded from `src/pages/analytics/index.astro`). | -Environment: set `PUBLIC_SUPABASE_URL` and `PUBLIC_SUPABASE_ANON_KEY` in `.env` (see `.env.example`). +Environment: set `PUBLIC_SUPABASE_URL` and `PUBLIC_SUPABASE_ANON_KEY` in your `.env` file (see `.env.example`). + +## Date range (7 / 30 / 90 / 365 days) + +The dashboard calls separate views for each window (for example `analytics_totals_7d`, `analytics_totals_90d`, `management_engagement_365d`, `external_link_clicks_7d`). If Last 7 days, Last 90 days, or Last 365 days shows a load error but Last 30 days works, your Supabase project is missing those views. Re-run the current `setup.sql` in the SQL Editor (safe to run again; it uses `CREATE OR REPLACE`). Import alias: `@analytics/Dashboard` (see `tsconfig.json`). diff --git a/src/analytics/setup.sql b/src/analytics/setup.sql index d9e89d8f0..f08fed794 100644 --- a/src/analytics/setup.sql +++ b/src/analytics/setup.sql @@ -78,6 +78,42 @@ ORDER BY page_views DESC LIMIT 50; +-- 7-day totals (same logic as analytics_totals_30d; used when the dashboard range is 7 days) +CREATE OR REPLACE VIEW analytics_totals_7d +WITH (security_invoker = true) AS +WITH windowed AS ( + SELECT * + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' +), +session_pv AS ( + SELECT session_id, COUNT(*) AS pv_count + FROM windowed + GROUP BY session_id +) +SELECT + (SELECT COUNT(DISTINCT session_id) FROM windowed) AS visits, + (SELECT COUNT(*) FROM windowed) AS page_views, + (SELECT COUNT(DISTINCT visitor_id) FROM windowed) AS unique_visitors, + COALESCE( + ( + SELECT ROUND(AVG(duration_seconds)::NUMERIC, 0) + FROM windowed + WHERE duration_seconds IS NOT NULL + ), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * (SELECT COUNT(*) FROM session_pv WHERE pv_count = 1)::NUMERIC + / NULLIF((SELECT COUNT(*) FROM session_pv), 0), + 1 + ), + 0 + ) AS bounce_rate_pct; + + -- 30-day totals for summary cards — accurate unique counts across the window -- Bounce rate = sessions with exactly one page view in the window / all sessions CREATE OR REPLACE VIEW analytics_totals_30d @@ -115,6 +151,78 @@ SELECT ) AS bounce_rate_pct; +-- 90-day totals (same logic as analytics_totals_30d; used when the dashboard range is 90 days) +CREATE OR REPLACE VIEW analytics_totals_90d +WITH (security_invoker = true) AS +WITH windowed AS ( + SELECT * + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' +), +session_pv AS ( + SELECT session_id, COUNT(*) AS pv_count + FROM windowed + GROUP BY session_id +) +SELECT + (SELECT COUNT(DISTINCT session_id) FROM windowed) AS visits, + (SELECT COUNT(*) FROM windowed) AS page_views, + (SELECT COUNT(DISTINCT visitor_id) FROM windowed) AS unique_visitors, + COALESCE( + ( + SELECT ROUND(AVG(duration_seconds)::NUMERIC, 0) + FROM windowed + WHERE duration_seconds IS NOT NULL + ), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * (SELECT COUNT(*) FROM session_pv WHERE pv_count = 1)::NUMERIC + / NULLIF((SELECT COUNT(*) FROM session_pv), 0), + 1 + ), + 0 + ) AS bounce_rate_pct; + + +-- 365-day totals (same logic; used when the dashboard range is one year) +CREATE OR REPLACE VIEW analytics_totals_365d +WITH (security_invoker = true) AS +WITH windowed AS ( + SELECT * + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' +), +session_pv AS ( + SELECT session_id, COUNT(*) AS pv_count + FROM windowed + GROUP BY session_id +) +SELECT + (SELECT COUNT(DISTINCT session_id) FROM windowed) AS visits, + (SELECT COUNT(*) FROM windowed) AS page_views, + (SELECT COUNT(DISTINCT visitor_id) FROM windowed) AS unique_visitors, + COALESCE( + ( + SELECT ROUND(AVG(duration_seconds)::NUMERIC, 0) + FROM windowed + WHERE duration_seconds IS NOT NULL + ), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * (SELECT COUNT(*) FROM session_pv WHERE pv_count = 1)::NUMERIC + / NULLIF((SELECT COUNT(*) FROM session_pv), 0), + 1 + ), + 0 + ) AS bounce_rate_pct; + + -- Event breakdown by type and name (excludes outcome, external_link, and legacy web_vital rows) CREATE OR REPLACE VIEW events_summary WITH (security_invoker = true) AS @@ -128,6 +236,66 @@ GROUP BY event_type, event_name ORDER BY event_count DESC; +-- Engagement depth (last 7 days) — same shape as management_engagement_30d +CREATE OR REPLACE VIEW management_engagement_7d +WITH (security_invoker = true) AS +SELECT + COALESCE( + ( + SELECT ROUND(AVG(cnt)::numeric, 2) + FROM ( + SELECT session_id, COUNT(*)::bigint AS cnt + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + GROUP BY session_id + ) q + ), + 0 + ) AS avg_pages_per_session, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT session_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + GROUP BY session_id + HAVING COUNT(*) >= 2 + ) m + ), + 0 + ) AS sessions_with_2plus_pages, + COALESCE( + ( + SELECT COUNT(DISTINCT session_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + ), + 0 + ) AS sessions_total, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT visitor_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + GROUP BY visitor_id + HAVING COUNT(DISTINCT session_id) > 1 + ) r + ), + 0 + ) AS returning_visitors, + COALESCE( + ( + SELECT COUNT(DISTINCT visitor_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + ), + 0 + ) AS unique_visitors_30d; + + -- Engagement depth (last 30 days): pages per session and returning visitors CREATE OR REPLACE VIEW management_engagement_30d WITH (security_invoker = true) AS @@ -188,6 +356,126 @@ SELECT ) AS unique_visitors_30d; +-- Engagement depth (last 90 days) — same shape as management_engagement_30d +CREATE OR REPLACE VIEW management_engagement_90d +WITH (security_invoker = true) AS +SELECT + COALESCE( + ( + SELECT ROUND(AVG(cnt)::numeric, 2) + FROM ( + SELECT session_id, COUNT(*)::bigint AS cnt + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + GROUP BY session_id + ) q + ), + 0 + ) AS avg_pages_per_session, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT session_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + GROUP BY session_id + HAVING COUNT(*) >= 2 + ) m + ), + 0 + ) AS sessions_with_2plus_pages, + COALESCE( + ( + SELECT COUNT(DISTINCT session_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + ), + 0 + ) AS sessions_total, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT visitor_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + GROUP BY visitor_id + HAVING COUNT(DISTINCT session_id) > 1 + ) r + ), + 0 + ) AS returning_visitors, + COALESCE( + ( + SELECT COUNT(DISTINCT visitor_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + ), + 0 + ) AS unique_visitors_30d; + + +-- Engagement depth (last 365 days) — same shape as management_engagement_30d +CREATE OR REPLACE VIEW management_engagement_365d +WITH (security_invoker = true) AS +SELECT + COALESCE( + ( + SELECT ROUND(AVG(cnt)::numeric, 2) + FROM ( + SELECT session_id, COUNT(*)::bigint AS cnt + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + GROUP BY session_id + ) q + ), + 0 + ) AS avg_pages_per_session, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT session_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + GROUP BY session_id + HAVING COUNT(*) >= 2 + ) m + ), + 0 + ) AS sessions_with_2plus_pages, + COALESCE( + ( + SELECT COUNT(DISTINCT session_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + ), + 0 + ) AS sessions_total, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT visitor_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + GROUP BY visitor_id + HAVING COUNT(DISTINCT session_id) > 1 + ) r + ), + 0 + ) AS returning_visitors, + COALESCE( + ( + SELECT COUNT(DISTINCT visitor_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + ), + 0 + ) AS unique_visitors_30d; + + -- High-value outbound clicks (legacy outcome events), last 30 days CREATE OR REPLACE VIEW outcome_clicks_30d WITH (security_invoker = true) AS @@ -202,6 +490,21 @@ GROUP BY event_name ORDER BY clicks DESC; +-- External link clicks by destination URL, last 7 days +CREATE OR REPLACE VIEW public.external_link_clicks_7d +WITH (security_invoker = true) AS +SELECT + event_data->>'href' AS href, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'external_link' + AND timestamp >= NOW() - INTERVAL '7 days' + AND COALESCE(NULLIF(TRIM(event_data->>'href'), ''), '') <> '' +GROUP BY event_data->>'href' +ORDER BY clicks DESC +LIMIT 100; + + -- External link clicks by destination URL, last 30 days (one row per href) CREATE OR REPLACE VIEW public.external_link_clicks_30d WITH (security_invoker = true) AS @@ -214,4 +517,34 @@ WHERE event_type = 'external_link' AND COALESCE(NULLIF(TRIM(event_data->>'href'), ''), '') <> '' GROUP BY event_data->>'href' ORDER BY clicks DESC +LIMIT 100; + + +-- External link clicks by destination URL, last 90 days +CREATE OR REPLACE VIEW public.external_link_clicks_90d +WITH (security_invoker = true) AS +SELECT + event_data->>'href' AS href, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'external_link' + AND timestamp >= NOW() - INTERVAL '90 days' + AND COALESCE(NULLIF(TRIM(event_data->>'href'), ''), '') <> '' +GROUP BY event_data->>'href' +ORDER BY clicks DESC +LIMIT 100; + + +-- External link clicks by destination URL, last 365 days +CREATE OR REPLACE VIEW public.external_link_clicks_365d +WITH (security_invoker = true) AS +SELECT + event_data->>'href' AS href, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'external_link' + AND timestamp >= NOW() - INTERVAL '365 days' + AND COALESCE(NULLIF(TRIM(event_data->>'href'), ''), '') <> '' +GROUP BY event_data->>'href' +ORDER BY clicks DESC LIMIT 100; \ No newline at end of file diff --git a/src/pages/analytics/index.astro b/src/pages/analytics/index.astro index 1017a4f68..ecca51d7e 100644 --- a/src/pages/analytics/index.astro +++ b/src/pages/analytics/index.astro @@ -1,13 +1,53 @@ --- import Dashboard from '@analytics/Dashboard'; +import { pathWithBase } from '@utils/base'; + +const docsHome = pathWithBase(''); --- + - Analytics — Commerce Storefront Docs + Storefront Analytics — Commerce Storefront Docs @@ -18,31 +58,7 @@ import Dashboard from '@analytics/Dashboard'; - - - - -
- -
+
@@ -65,6 +81,7 @@ import Dashboard from '@analytics/Dashboard'; } :root { + color-scheme: light; --bg: #f8f9fa; --bg-surface: #ffffff; --border: #e5e7eb; @@ -82,6 +99,12 @@ import Dashboard from '@analytics/Dashboard'; --badge-custom-color: #6b21a8; --badge-outcome-bg: #e0f2fe; --badge-outcome-color: #0369a1; + --analytics-header-height: 56px; + /* Section titles use the same token as body text (light mode: dark type; dark mode: near-white). */ + --section-header-color: var(--text); + /* Amber text/links: ≥4.5:1 on --bg (#f8f9fa) and on white cards (WCAG AA). */ + --analytics-link: #9a3412; + --analytics-link-hover: #7c2d12; --chart-grid: #e5e7eb; --chart-label: #9ca3af; --chart-tooltip-bg: #ffffff; @@ -90,32 +113,34 @@ import Dashboard from '@analytics/Dashboard'; --chart-line-3: #9333ea; } - @media (prefers-color-scheme: dark) { - :root { - --bg: #0f172a; - --bg-surface: #1e293b; - --border: #334155; - --text: #f1f5f9; - --text-muted: #94a3b8; - --accent: #60a5fa; - --accent-hover: #93c5fd; - --badge-click-bg: #1e3a5f; - --badge-click-color: #93c5fd; - --badge-scroll-bg: #14532d; - --badge-scroll-color: #86efac; - --badge-form-bg: #451a03; - --badge-form-color: #fde047; - --badge-custom-bg: #2e1065; - --badge-custom-color: #d8b4fe; - --badge-outcome-bg: #0c4a6e; - --badge-outcome-color: #7dd3fc; - --chart-grid: #334155; - --chart-label: #64748b; - --chart-tooltip-bg: #1e293b; - --chart-line-1: #60a5fa; - --chart-line-2: #4ade80; - --chart-line-3: #c084fc; - } + html[data-theme='dark'] { + color-scheme: dark; + --bg: #0f172a; + --bg-surface: #1e293b; + --border: #334155; + --text: #f1f5f9; + --text-muted: #94a3b8; + --accent: #60a5fa; + --accent-hover: #93c5fd; + --badge-click-bg: #1e3a5f; + --badge-click-color: #93c5fd; + --badge-scroll-bg: #14532d; + --badge-scroll-color: #86efac; + --badge-form-bg: #451a03; + --badge-form-color: #fde047; + --badge-custom-bg: #2e1065; + --badge-custom-color: #d8b4fe; + --badge-outcome-bg: #0c4a6e; + --badge-outcome-color: #7dd3fc; + /* Amber on dark slate: ≥4.5:1 on --bg (#0f172a) (WCAG AA). */ + --analytics-link: #fbbf24; + --analytics-link-hover: #fcd34d; + --chart-grid: #334155; + --chart-label: #64748b; + --chart-tooltip-bg: #1e293b; + --chart-line-1: #60a5fa; + --chart-line-2: #4ade80; + --chart-line-3: #c084fc; } html, @@ -134,11 +159,11 @@ import Dashboard from '@analytics/Dashboard'; } a { - color: var(--accent); + color: var(--analytics-link); text-decoration: none; } a:hover { - color: var(--accent-hover); + color: var(--analytics-link-hover); text-decoration: underline; } @@ -151,34 +176,54 @@ import Dashboard from '@analytics/Dashboard'; z-index: 10; } - .header-inner { + .analytics-header-inner { max-width: 1100px; margin: 0 auto; - padding: 0 1.5rem; - height: 56px; + padding: 0.65rem 1.5rem; + min-height: var(--analytics-header-height); display: flex; align-items: center; justify-content: space-between; gap: 1rem; + flex-wrap: wrap; + } + + .analytics-header-group { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem 1rem; + min-width: 0; + } + + .analytics-header-title { + font-weight: 600; + font-size: 1rem; + color: var(--text); + margin: 0; + white-space: nowrap; + } + + .analytics-header-group .range-control { + margin-bottom: 0; } .home-link { display: flex; align-items: center; gap: 0.375rem; - color: var(--text-muted); + color: var(--analytics-link); font-size: 0.875rem; white-space: nowrap; } .home-link:hover { - color: var(--text); + color: var(--analytics-link-hover); text-decoration: none; } - .header-title { - font-weight: 600; - font-size: 1rem; - color: var(--text); + .home-link--docs { + flex-shrink: 0; + margin-left: auto; } /* ---- Main layout ---- */ @@ -197,6 +242,55 @@ import Dashboard from '@analytics/Dashboard'; border-top: 1px solid var(--border); } + /* ---- Date range (summary + chart) ---- */ + .range-control { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-bottom: 1rem; + } + + .range-control__btn { + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + padding: 0.4rem 0.75rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-surface); + color: var(--text-muted); + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease, + border-color 0.15s ease; + } + + .range-control__btn:hover:not(:disabled) { + color: var(--text); + border-color: var(--accent); + } + + .range-control__btn:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .range-control__btn--active { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; + } + + html[data-theme='dark'] .range-control__btn--active { + color: #0f172a; + } + + .range-control__btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + /* ---- Dashboard container ---- */ .dashboard-container { display: flex; @@ -208,12 +302,15 @@ import Dashboard from '@analytics/Dashboard'; .section-header { font-size: 0.875rem; font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text); + letter-spacing: normal; + color: var(--section-header-color); margin-top: 1rem; } + .dashboard-container > .section-header { + margin-top: 0; + } + .section-intro { font-size: 0.875rem; color: var(--text-muted); @@ -233,9 +330,21 @@ import Dashboard from '@analytics/Dashboard'; gap: 1rem; } - /* Engagement row: slightly wider second column for the long label */ - .cards-grid--engagement { - grid-template-columns: minmax(0, 1fr) minmax(10rem, 1.22fr) minmax(0, 1fr) minmax(0, 1fr); + .data-table__cell-sub { + display: block; + margin-top: 0.1875rem; + font-size: 0.75rem; + font-weight: 400; + color: var(--text-muted); + } + + .summary-loading-bar { + height: 1.25rem; + max-width: 5.5rem; + margin-left: auto; + border-radius: 6px; + background: var(--border); + animation: pulse 1.5s ease-in-out infinite; } .stat-card { @@ -281,8 +390,58 @@ import Dashboard from '@analytics/Dashboard'; padding: 1.25rem 0.5rem 0.75rem; } + .chart-wrapper.chart-wrapper--daily { + /* Light surface: cyan / amber / pink series */ + --chart-daily-primary: #0891b2; + --chart-daily-area-top: #22d3ee; + --chart-daily-area-bottom: #cffafe; + --chart-daily-mean-line: rgba(71, 85, 105, 0.55); + --chart-daily-now-line: rgba(217, 119, 6, 0.75); + --chart-daily-visits-line: #d97706; + --chart-daily-unique-line: #db2777; + --chart-daily-grid: rgba(100, 116, 139, 0.18); + --chart-daily-label: #64748b; + --chart-daily-axis-line: rgba(100, 116, 139, 0.35); + --chart-daily-legend: #334155; + --chart-daily-tooltip-bg: #ffffff; + --chart-daily-tooltip-border: #e2e8f0; + --chart-daily-tooltip-fg: #0f172a; + --chart-daily-tooltip-muted: #64748b; + --chart-daily-peak-dot: #65a30d; + --chart-daily-peak-ring: rgba(255, 255, 255, 0.95); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.75rem 0.375rem 0.5rem; + } + + html[data-theme='dark'] .chart-wrapper.chart-wrapper--daily { + /* Hue-separated series on near-black panel */ + --chart-daily-primary: #22d3ee; + --chart-daily-area-top: #22d3ee; + --chart-daily-area-bottom: #155e75; + --chart-daily-mean-line: rgba(226, 232, 240, 0.75); + --chart-daily-now-line: rgba(251, 191, 36, 0.55); + --chart-daily-visits-line: #fbbf24; + --chart-daily-unique-line: #f472b6; + --chart-daily-grid: rgba(148, 163, 184, 0.12); + --chart-daily-label: #94a3b8; + --chart-daily-axis-line: rgba(148, 163, 184, 0.25); + --chart-daily-legend: #e2e8f0; + --chart-daily-tooltip-bg: #0f172a; + --chart-daily-tooltip-border: #334155; + --chart-daily-tooltip-fg: #e2e8f0; + --chart-daily-tooltip-muted: #94a3b8; + --chart-daily-peak-dot: #a3e635; + --chart-daily-peak-ring: rgba(15, 23, 42, 0.85); + background: #070b14; + border: 1px solid #1e293b; + border-radius: 10px; + padding: 0.75rem 0.375rem 0.5rem; + } + .chart-placeholder { - height: 280px; + height: 300px; border-radius: 10px; background: var(--border); animation: pulse 1.5s ease-in-out infinite; @@ -310,13 +469,13 @@ import Dashboard from '@analytics/Dashboard'; text-transform: uppercase; letter-spacing: 0.04em; padding: 0.625rem 1rem; - text-align: left; + text-align: center; vertical-align: middle; border-bottom: 1px solid var(--border); } .data-table th.num-col { - text-align: right; + text-align: center; } .data-table td { @@ -338,6 +497,40 @@ import Dashboard from '@analytics/Dashboard'; font-variant-numeric: tabular-nums; } + /* Top pages: Page column left; Views, Unique visitors, Avg. active time centered */ + .data-table--top-pages th:nth-child(1), + .data-table--top-pages td:nth-child(1) { + text-align: left; + } + + .data-table--top-pages th:nth-child(n + 2), + .data-table--top-pages td:nth-child(n + 2) { + text-align: center; + } + + /* Single-row metric tables (Summary, engagement row): centered values */ + .data-table--metrics-row td { + text-align: center; + } + + .data-table--metrics-row tbody td { + font-size: 1.125rem; + font-weight: 700; + line-height: 1.25; + color: var(--text); + padding-top: 0.875rem; + padding-bottom: 0.875rem; + } + + .data-table--metrics-row .data-table__cell-sub { + text-align: center; + font-weight: 400; + } + + .data-table--metrics-row .summary-loading-bar { + margin-inline: auto; + } + .page-link { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.8125rem; @@ -426,6 +619,23 @@ import Dashboard from '@analytics/Dashboard'; font-size: 0.8125rem; } + html[data-theme='dark'] .error-banner { + background: rgba(127, 29, 29, 0.35); + border-color: #b91c1c; + color: #fecaca; + } + + html[data-theme='dark'] .setup-notice { + background: rgba(120, 53, 15, 0.45); + border-color: #b45309; + color: #fde68a; + } + + html[data-theme='dark'] .setup-notice code { + background: rgba(0, 0, 0, 0.25); + color: #fef3c7; + } + @keyframes pulse { 0%, 100% { @@ -441,18 +651,14 @@ import Dashboard from '@analytics/Dashboard'; grid-template-columns: 1fr 1fr; } - .cards-grid--engagement { - grid-template-columns: 1fr 1fr; - } - .stat-value { font-size: 1.375rem; } - .data-table th:nth-child(3), - .data-table td:nth-child(3), - .data-table th:nth-child(4), - .data-table td:nth-child(4) { + .data-table:not(.data-table--metrics-row) th:nth-child(3), + .data-table:not(.data-table--metrics-row) td:nth-child(3), + .data-table:not(.data-table--metrics-row) th:nth-child(4), + .data-table:not(.data-table--metrics-row) td:nth-child(4) { display: none; } } From 8a6e739a77cb56ccd192d2a134e81a5a215b3b0a Mon Sep 17 00:00:00 2001 From: Bruce Denham Date: Fri, 8 May 2026 14:29:31 -0500 Subject: [PATCH 8/8] Updates to close this out --- src/analytics/Dashboard.tsx | 6 ++-- src/pages/analytics/index.astro | 56 +++++++++++++++++++-------------- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/src/analytics/Dashboard.tsx b/src/analytics/Dashboard.tsx index b2684b0df..7b0faee2a 100644 --- a/src/analytics/Dashboard.tsx +++ b/src/analytics/Dashboard.tsx @@ -833,7 +833,7 @@ export default function Dashboard({ docsHome }: DashboardProps) {
) : null} - + {loading ? ( @@ -844,7 +844,7 @@ export default function Dashboard({ docsHome }: DashboardProps) {

) : (
- +
@@ -883,7 +883,7 @@ export default function Dashboard({ docsHome }: DashboardProps) {

No events recorded yet.

) : (
-
Link
+
diff --git a/src/pages/analytics/index.astro b/src/pages/analytics/index.astro index ecca51d7e..99f39532a 100644 --- a/src/pages/analytics/index.astro +++ b/src/pages/analytics/index.astro @@ -82,9 +82,10 @@ const docsHome = pathWithBase(''); :root { color-scheme: light; - --bg: #f8f9fa; - --bg-surface: #ffffff; - --border: #e5e7eb; + --bg: #e8eaed; + --bg-surface: #f4f5f7; + --border: #d1d5db; + --table-header-bg: #dfe3e8; --text: #111827; --text-muted: #6b7280; --accent: #2563eb; @@ -102,9 +103,9 @@ const docsHome = pathWithBase(''); --analytics-header-height: 56px; /* Section titles use the same token as body text (light mode: dark type; dark mode: near-white). */ --section-header-color: var(--text); - /* Amber text/links: ≥4.5:1 on --bg (#f8f9fa) and on white cards (WCAG AA). */ - --analytics-link: #9a3412; - --analytics-link-hover: #7c2d12; + /* Links: light mode matches active range chip (--accent); dark mode keeps warm contrast on slate. */ + --analytics-link: var(--accent); + --analytics-link-hover: var(--accent-hover); --chart-grid: #e5e7eb; --chart-label: #9ca3af; --chart-tooltip-bg: #ffffff; @@ -118,6 +119,7 @@ const docsHome = pathWithBase(''); --bg: #0f172a; --bg-surface: #1e293b; --border: #334155; + --table-header-bg: #0f172a; --text: #f1f5f9; --text-muted: #94a3b8; --accent: #60a5fa; @@ -391,14 +393,14 @@ const docsHome = pathWithBase(''); } .chart-wrapper.chart-wrapper--daily { - /* Light surface: cyan / amber / pink series */ - --chart-daily-primary: #0891b2; - --chart-daily-area-top: #22d3ee; - --chart-daily-area-bottom: #cffafe; + /* Page views: accent; visits: green; unique visitors: dark red */ + --chart-daily-primary: var(--accent); + --chart-daily-area-top: var(--accent); + --chart-daily-area-bottom: #d1d5db; --chart-daily-mean-line: rgba(71, 85, 105, 0.55); --chart-daily-now-line: rgba(217, 119, 6, 0.75); - --chart-daily-visits-line: #d97706; - --chart-daily-unique-line: #db2777; + --chart-daily-visits-line: #16a34a; + --chart-daily-unique-line: #991b1b; --chart-daily-grid: rgba(100, 116, 139, 0.18); --chart-daily-label: #64748b; --chart-daily-axis-line: rgba(100, 116, 139, 0.35); @@ -416,14 +418,14 @@ const docsHome = pathWithBase(''); } html[data-theme='dark'] .chart-wrapper.chart-wrapper--daily { - /* Hue-separated series on near-black panel */ - --chart-daily-primary: #22d3ee; - --chart-daily-area-top: #22d3ee; - --chart-daily-area-bottom: #155e75; + /* Page views: accent blue on dark chart panel */ + --chart-daily-primary: var(--accent); + --chart-daily-area-top: var(--accent); + --chart-daily-area-bottom: #1e3a8a; --chart-daily-mean-line: rgba(226, 232, 240, 0.75); --chart-daily-now-line: rgba(251, 191, 36, 0.55); - --chart-daily-visits-line: #fbbf24; - --chart-daily-unique-line: #f472b6; + --chart-daily-visits-line: #4ade80; + --chart-daily-unique-line: #dc2626; --chart-daily-grid: rgba(148, 163, 184, 0.12); --chart-daily-label: #94a3b8; --chart-daily-axis-line: rgba(148, 163, 184, 0.25); @@ -462,7 +464,7 @@ const docsHome = pathWithBase(''); } .data-table th { - background: var(--bg); + background: var(--table-header-bg); color: var(--text); font-weight: 600; font-size: 0.75rem; @@ -508,6 +510,14 @@ const docsHome = pathWithBase(''); text-align: center; } + /* External links + Events: left-aligned headers and cells */ + .data-table--align-left th, + .data-table--align-left th.num-col, + .data-table--align-left td, + .data-table--align-left td.num-col { + text-align: left; + } + /* Single-row metric tables (Summary, engagement row): centered values */ .data-table--metrics-row td { text-align: center; @@ -655,10 +665,10 @@ const docsHome = pathWithBase(''); font-size: 1.375rem; } - .data-table:not(.data-table--metrics-row) th:nth-child(3), - .data-table:not(.data-table--metrics-row) td:nth-child(3), - .data-table:not(.data-table--metrics-row) th:nth-child(4), - .data-table:not(.data-table--metrics-row) td:nth-child(4) { + .data-table:not(.data-table--metrics-row):not(.data-table--align-left) th:nth-child(3), + .data-table:not(.data-table--metrics-row):not(.data-table--align-left) td:nth-child(3), + .data-table:not(.data-table--metrics-row):not(.data-table--align-left) th:nth-child(4), + .data-table:not(.data-table--metrics-row):not(.data-table--align-left) td:nth-child(4) { display: none; } }
Type