From 5a2ca88b9a9a7a047ad146a0edad1655f7e2a973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=A8=D1=82=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C?= Date: Tue, 18 Aug 2026 15:25:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D1=83=20=D0=B2=D0=BE=D0=BA=D1=80=D1=83=D0=B3=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 8 + .../plans/2026-08-18-analytics-ui-overhaul.md | 216 ++++++++++++++ .../specs/2026-08-18-analytics-ui-overhaul.md | 85 ++++++ e2e/analytics-ui.spec.ts | 281 ++++++++++++++++++ package.json | 1 + web/src/analysis/charts.tsx | 10 +- web/src/analysis/ranges.test.ts | 83 ++++++ web/src/analysis/ranges.ts | 154 ++++++++++ web/src/analysis/useAnalyticsRange.ts | 29 ++ .../components/AnalyticsDateRange.test.tsx | 70 +++++ web/src/components/AnalyticsDateRange.tsx | 146 +++++++++ web/src/components/ui.test.tsx | 29 +- web/src/components/ui.tsx | 67 ++++- web/src/components/ui/badge.tsx | 2 +- web/src/components/ui/button.tsx | 12 +- web/src/components/ui/card.tsx | 2 +- web/src/components/ui/input.tsx | 2 +- web/src/components/ui/select.tsx | 2 +- web/src/components/ui/table.tsx | 2 +- web/src/index.css | 25 +- web/src/lightVisualSystem.test.ts | 11 +- web/src/live-screen-ux.test.tsx | 6 +- web/src/owner-ui-feedback.test.tsx | 8 +- web/src/screens/Data.health.test.tsx | 20 ++ web/src/screens/Data.tsx | 23 +- web/src/screens/Experience.answer.test.tsx | 45 ++- web/src/screens/Experience.tsx | 68 +++-- web/src/screens/Measurement.tsx | 11 +- web/src/screens/Overview.test.tsx | 51 +++- web/src/screens/Overview.tsx | 215 ++++++++------ web/src/screens/Person.test.tsx | 67 +++++ web/src/screens/Person.tsx | 74 ++--- web/src/screens/ProductAnalytics.tsx | 108 +++---- web/src/screens/ProductAnalytics.ui.test.tsx | 39 ++- web/src/screens/SavedAnswers.test.tsx | 12 + web/src/screens/SavedAnswers.tsx | 52 ++-- web/src/screens/Users.test.tsx | 25 +- web/src/screens/Users.tsx | 124 +++----- web/src/screens/WebAnalytics.test.tsx | 94 ++++-- web/src/screens/WebAnalytics.tsx | 214 +++++++------ 40 files changed, 1923 insertions(+), 570 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-18-analytics-ui-overhaul.md create mode 100644 docs/superpowers/specs/2026-08-18-analytics-ui-overhaul.md create mode 100644 e2e/analytics-ui.spec.ts create mode 100644 web/src/analysis/ranges.test.ts create mode 100644 web/src/analysis/ranges.ts create mode 100644 web/src/analysis/useAnalyticsRange.ts create mode 100644 web/src/components/AnalyticsDateRange.test.tsx create mode 100644 web/src/components/AnalyticsDateRange.tsx create mode 100644 web/src/screens/Person.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf69a4c..117073c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,14 @@ jobs: pnpm --dir web test --run pnpm --dir web build + - name: Install analytics browser + run: pnpm exec playwright install --with-deps chromium + + - name: Test analytics workspace in a real browser + env: + POOLSTATIS_E2E_DISPOSABLE_DB: "true" + run: pnpm test:e2e:analytics + - name: Test and build SDK run: | pnpm --dir sdk test diff --git a/docs/superpowers/plans/2026-08-18-analytics-ui-overhaul.md b/docs/superpowers/plans/2026-08-18-analytics-ui-overhaul.md new file mode 100644 index 0000000..4d8b8f0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-analytics-ui-overhaul.md @@ -0,0 +1,216 @@ +# Analytics UI Overhaul Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the Poolstatis customer analytics UI around a visible shared period, automatic answers, dominant charts, compact tables, pill controls, large rounded surfaces, readable type, and concise secondary evidence. + +**Architecture:** Add one pure analytics-range module and one shared date-range control, then route every time-scoped screen through that contract. Apply visual rules through existing Tailwind v4 tokens and shadcn primitives, while simplifying screen composition without changing backend truth semantics. Protect the behavior with component tests first and a real-browser disposable-database E2E suite. + +**Tech Stack:** React 19, TypeScript, Tailwind CSS v4, shadcn/radix-ui, Recharts, Vitest/Testing Library, Playwright, Fastify, PostgreSQL. + +**Spec:** `docs/superpowers/specs/2026-08-18-analytics-ui-overhaul.md` + +**Execution status (2026-08-18):** Tasks 1–8 and Task 9 steps 1–3 are complete in the integration worktree. The remaining steps are fresh-origin integration, PR/merge, and the separate production release gate. + +## Global Constraints + +- Existing Geist/STIX Two Text/Geist Mono roles remain unchanged. +- Visible text is at least 15px; body and controls default to 16px. +- Buttons are full pills; controls are pills; containing cards use at least a 24px radius. +- `Today`, `Yesterday`, `7 days`, `30 days`, `90 days`, and `Custom` are available on every time-scoped analytics screen. +- Query windows are truthful half-open UTC intervals; no fabricated comparison delta is allowed. +- Safety, privacy, destructive-action, and server-owned evidence cannot be deleted; duplicated prose moves to one concise disclosure. +- `/i/v1/*`, SDK, MCP, database, auth, and hosted policy contracts remain compatible. +- No production deployment claim without immutable SHA, backup/rollback evidence, repeated live probes, and read-back. + +--- + +### Task 1: Shared range contract and control + +**Files:** +- Create: `web/src/analysis/ranges.ts` +- Create: `web/src/analysis/ranges.test.ts` +- Create: `web/src/analysis/useAnalyticsRange.ts` +- Create: `web/src/components/AnalyticsDateRange.tsx` +- Create: `web/src/components/AnalyticsDateRange.test.tsx` + +**Interfaces:** +- Produces: `AnalyticsRangePreset`, `AnalyticsRangeSelection`, `ResolvedAnalyticsRange`, `resolveAnalyticsRange(selection, now, timeZone)`, `previousAnalyticsRange(range)`, `rangeSearchParams(selection)`, `rangeFromSearchParams(search)`. +- Produces: `` with accessible presets and custom date inputs. +- Consumers: Home, Web, Product, Funnels, People, Person, and browser E2E. + +- [ ] **Step 1: Write failing pure range tests** + +```ts +expect(resolveAnalyticsRange({ kind: 'preset', preset: 'today' }, new Date('2026-08-18T11:00:00Z'), 'UTC')) + .toMatchObject({ from: '2026-08-18T00:00:00.000Z', to: '2026-08-18T11:00:00.000Z' }); +expect(resolveAnalyticsRange({ kind: 'custom', from: '2026-08-01', to: '2026-08-03' }, now, 'UTC')) + .toMatchObject({ from: '2026-08-01T00:00:00.000Z', to: '2026-08-04T00:00:00.000Z' }); +``` + +- [ ] **Step 2: Run `pnpm --dir web test --run src/analysis/ranges.test.ts` and confirm missing-module failure** +- [ ] **Step 3: Implement the pure date helpers with invalid/reversed custom-range rejection** +- [ ] **Step 4: Run the range test and confirm it passes** +- [ ] **Step 5: Write a failing component test for Today, Custom, Apply, Cancel, and accessible labels** +- [ ] **Step 6: Run `pnpm --dir web test --run src/components/AnalyticsDateRange.test.tsx` and confirm the control is missing** +- [ ] **Step 7: Implement the shared segmented preset rail and popover/dialog custom calendar form using existing Button/Input primitives** +- [ ] **Step 8: Run both new test files and commit `Добавить единый период аналитики`** + +### Task 2: Visual foundation + +**Files:** +- Modify: `web/src/index.css` +- Modify: `web/src/components/ui/button.tsx` +- Modify: `web/src/components/ui/input.tsx` +- Modify: `web/src/components/ui/select.tsx` +- Modify: `web/src/components/ui/badge.tsx` +- Modify: `web/src/components/ui/card.tsx` +- Modify: `web/src/components/ui.tsx` +- Modify: `web/src/lightVisualSystem.test.ts` +- Modify: `web/src/components/ui.test.tsx` + +**Interfaces:** +- Produces: pill controls, `--radius-control: 9999px`, `--radius-panel: 1.5rem`, `--text-xs: 0.9375rem`, `--text-sm: 1rem`, compact `KpiRail`, `DataDetails`, and a larger `PageHeading` action row. +- Consumers: every route. + +- [ ] **Step 1: Change visual-system tests to require 15px minimum text, 16px controls, pill buttons, and 24px panels** +- [ ] **Step 2: Run `pnpm --dir web test --run src/lightVisualSystem.test.ts src/components/ui.test.tsx` and confirm current tokens/components fail** +- [ ] **Step 3: Update Tailwind tokens and shadcn primitives; retain 44px coarse-pointer targets and visible focus rings** +- [ ] **Step 4: Replace repeated Stat cards with one divided KPI rail and add one compact DataDetails disclosure primitive** +- [ ] **Step 5: Simplify sidebar footer/status copy and make navigation rows pill-shaped without changing route availability** +- [ ] **Step 6: Run visual-system/component/accessibility tests and commit `Обновить визуальную систему аналитики`** + +### Task 3: Home answer-first dashboard + +**Files:** +- Modify: `web/src/screens/Overview.tsx` +- Modify: `web/src/screens/Overview.test.tsx` + +**Interfaces:** +- Consumes: `AnalyticsRangeSelection`, `AnalyticsDateRange`, `KpiRail`, current control-tower and query APIs. +- Produces: period-aware Home queries, one primary answer surface, compact attention rows, and URL-preserved range. + +- [ ] **Step 1: Add failing tests that select Today/custom and assert Home query payloads use the resolved exact interval** +- [ ] **Step 2: Add a failing layout test that KPI/chart content precedes attention copy in document order** +- [ ] **Step 3: Run `pnpm --dir web test --run src/screens/Overview.test.tsx` and confirm both behaviors fail** +- [ ] **Step 4: Move the date control into PageHeading, replace hard-coded `-30d`, render KPI rail/chart first, and turn attention cards into rows** +- [ ] **Step 5: Remove duplicated observed/trust prose from the first viewport and keep it once in DataDetails** +- [ ] **Step 6: Run Overview and analytics component tests and commit `Сделать Home ответом, а не сводкой`** + +### Task 4: Product and Funnels autorun + +**Files:** +- Modify: `web/src/screens/ProductAnalytics.tsx` +- Modify: `web/src/screens/ProductAnalytics.ui.test.tsx` +- Modify: `web/src/screens/ProductAnalytics.test.ts` + +**Interfaces:** +- Consumes: range contract and current template/query clients. +- Produces: automatic execution keyed by project/env/template/resource/range/breakdown, compact template segments, stable loading canvas, chart-first answer. + +- [ ] **Step 1: Add a failing test proving a ready Product answer executes on mount and reruns after period/template changes without `Run answer`** +- [ ] **Step 2: Add a failing Funnel test proving the selected funnel loads automatically and the conversion graphic is above technical details** +- [ ] **Step 3: Run targeted Product tests and confirm manual-run expectations fail** +- [ ] **Step 4: Replace manual execute state with scope-keyed automatic effects that ignore stale responses** +- [ ] **Step 5: Convert template cards to a compact horizontal segmented control and keep disabled capabilities out of the primary rail** +- [ ] **Step 6: Put result/KPI/chart before save and evidence actions; keep manual typed query in advanced details only** +- [ ] **Step 7: Run Product tests and commit `Автоматизировать Product и Funnels`** + +### Task 5: Web chart-first overview + +**Files:** +- Modify: `web/src/screens/WebAnalytics.tsx` +- Modify: `web/src/screens/WebAnalytics.test.tsx` +- Modify: `web/src/live-screen-ux.test.tsx` + +**Interfaces:** +- Consumes: range contract and current operational/trend APIs. +- Produces: automatic overview and breakdown, period-aware comparison, compact tabbed breakdown under a dominant chart. + +- [ ] **Step 1: Add failing tests for Today/custom exact queries and automatic first breakdown loading** +- [ ] **Step 2: Add a failing test that no `Load traffic breakdown` action is required for the default tab** +- [ ] **Step 3: Run targeted Web tests and confirm failures** +- [ ] **Step 4: Resolve all overview/session/trend queries from the shared range and load the selected breakdown automatically** +- [ ] **Step 5: Collapse accounting/privacy/methodology into DataDetails and keep unavailable dimensions as concise tab empty states** +- [ ] **Step 6: Run Web/live-screen tests and commit `Упростить Web analytics`** + +### Task 6: People and person workspace + +**Files:** +- Modify: `web/src/screens/Users.tsx` +- Modify: `web/src/screens/Users.test.tsx` +- Modify: `web/src/screens/Person.tsx` +- Create: `web/src/screens/Person.test.tsx` + +**Interfaces:** +- Consumes: range contract and actors/person APIs. +- Produces: compact toolbar, exact ranges, table without repeated order-evidence column, concise global ordering context, one DataDetails disclosure. + +- [ ] **Step 1: Add failing tests for custom actor `from`/`to`, removal of the Order evidence header, and a single ordering context** +- [ ] **Step 2: Run Users tests and confirm the old table fails the new contract** +- [ ] **Step 3: Replace the five-column filter card with a wrapping toolbar and shared date control** +- [ ] **Step 4: Remove repeated evidence window/badge from each row; preserve activation metric evidence only when selected** +- [ ] **Step 5: Replace the large Data limits cards and long resolution footnote with one concise DataDetails disclosure** +- [ ] **Step 6: Apply the same date/readability rules to Person and run tests** +- [ ] **Step 7: Commit `Собрать People вокруг таблицы`** + +### Task 7: System-wide secondary surfaces + +**Files:** +- Modify: `web/src/screens/SavedAnswers.tsx` +- Modify: `web/src/screens/Measurement.tsx` +- Modify: `web/src/screens/Data.tsx` +- Modify: `web/src/screens/Experience.tsx` +- Modify: related existing test files + +**Interfaces:** +- Consumes: global primitives and DataDetails. +- Produces: consistent pill actions, large rounded surfaces, readable labels, fewer repeated descriptions, table/list-first layouts. + +- [ ] **Step 1: Add failing assertions in existing screen tests for concise primary copy and collapsed secondary evidence** +- [ ] **Step 2: Run affected tests and confirm failures are caused by current repeated/expanded copy** +- [ ] **Step 3: Replace one-off small labels and tight radii with shared primitives; remove repeated sentences already represented by headings/status** +- [ ] **Step 4: Keep privacy/destructive/server-truth messages next to their actions and move technical provenance into DataDetails** +- [ ] **Step 5: Run all Web tests and commit `Привести админку к единой системе`** + +### Task 8: Real browser E2E and responsive QA + +**Files:** +- Create: `e2e/analytics-ui.spec.ts` +- Modify: `playwright.config.ts` +- Modify: `package.json` +- Modify: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: `test/globalSetup.ts`, `test/helpers.ts`, real Fastify/Vite, disposable PostgreSQL environment variables. +- Produces: `pnpm test:e2e:analytics` and CI browser coverage for desktop/mobile analytics flows. + +- [ ] **Step 1: Write the E2E suite before final UI fixes; seed a project, registry metrics, funnel, events, and a saved answer; exercise truthful empty/live contracts on Ship, Usage, and Setup** +- [ ] **Step 2: Run with disposable DB variables and confirm it catches at least one current visual/behavioral contract violation** +- [ ] **Step 3: Cover Home, Web, Product, Funnels, People, Saved, Ship, Usage, Setup at 1440x900 and 390x844** +- [ ] **Step 4: Assert visible Today/Custom, no Run answer, computed pill/24px radii, computed font size >=15px, no overflow, and no console/page errors** +- [ ] **Step 5: Fix every failing screen without weakening assertions** +- [ ] **Step 6: Run the E2E suite twice and commit `Добавить E2E редизайна аналитики`** + +### Task 9: Full verification, integration, and deployment + +**Files:** +- Modify only if verification exposes a real defect. + +**Interfaces:** +- Produces: reviewed integration candidate based on freshly fetched `origin/main`, pushed PR, merged SHA, deployed immutable artifact, and live read-back. + +- [ ] **Step 1: Start disposable PostgreSQL and run `pnpm typecheck`, `pnpm test`, `pnpm --dir web test --run`, `pnpm --dir web build`, `pnpm --dir sdk test`, SDK/MCP build/pack checks, analytics E2E, and self-host Compose config/build** +- [ ] **Step 2: Run desktop/mobile browser review for overflow, typography, interactions, charts, loading, empty/error states, and console errors** +- [ ] **Step 3: Review the full diff against this spec; verify every requirement with file/test/browser evidence** +- [ ] **Step 4: Fetch `origin`, rebase/merge semantically onto the exact current `origin/main`, run `git merge-tree`, and rerun full gates** +- [ ] **Step 5: Commit only relevant files with a short Russian commit message, push the named branch, and open a PR with the repository template** +- [ ] **Step 6: After required checks and review pass, merge through the PR and read back that `origin/main` contains the integrated SHA** +- [ ] **Step 7: Before deployment, verify production lineage, create and restore-test the required backup, compare protected counts, retain rollback, deploy atomically, and probe `/health`, authenticated app routes, desktop/mobile analytics, and ingest warnings repeatedly** + +## Self-review + +- Spec coverage: all explicit requirements map to Tasks 1–9. +- Placeholder scan: no implementation step delegates unspecified error handling or testing. +- Type consistency: every time-scoped consumer uses `AnalyticsRangeSelection` and `ResolvedAnalyticsRange`; the shared control owns custom calendar inputs but not query execution. +- Verification state: Docker-backed disposable-database gates, Web/SDK/MCP tests and builds, analytics E2E, self-host builds, and desktop/mobile browser review are green. Production release remains gated on the repository's deployment, backup, rollback, and live read-back procedure. diff --git a/docs/superpowers/specs/2026-08-18-analytics-ui-overhaul.md b/docs/superpowers/specs/2026-08-18-analytics-ui-overhaul.md new file mode 100644 index 0000000..9a99dd3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-analytics-ui-overhaul.md @@ -0,0 +1,85 @@ +# Analytics UI overhaul specification + +## Outcome + +Poolstatis customer analytics must read like a calm answer, not a configuration surface: + +1. The selected period is always visible at the top of an analytics screen. +2. `Today`, `Yesterday`, `7 days`, `30 days`, `90 days`, and an exact custom range are available wherever a time window changes the answer. +3. Valid screens load automatically. A user never has to press `Run answer` to reveal an already-configured result. +4. The first viewport prioritizes result, KPI rail, and chart. Trust, provenance, unsupported capabilities, and methodology are compact secondary details. +5. Buttons and button-like navigation use a full pill radius. Cards use a materially softer large radius. Inputs and selects use a consistent pill treatment. +6. Visible interface copy never relies on tiny type. The minimum visible text size is 15px; body and controls are 16px by default. +7. Repeated footnotes and repeated explanations are removed. Data truth is retained once in a concise `Data details` disclosure when it affects interpretation. +8. Tables remain the primary object on People, Saved, Usage, Definitions, and Events; unavailable capabilities must not take more space than the useful data. +9. Desktop and mobile preserve hierarchy, keyboard access, touch targets, and horizontal containment. + +## Visual direction + +- Use the existing Geist/STIX/Geist Mono typography roles and lime brand accent. +- Use the supplied Seline reference for a compact period-first overview and the supplied Mixpanel reference for a single compact control rail above a dominant chart. +- Do not clone proprietary branding. Reproduce the information hierarchy, density, period placement, and interaction model inside the existing Poolstatis design system. +- Prefer one containing surface with internal dividers over many equal cards. +- Use pills for actions and controls, not decorative status clutter. + +## Shared date semantics + +`AnalyticsRangeSelection` is the single UI state for analytics windows: + +```ts +type AnalyticsRangePreset = 'today' | 'yesterday' | '7d' | '30d' | '90d'; +type AnalyticsRangeSelection = + | { kind: 'preset'; preset: AnalyticsRangePreset } + | { kind: 'custom'; from: string; to: string }; +``` + +- Query intervals are half-open UTC intervals `[from, to)`. +- `Today` begins at 00:00 UTC and ends at the current instant; the UI labels this UTC contract explicitly. +- `Yesterday` is the complete previous calendar day. +- Rolling 7/30/90-day presets include the current partial day. +- Custom inputs are inclusive calendar dates in the control, converted to a half-open query ending at the next midnight. +- Comparison is previous equivalent period when an exact non-empty period exists; otherwise the UI says comparison is unavailable and does not invent a delta. +- The selected value is stored in URL search parameters so refresh and sharing preserve the answer. + +## Screen requirements + +### Home + +- Header: title, global period control, optional compare toggle. +- First surface: compact KPI rail, one dominant trend, compact breakdown. +- Open attention items appear below the answer as concise rows, not large narrative cards. +- Setup blockers use one inline action row. + +### Web + +- Load the overview immediately for the selected period. +- Keep KPI rail, trend, and one breakdown visible without a secondary `Load` action. +- Tabs change the breakdown below the chart; unavailable tabs render one concise empty state. + +### Product and Funnels + +- Template choice is a compact segmented/tab control. +- The chosen answer runs automatically when the template, metric, funnel, period, or breakdown changes. +- Loading preserves the chart canvas with a skeleton. +- Saving and official status actions are secondary to the chart. + +### People and Person + +- One compact toolbar contains search, period, queue, order, and activity. +- The table removes the dedicated repeated `Order evidence` column. Ordering context appears once above the table; row-level activation evidence appears only for an activation queue. +- Data limitations become one concise `Data details` disclosure below the table. +- IDs and event names may use mono, but never smaller than 15px. + +### Saved, Ship, Usage, Setup, Definitions, Events, Experience, Keys, Profile + +- Inherit the same type, radius, control, surface, spacing, and disclosure rules. +- Remove repeated explanatory copy where the heading/action already communicates the same thing. +- Keep safety-critical, privacy, destructive-action, and server-truth copy; make it concise and place it next to the affected action. + +## Verification contract + +- Unit/integration tests prove date conversion, URL persistence, autorun, and screen query payloads. +- Browser E2E uses the disposable PostgreSQL contract, a real Fastify instance, and a real Vite build/dev server. +- E2E covers Home, Web, Product, Funnels, People, Saved, Ship, Usage, and Setup at 1440x900 and 390x844. +- E2E asserts no horizontal overflow, no `Run answer`, visible `Today` and `Custom`, pill actions, large card radii, readable computed font sizes, and zero console/page errors. +- Full Core/Web/SDK/MCP/self-host gates run before integration. Production deployment remains separate from merge and requires backup, rollback, immutable SHA, and live read-back. diff --git a/e2e/analytics-ui.spec.ts b/e2e/analytics-ui.spec.ts new file mode 100644 index 0000000..2df8c55 --- /dev/null +++ b/e2e/analytics-ui.spec.ts @@ -0,0 +1,281 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { resolve } from 'node:path'; +import { test, expect, chromium, type Browser, type Page } from '@playwright/test'; +import setupDatabase from '../test/globalSetup.js'; +import { analysisViewInput } from '../test/analysis-view-fixtures.js'; +import { activeMetric, api, createTestEnv, hoursAgo, type TestEnv } from '../test/helpers.js'; + +let env: TestEnv; +let vite: ChildProcess; +let browser: Browser; +let webUrl: string; +let releaseDatabase: void | (() => Promise); + +test.beforeAll(async () => { + if (process.env.POOLSTATIS_E2E_DISPOSABLE_DB !== 'true' + || !process.env.TEST_ADMIN_DATABASE_URL + || !process.env.TEST_DATABASE_URL) { + throw new Error('analytics UI E2E requires explicitly marked disposable PostgreSQL URLs'); + } + + releaseDatabase = await setupDatabase(); + env = await createTestEnv({ ingestBuffer: false, queryCache: false }); + await seedAnalyticsWorkspace(env); + await env.app.listen({ host: '127.0.0.1', port: 0 }); + const apiAddress = env.app.server.address(); + if (!apiAddress || typeof apiAddress === 'string') throw new Error('API server did not bind'); + + const webPort = await freePort(); + webUrl = `http://127.0.0.1:${webPort}`; + vite = spawn('pnpm', ['--dir', 'web', 'dev', '--host', '127.0.0.1', '--port', String(webPort), '--strictPort'], { + cwd: resolve(import.meta.dirname, '..'), + env: { ...process.env, POOLSTATIS_URL: `http://127.0.0.1:${apiAddress.port}` }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await waitForHttp(webUrl, vite); + browser = await chromium.launch(); +}); + +test.afterAll(async () => { + await browser?.close(); + if (vite && !vite.killed) { + vite.kill('SIGTERM'); + await new Promise((resolveExit) => vite.once('exit', () => resolveExit())); + } + await env?.close(); + if (releaseDatabase) await releaseDatabase(); +}); + +const routes = [ + { path: '/', heading: 'Home', period: true }, + { path: '/analyze/web', heading: 'Web', period: true }, + { path: '/analyze/product', heading: 'Product', period: true }, + { path: '/analyze/funnels', heading: 'Funnels', period: true }, + { path: '/analyze/users', heading: 'People', period: true }, + { path: '/analyze/saved', heading: 'Saved answers', period: false }, + { path: '/changes', heading: 'Ship', period: false }, + { path: '/usage', heading: 'Usage', period: false }, + { path: '/setup', heading: 'Setup', period: false }, +] as const; + +for (const device of [ + { name: 'desktop', viewport: { width: 1440, height: 900 } }, + { name: 'mobile', viewport: { width: 390, height: 844 } }, +] as const) { + test(`keeps the complete analytics workspace clear and usable on ${device.name}`, async () => { + const context = await browser.newContext({ viewport: device.viewport }); + await context.addInitScript(({ adminToken, projectSlug }) => { + localStorage.setItem('poolstatis.conn', JSON.stringify({ baseUrl: '', token: adminToken })); + localStorage.setItem('poolstatis.project', projectSlug); + localStorage.setItem(`poolstatis.env.${projectSlug}`, 'prod'); + }, { adminToken: env.personalToken, projectSlug: env.projectSlug }); + const page = await context.newPage(); + const browserErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error' && !message.location().url.includes('/@vite/client')) { + browserErrors.push(message.text()); + } + }); + page.on('pageerror', (error) => browserErrors.push(error.message)); + + for (const route of routes) { + await page.goto(`${webUrl}${route.path}`); + await expect(page.getByRole('heading', { level: 1, name: route.heading, exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Run answer', exact: true })).toHaveCount(0); + if (route.period) { + const period = page.getByRole('group', { name: 'Analytics period' }); + await expect(period).toBeVisible(); + await expect(period.getByRole('button', { name: 'Today', exact: true })).toBeVisible(); + await expect(period.getByRole('button', { name: 'Custom', exact: true })).toBeVisible(); + } + if (route.path === '/analyze/product') { + await page.getByRole('button', { name: 'Custom', exact: true }).click(); + const dateInputs = page.locator('input[type="date"]'); + await dateInputs.nth(0).fill('2026-08-01'); + await dateInputs.nth(1).fill('2026-08-14'); + await page.getByRole('button', { name: 'Apply period', exact: true }).click(); + await expect(page).toHaveURL(/range=custom&from=2026-08-01&to=2026-08-14/); + await expect(page.getByText(/Aug 1.*14, 2026 · UTC/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Custom', exact: true })).toHaveAttribute('aria-pressed', 'true'); + } + await assertNoPageOverflow(page, route.path); + await assertReadableType(page, route.path); + await assertPillButtons(page, route.path); + await assertRoundedCards(page, route.path); + } + + expect(browserErrors).toEqual([]); + await context.close(); + }); +} + +async function seedAnalyticsWorkspace(target: TestEnv): Promise { + const project = `/api/v1/projects/${target.projectSlug}`; + const browserSetup = await api(target, target.secretToken, 'POST', `${project}/properties/browser-analytics`, { + route_keys: ['home', 'pricing', 'signup'], + }); + if (browserSetup.status !== 200) throw new Error(`browser setup failed: ${JSON.stringify(browserSetup.body)}`); + for (const metric of browserSetup.body.metrics as Array<{ key: string }>) { + const activated = await api(target, target.secretToken, 'PATCH', `${project}/metrics/${metric.key}`, { status: 'active' }); + if (activated.status !== 200) throw new Error(`browser metric activation failed: ${JSON.stringify(activated.body)}`); + } + const routeTrust = await api(target, target.secretToken, 'PATCH', `${project}/properties/event/%24route_key`, { status: 'trusted' }); + if (routeTrust.status !== 200) throw new Error(`route property trust failed: ${JSON.stringify(routeTrust.body)}`); + + await activeMetric(target, { + key: 'activation_started', + type: 'unique_actors', + source: { event: 'activation.started' }, + purpose: 'Counts people who begin the primary product activation journey.', + }); + await activeMetric(target, { + key: 'activation_completed', + type: 'unique_actors', + source: { event: 'activation.completed' }, + purpose: 'Counts people who reach the first meaningful product outcome.', + }); + const funnel = await api(target, target.secretToken, 'POST', `${project}/funnels`, { + key: 'activation_funnel', + name: 'Activation funnel', + goal: 'Measure how many people reach the first meaningful product outcome.', + steps: [ + { metric_key: 'activation_started', label: 'Started activation' }, + { metric_key: 'activation_completed', label: 'Completed activation' }, + ], + window_seconds: 86_400, + }); + if (funnel.status !== 201) throw new Error(`funnel setup failed: ${JSON.stringify(funnel.body)}`); + + const events: Array> = []; + for (let index = 0; index < 12; index += 1) { + const actor = `visitor-${index + 1}`; + const session = `session-${index + 1}`; + const timestamp = hoursAgo(index + 1); + events.push({ + event: 'page.viewed', + distinct_id: actor, + session_id: session, + timestamp, + properties: { + $browser_context: '1', + $route_key: index % 3 === 0 ? 'pricing' : 'home', + $page_view_id: `page-${index + 1}`, + $device_class: index % 2 === 0 ? 'desktop' : 'mobile', + $browser_family: 'chrome', + $os_family: index % 2 === 0 ? 'macos' : 'ios', + $language: 'en', + $viewport_bucket: index % 2 === 0 ? 'xl' : 'sm', + }, + }); + events.push({ event: 'activation.started', distinct_id: actor, session_id: session, timestamp }); + if (index < 8) { + events.push({ + event: 'activation.completed', + distinct_id: actor, + session_id: session, + timestamp: new Date(Date.parse(timestamp) + 60_000).toISOString(), + }); + } + } + const ingested = await api(target, target.ingestToken, 'POST', '/i/v1/events', { + batch_id: `analytics-ui-${Date.now()}`, + events, + }); + if (ingested.status !== 200 || ingested.body.accepted !== events.length) { + throw new Error(`analytics seed failed: ${JSON.stringify(ingested.body)}`); + } + + const saved = await api( + target, + target.secretToken, + 'POST', + `${project}/analysis-views`, + analysisViewInput(target.projectSlug), + ); + if (saved.status !== 201) throw new Error(`saved answer setup failed: ${JSON.stringify(saved.body)}`); +} + +async function assertNoPageOverflow(page: Page, route: string): Promise { + const dimensions = await page.evaluate(() => ({ + viewport: window.innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + })); + expect(dimensions, `${route} must not overflow the viewport`).toEqual({ + viewport: dimensions.viewport, + document: dimensions.viewport, + body: dimensions.viewport, + }); +} + +async function assertReadableType(page: Page, route: string): Promise { + const offenders = await page.locator('main').evaluate((root) => ( + Array.from(root.querySelectorAll('*')).flatMap((element) => { + const directText = Array.from(element.childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent?.trim() ?? '') + .join(' ') + .trim(); + if (!directText) return []; + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 + || rect.width <= 2 || rect.height <= 2) return []; + const fontSize = Number.parseFloat(style.fontSize); + return fontSize < 15 ? [{ text: directText.slice(0, 80), fontSize, tag: element.tagName }] : []; + }) + )); + expect(offenders, `${route} contains text smaller than 15px`).toEqual([]); +} + +async function assertPillButtons(page: Page, route: string): Promise { + const offenders = await page.locator('button:visible').evaluateAll((buttons) => ( + buttons.flatMap((button) => { + const rect = button.getBoundingClientRect(); + if (rect.width <= 2 || rect.height <= 2) return []; + const radius = Number.parseFloat(getComputedStyle(button).borderTopLeftRadius); + const required = Math.min(rect.width, rect.height) / 2 - 1; + return radius < required + ? [{ label: button.getAttribute('aria-label') ?? button.textContent?.trim().slice(0, 80), radius, required }] + : []; + }) + )); + expect(offenders, `${route} contains a non-pill button`).toEqual([]); +} + +async function assertRoundedCards(page: Page, route: string): Promise { + const offenders = await page.locator('[data-slot="card"]:visible').evaluateAll((cards) => ( + cards.flatMap((card) => { + const radius = Number.parseFloat(getComputedStyle(card).borderTopLeftRadius); + return radius < 24 ? [{ text: card.textContent?.trim().slice(0, 80), radius }] : []; + }) + )); + expect(offenders, `${route} contains a card with radius below 24px`).toEqual([]); +} + +async function freePort(): Promise { + return new Promise((resolvePort, reject) => { + const server = createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') return reject(new Error('free port unavailable')); + server.close((error) => error ? reject(error) : resolvePort(address.port)); + }); + }); +} + +async function waitForHttp(url: string, process: ChildProcess): Promise { + const errors: Buffer[] = []; + process.stderr?.on('data', (chunk) => errors.push(Buffer.from(chunk))); + for (let attempt = 0; attempt < 100; attempt += 1) { + if (process.exitCode !== null) throw new Error(`Vite exited early: ${Buffer.concat(errors).toString('utf8')}`); + try { + const response = await fetch(url); + if (response.ok) return; + } catch {} + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(`Vite did not become ready: ${Buffer.concat(errors).toString('utf8')}`); +} diff --git a/package.json b/package.json index 4d257d8..43f40f9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "mcp": "tsx src/cli/mcp.ts", "mcp:smoke": "tsx src/cli/mcpSmoke.ts", "mcp:package:test": "vitest run test/mcp-package.test.ts test/mcp-runner-preset.test.ts", + "test:e2e:analytics": "playwright test e2e/analytics-ui.spec.ts", "load:smoke": "tsx src/cli/loadSmoke.ts", "retention:run": "tsx src/cli/retention.ts", "preflight:migration-023": "node dist/cli/preflightMigration023.js", diff --git a/web/src/analysis/charts.tsx b/web/src/analysis/charts.tsx index 15573b0..797e45f 100644 --- a/web/src/analysis/charts.tsx +++ b/web/src/analysis/charts.tsx @@ -43,9 +43,11 @@ const CHART_COLORS = [ const AXIS = { tickLine: false, axisLine: false, - tick: { fill: 'var(--muted-foreground)', fontSize: 12 }, + tick: { fill: 'var(--muted-foreground)', fontSize: 'var(--text-xs)' }, } as const; +const LEGEND_STYLE = { fontSize: 'var(--text-xs)' } as const; + function ChartFrame({ children, label }: { children: ReactNode; label: string }) { return (
@@ -65,7 +67,7 @@ function ChartTooltip() { borderRadius: 'var(--radius-panel)', background: 'var(--popover)', color: 'var(--popover-foreground)', - fontSize: 12, + fontSize: 'var(--text-xs)', }} labelStyle={{ color: 'var(--foreground)', fontWeight: 600 }} /> @@ -120,7 +122,7 @@ export function BreakdownBars({ result }: { result: TrendQueryResult }) { - + {seriesKeys.map((key, index) => ( - + diff --git a/web/src/analysis/ranges.test.ts b/web/src/analysis/ranges.test.ts new file mode 100644 index 0000000..fdc5c67 --- /dev/null +++ b/web/src/analysis/ranges.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { + previousAnalyticsRange, + rangeFromSearchParams, + rangeSearchParams, + resolveAnalyticsRange, +} from './ranges'; + +const now = new Date('2026-08-18T11:49:00.000Z'); + +describe('analytics ranges', () => { + it('resolves today as the current partial UTC calendar day', () => { + expect(resolveAnalyticsRange({ kind: 'preset', preset: 'today' }, now, 'UTC')).toEqual({ + from: '2026-08-18T00:00:00.000Z', + to: '2026-08-18T11:49:00.000Z', + label: 'Today', + days: 1, + complete: false, + }); + }); + + it('resolves yesterday as one complete UTC calendar day', () => { + expect(resolveAnalyticsRange({ kind: 'preset', preset: 'yesterday' }, now, 'UTC')).toEqual({ + from: '2026-08-17T00:00:00.000Z', + to: '2026-08-18T00:00:00.000Z', + label: 'Yesterday', + days: 1, + complete: true, + }); + }); + + it('keeps rolling presets anchored to the current instant', () => { + expect(resolveAnalyticsRange({ kind: 'preset', preset: '7d' }, now, 'UTC')).toMatchObject({ + from: '2026-08-11T11:49:00.000Z', + to: '2026-08-18T11:49:00.000Z', + label: 'Last 7 days', + days: 7, + complete: false, + }); + }); + + it('converts inclusive custom calendar dates to a half-open interval', () => { + expect(resolveAnalyticsRange({ kind: 'custom', from: '2026-08-01', to: '2026-08-03' }, now, 'UTC')).toEqual({ + from: '2026-08-01T00:00:00.000Z', + to: '2026-08-04T00:00:00.000Z', + label: 'Aug 1–3, 2026', + days: 3, + complete: true, + }); + }); + + it('rejects reversed or impossible custom dates', () => { + expect(() => resolveAnalyticsRange({ kind: 'custom', from: '2026-08-04', to: '2026-08-03' }, now, 'UTC')) + .toThrow('Custom range start must not be after its end'); + expect(() => resolveAnalyticsRange({ kind: 'custom', from: '2026-02-30', to: '2026-03-03' }, now, 'UTC')) + .toThrow('Custom range dates must be valid calendar dates'); + }); + + it('builds the previous equivalent half-open interval', () => { + expect(previousAnalyticsRange({ + from: '2026-08-01T00:00:00.000Z', + to: '2026-08-04T00:00:00.000Z', + label: 'Aug 1–3, 2026', + days: 3, + complete: true, + })).toEqual({ + from: '2026-07-29T00:00:00.000Z', + to: '2026-08-01T00:00:00.000Z', + label: 'Previous period', + days: 3, + complete: true, + }); + }); + + it('round-trips presets and custom ranges through URL search params', () => { + const preset = { kind: 'preset', preset: 'today' } as const; + expect(rangeFromSearchParams(rangeSearchParams(preset))).toEqual(preset); + + const custom = { kind: 'custom', from: '2026-08-01', to: '2026-08-03' } as const; + expect(rangeFromSearchParams(rangeSearchParams(custom))).toEqual(custom); + expect(rangeFromSearchParams(new URLSearchParams('range=unknown'))).toEqual({ kind: 'preset', preset: '30d' }); + }); +}); diff --git a/web/src/analysis/ranges.ts b/web/src/analysis/ranges.ts new file mode 100644 index 0000000..8b3f9fb --- /dev/null +++ b/web/src/analysis/ranges.ts @@ -0,0 +1,154 @@ +export type AnalyticsRangePreset = 'today' | 'yesterday' | '7d' | '30d' | '90d'; + +export type AnalyticsRangeSelection = + | { kind: 'preset'; preset: AnalyticsRangePreset } + | { kind: 'custom'; from: string; to: string }; + +export interface ResolvedAnalyticsRange { + from: string; + to: string; + label: string; + days: number; + complete: boolean; +} + +const DAY_MS = 86_400_000; +const PRESET_DAYS: Record, number> = { + '7d': 7, + '30d': 30, + '90d': 90, +}; +const PRESETS = new Set(['today', 'yesterday', '7d', '30d', '90d']); +export const DEFAULT_ANALYTICS_RANGE: AnalyticsRangeSelection = { kind: 'preset', preset: '30d' }; + +export function resolveAnalyticsRange( + selection: AnalyticsRangeSelection, + now = new Date(), + timeZone = 'UTC', +): ResolvedAnalyticsRange { + if (!Number.isFinite(now.getTime())) throw new Error('Analytics range anchor must be a valid date'); + if (timeZone !== 'UTC') throw new Error('Poolstatis analytics currently uses UTC calendar boundaries'); + + if (selection.kind === 'custom') { + const from = parseCalendarDate(selection.from); + const through = parseCalendarDate(selection.to); + if (!from || !through) throw new Error('Custom range dates must be valid calendar dates'); + if (from.getTime() > through.getTime()) throw new Error('Custom range start must not be after its end'); + const to = new Date(through.getTime() + DAY_MS); + const days = Math.round((to.getTime() - from.getTime()) / DAY_MS); + return { + from: from.toISOString(), + to: to.toISOString(), + label: formatCustomRange(from, through), + days, + complete: to.getTime() <= now.getTime(), + }; + } + + if (selection.preset === 'today') { + const from = startOfUtcDay(now); + return { + from: from.toISOString(), + to: now.toISOString(), + label: 'Today', + days: 1, + complete: false, + }; + } + + if (selection.preset === 'yesterday') { + const to = startOfUtcDay(now); + const from = new Date(to.getTime() - DAY_MS); + return { + from: from.toISOString(), + to: to.toISOString(), + label: 'Yesterday', + days: 1, + complete: true, + }; + } + + const days = PRESET_DAYS[selection.preset]; + return { + from: new Date(now.getTime() - days * DAY_MS).toISOString(), + to: now.toISOString(), + label: `Last ${days} days`, + days, + complete: false, + }; +} + +export function previousAnalyticsRange(range: ResolvedAnalyticsRange): ResolvedAnalyticsRange { + const from = Date.parse(range.from); + const to = Date.parse(range.to); + if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) { + throw new Error('Analytics range must be a valid non-empty interval'); + } + const duration = to - from; + return { + from: new Date(from - duration).toISOString(), + to: new Date(from).toISOString(), + label: 'Previous period', + days: Math.max(1, Math.ceil(duration / DAY_MS)), + complete: true, + }; +} + +export function rangeSearchParams(selection: AnalyticsRangeSelection): URLSearchParams { + const search = new URLSearchParams(); + if (selection.kind === 'preset') { + search.set('range', selection.preset); + } else { + search.set('range', 'custom'); + search.set('from', selection.from); + search.set('to', selection.to); + } + return search; +} + +export function rangeFromSearchParams(search: URLSearchParams): AnalyticsRangeSelection { + const range = search.get('range'); + if (range === 'custom') { + const from = search.get('from') ?? ''; + const to = search.get('to') ?? ''; + const parsedFrom = parseCalendarDate(from); + const parsedTo = parseCalendarDate(to); + if (parsedFrom && parsedTo && parsedFrom.getTime() <= parsedTo.getTime()) { + return { kind: 'custom', from, to }; + } + return DEFAULT_ANALYTICS_RANGE; + } + return range && PRESETS.has(range as AnalyticsRangePreset) + ? { kind: 'preset', preset: range as AnalyticsRangePreset } + : DEFAULT_ANALYTICS_RANGE; +} + +export function selectionFromLegacyRange(range: '7d' | '30d' | '90d'): AnalyticsRangeSelection { + return { kind: 'preset', preset: range }; +} + +function startOfUtcDay(value: Date): Date { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); +} + +function parseCalendarDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const result = new Date(Date.UTC(year, month - 1, day)); + if (result.getUTCFullYear() !== year || result.getUTCMonth() !== month - 1 || result.getUTCDate() !== day) return null; + return result; +} + +function formatCustomRange(from: Date, through: Date): string { + const sameYear = from.getUTCFullYear() === through.getUTCFullYear(); + const sameMonth = sameYear && from.getUTCMonth() === through.getUTCMonth(); + const monthDay = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); + const full = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); + if (from.getTime() === through.getTime()) return full.format(from); + if (sameMonth) return `${monthDay.format(from).replace(/,$/, '')}–${through.getUTCDate()}, ${through.getUTCFullYear()}`; + if (sameYear) return `${monthDay.format(from).replace(/,$/, '')}–${full.format(through)}`; + return `${full.format(from)}–${full.format(through)}`; +} diff --git a/web/src/analysis/useAnalyticsRange.ts b/web/src/analysis/useAnalyticsRange.ts new file mode 100644 index 0000000..62ba4c3 --- /dev/null +++ b/web/src/analysis/useAnalyticsRange.ts @@ -0,0 +1,29 @@ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + DEFAULT_ANALYTICS_RANGE, + rangeFromSearchParams, + rangeSearchParams, + resolveAnalyticsRange, + type AnalyticsRangeSelection, +} from './ranges'; + +const RANGE_KEYS = ['range', 'from', 'to'] as const; + +export function useAnalyticsRange(defaultSelection: AnalyticsRangeSelection = DEFAULT_ANALYTICS_RANGE) { + const [search, setSearch] = useSearchParams(); + const hasRange = search.has('range'); + const selection = useMemo( + () => hasRange ? rangeFromSearchParams(search) : defaultSelection, + [defaultSelection, hasRange, search], + ); + const resolved = useMemo(() => resolveAnalyticsRange(selection), [selection]); + const setSelection = useCallback((next: AnalyticsRangeSelection) => { + const updated = new URLSearchParams(search); + for (const key of RANGE_KEYS) updated.delete(key); + rangeSearchParams(next).forEach((value, key) => updated.set(key, value)); + setSearch(updated, { replace: true }); + }, [search, setSearch]); + + return { selection, resolved, setSelection }; +} diff --git a/web/src/components/AnalyticsDateRange.test.tsx b/web/src/components/AnalyticsDateRange.test.tsx new file mode 100644 index 0000000..5fe709c --- /dev/null +++ b/web/src/components/AnalyticsDateRange.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AnalyticsDateRange } from './AnalyticsDateRange'; + +describe('AnalyticsDateRange', () => { + it('keeps quick periods visible and reports preset changes immediately', () => { + const onChange = vi.fn(); + render( + , + ); + + expect(screen.getByRole('group', { name: 'Analytics period' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Today' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Yesterday' })).toBeVisible(); + expect(screen.getByRole('button', { name: '7 days' })).toBeVisible(); + expect(screen.getByRole('button', { name: '30 days' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: '90 days' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Custom' })).toBeVisible(); + + fireEvent.click(screen.getByRole('button', { name: 'Today' })); + expect(onChange).toHaveBeenCalledWith({ kind: 'preset', preset: 'today' }); + }); + + it('applies an inclusive custom calendar range and can be cancelled', () => { + const onChange = vi.fn(); + const view = render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + expect(screen.getByRole('dialog', { name: 'Custom period' })).toBeVisible(); + fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2026-08-01' } }); + fireEvent.change(screen.getByLabelText('End date'), { target: { value: '2026-08-03' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply period' })); + expect(onChange).toHaveBeenCalledWith({ kind: 'custom', from: '2026-08-01', to: '2026-08-03' }); + + view.rerender( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('dialog', { name: 'Custom period' })).not.toBeInTheDocument(); + }); + + it('exposes comparison as an explicit pressed state', () => { + const onCompareChange = vi.fn(); + render( + , + ); + + const compare = screen.getByRole('button', { name: 'Compare to previous period' }); + expect(compare).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(compare); + expect(onCompareChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/web/src/components/AnalyticsDateRange.tsx b/web/src/components/AnalyticsDateRange.tsx new file mode 100644 index 0000000..47dd1e2 --- /dev/null +++ b/web/src/components/AnalyticsDateRange.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react'; +import { Check } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import { resolveAnalyticsRange, type AnalyticsRangePreset, type AnalyticsRangeSelection } from '../analysis/ranges'; + +const QUICK_RANGES: Array<{ preset: AnalyticsRangePreset; label: string }> = [ + { preset: 'today', label: 'Today' }, + { preset: 'yesterday', label: 'Yesterday' }, + { preset: '7d', label: '7 days' }, + { preset: '30d', label: '30 days' }, + { preset: '90d', label: '90 days' }, +]; + +export function AnalyticsDateRange({ + value, + onChange, + compare, + onCompareChange, + className, +}: { + value: AnalyticsRangeSelection; + onChange: (value: AnalyticsRangeSelection) => void; + compare?: boolean; + onCompareChange?: (value: boolean) => void; + className?: string; +}) { + const [customOpen, setCustomOpen] = useState(false); + const [from, setFrom] = useState(value.kind === 'custom' ? value.from : ''); + const [to, setTo] = useState(value.kind === 'custom' ? value.to : ''); + const [error, setError] = useState(null); + + useEffect(() => { + if (value.kind !== 'custom') return; + setFrom(value.from); + setTo(value.to); + }, [value]); + + const openCustom = () => { + if (value.kind === 'custom') { + setFrom(value.from); + setTo(value.to); + } + setError(null); + setCustomOpen(true); + }; + + const applyCustom = () => { + const next = { kind: 'custom', from, to } as const; + try { + resolveAnalyticsRange(next); + onChange(next); + setCustomOpen(false); + setError(null); + } catch (caught) { + setError((caught as Error).message); + } + }; + + return ( + <> +
+
+ {QUICK_RANGES.map((item) => { + const selected = value.kind === 'preset' && value.preset === item.preset; + return ( + + ); + })} + +
+ {onCompareChange && ( + + )} +
+ + + + + Custom period + Choose inclusive calendar dates. Analytics uses UTC boundaries. + +
+ + +
+ {error &&

{error}

} + + + + +
+
+ + ); +} diff --git a/web/src/components/ui.test.tsx b/web/src/components/ui.test.tsx index e32afc0..c31722f 100644 --- a/web/src/components/ui.test.tsx +++ b/web/src/components/ui.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { ErrorNote, Meter, PageHeading, WarningNote } from './ui'; +import { DataDetails, ErrorNote, KpiRail, Meter, PageHeading, WarningNote } from './ui'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; import { Table, TableBody, TableRow } from './ui/table'; @@ -40,6 +40,11 @@ describe('page headings', () => { }); describe('button contrast', () => { + it('uses a full pill shape for every button variant', () => { + render(); + expect(screen.getByRole('button', { name: 'Open answer' })).toHaveClass('rounded-full'); + }); + it('keeps outline hover neutral instead of applying the brand accent', () => { render(); const button = screen.getByRole('button', { name: 'Open definition' }); @@ -63,6 +68,28 @@ describe('button contrast', () => { }); }); +describe('analytics composition', () => { + it('groups headline metrics into one divided rail instead of separate cards', () => { + render(); + + const rail = screen.getByRole('list', { name: 'Key metrics' }); + expect(rail).toHaveClass('rounded-panel'); + expect(screen.getAllByRole('listitem')).toHaveLength(2); + expect(screen.getByText('612')).toHaveClass('tabular-nums'); + }); + + it('keeps secondary evidence collapsed in one concise disclosure', () => { + render(Exact UTC window and privacy limits.); + const details = screen.getByText('Data details').closest('details'); + expect(details).not.toHaveAttribute('open'); + fireEvent.click(screen.getByText('Data details')); + expect(screen.getByText('Exact UTC window and privacy limits.')).toBeVisible(); + }); +}); + describe('brand state colors', () => { it('uses the primary token for progress fills', () => { const { container } = render(); diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx index 905025f..a58f58f 100644 --- a/web/src/components/ui.tsx +++ b/web/src/components/ui.tsx @@ -35,7 +35,7 @@ export function HelpHint({ label, ariaLabel }: { label: ReactNode; ariaLabel: st @@ -50,11 +50,11 @@ export function HelpDisclosure({ label, ariaLabel }: { label: ReactNode; ariaLab ? -
+
{label}
@@ -73,14 +73,14 @@ export function PageHeading({ title, lead, help, meta, actions, className }: { return (
- {meta &&
{meta}
} + {meta &&
{meta}
}
-

{title}

+

{title}

{help && }
- {lead &&

{lead}

} + {lead &&

{lead}

}
- {actions &&
{actions}
} + {actions &&
{actions}
}
); } @@ -119,7 +119,7 @@ export function Panel({ title, right, children }: { title?: ReactNode; right?: R return ( {(title || right) && ( - + {/* Heading is serif; any inline subtitle inside should pass font-sans. */} {title ? {title} : } {right} @@ -132,21 +132,66 @@ export function Panel({ title, right, children }: { title?: ReactNode; right?: R /** Small muted label (replaces the old all-caps eyebrow). */ export function FieldLabel({ children, className }: { children: ReactNode; className?: string }) { - return {children}; + return {children}; } export function Stat({ label, value, sub }: { label: string; value: ReactNode; sub?: ReactNode }) { return ( -
{label}
+
{label}
{value}
- {sub &&
{sub}
} + {sub &&
{sub}
}
); } +export interface KpiRailItem { + label: ReactNode; + value: ReactNode; + detail?: ReactNode; + delta?: ReactNode; +} + +export function KpiRail({ items, className }: { items: KpiRailItem[]; className?: string }) { + return ( + +
    + {items.map((item, index) => ( +
  • +
    + {item.label} + {item.delta && {item.delta}} +
    +
    {item.value}
    + {item.detail &&
    {item.detail}
    } +
  • + ))} +
+
+ ); +} + +export function DataDetails({ summary = 'Data details', children, className }: { + summary?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+ + {summary} + +
{children}
+
+ ); +} + export function EmptyState({ headline, lead, action }: { headline: string; lead?: string; action?: ReactNode }) { return (
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index c3bed3e..b36bf1c 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui" import { cn } from "@/lib/utils" const badgeVariants = cva( - "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3", + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2.5 py-1 text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3.5", { variants: { variant: { diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index ee1605d..a39bcc9 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex shrink-0 items-center justify-center gap-2 rounded-field text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,transform] outline-none active:translate-y-px focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring disabled:pointer-events-none disabled:translate-y-0 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "inline-flex shrink-0 items-center justify-center gap-2 rounded-full text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,transform] outline-none active:translate-y-px focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring disabled:pointer-events-none disabled:translate-y-0 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { @@ -22,13 +22,13 @@ const buttonVariants = cva( }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", - xs: "h-6 gap-1 rounded-control px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + xs: "h-7 gap-1 rounded-full px-2.5 text-xs has-[>svg]:px-2 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 px-6 has-[>svg]:px-4", - icon: "size-9 rounded-control", - "icon-xs": "size-6 rounded-control [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8 rounded-control", - "icon-lg": "size-10 rounded-control", + icon: "size-9 rounded-full", + "icon-xs": "size-7 rounded-full [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8 rounded-full", + "icon-lg": "size-10 rounded-full", }, }, defaultVariants: { diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx index 0b23ef8..5087c97 100644 --- a/web/src/components/ui/card.tsx +++ b/web/src/components/ui/card.tsx @@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
) { type={type} data-slot="input" className={cn( - "h-9 w-full min-w-0 rounded-field border border-input bg-card px-3 py-1 text-base shadow-xs transition-[color,background-color,border-color,box-shadow] outline-none selection:bg-muted selection:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted/60 disabled:opacity-60 md:text-sm", + "h-10 w-full min-w-0 rounded-full border border-input bg-card px-4 py-2 text-base shadow-xs transition-[color,background-color,border-color,box-shadow] outline-none selection:bg-muted selection:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted/60 disabled:opacity-60", "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring", "aria-invalid:border-destructive aria-invalid:ring-destructive/20", className diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx index c76e61e..f492552 100644 --- a/web/src/components/ui/select.tsx +++ b/web/src/components/ui/select.tsx @@ -37,7 +37,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-fit items-center justify-between gap-2 rounded-field border border-input bg-card px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,background-color,border-color,box-shadow] outline-none hover:border-ring/45 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring disabled:cursor-not-allowed disabled:bg-muted/60 disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", + "flex w-fit items-center justify-between gap-2 rounded-full border border-input bg-card px-4 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,background-color,border-color,box-shadow] outline-none hover:border-ring/45 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring disabled:cursor-not-allowed disabled:bg-muted/60 disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-10 data-[size=sm]:h-9 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className )} {...props} diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx index 052792c..2d0b423 100644 --- a/web/src/components/ui/table.tsx +++ b/web/src/components/ui/table.tsx @@ -71,7 +71,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) { [role=checkbox]]:translate-y-0.5", + "h-10 px-2 text-left align-middle text-sm font-semibold whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5", className )} {...props} diff --git a/web/src/index.css b/web/src/index.css index 07b133a..9a195b3 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -13,14 +13,14 @@ @theme inline { --radius-xs: 0.25rem; - --radius-sm: 0.5rem; - --radius-md: 0.625rem; - --radius-lg: 0.75rem; - --radius-xl: 1rem; - --radius-control: 0.5rem; - --radius-field: 0.625rem; - --radius-panel: 0.75rem; - --radius-dialog: 1rem; + --radius-sm: 0.75rem; + --radius-md: 1rem; + --radius-lg: 1.25rem; + --radius-xl: 1.5rem; + --radius-control: 9999px; + --radius-field: 9999px; + --radius-panel: 1.5rem; + --radius-dialog: 1.75rem; --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -65,8 +65,10 @@ --font-sans: "Geist", system-ui, sans-serif; --font-mono: "Geist Mono", ui-monospace, monospace; --font-serif: "STIX Two Text", Georgia, serif; - --text-xs: 0.875rem; - --text-xs--line-height: 1.25rem; + --text-xs: 0.9375rem; + --text-xs--line-height: 1.375rem; + --text-sm: 1rem; + --text-sm--line-height: 1.5rem; } :root { @@ -179,6 +181,9 @@ text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; } + button { + border-radius: var(--radius-control); + } /* STIX Two Text is for headings only; data/code uses Geist Mono. */ .brand-wordmark { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; diff --git a/web/src/lightVisualSystem.test.ts b/web/src/lightVisualSystem.test.ts index 9a783ee..e6ce4be 100644 --- a/web/src/lightVisualSystem.test.ts +++ b/web/src/lightVisualSystem.test.ts @@ -8,6 +8,8 @@ describe('Core light-first visual system', () => { const card = readFileSync(resolve(process.cwd(), 'src/components/ui/card.tsx'), 'utf8'); const dropdown = readFileSync(resolve(process.cwd(), 'src/components/ui/dropdown-menu.tsx'), 'utf8'); const select = readFileSync(resolve(process.cwd(), 'src/components/ui/select.tsx'), 'utf8'); + const button = readFileSync(resolve(process.cwd(), 'src/components/ui/button.tsx'), 'utf8'); + const input = readFileSync(resolve(process.cwd(), 'src/components/ui/input.tsx'), 'utf8'); expect(css).toContain('color-scheme: light'); expect(css).toContain('--background: #f6f8f5'); @@ -28,8 +30,13 @@ describe('Core light-first visual system', () => { expect(css).toContain('letter-spacing: var(--auth-display-tracking)'); expect(css).toContain('line-height: var(--auth-display-leading)'); expect(existsSync(resolve(process.cwd(), 'public/fonts/google-sans-flex-latin.woff2'))).toBe(true); - expect(css).toContain('--text-xs: 0.875rem'); - expect(css).toContain('--text-xs--line-height: 1.25rem'); + expect(css).toContain('--text-xs: 0.9375rem'); + expect(css).toContain('--text-sm: 1rem'); + expect(css).toContain('--radius-control: 9999px'); + expect(css).toContain('--radius-panel: 1.5rem'); + expect(button).toContain('rounded-full'); + expect(input).toContain('rounded-full'); + expect(select).toContain('rounded-full'); expect(card).toContain('bg-card'); expect(dropdown).toContain('bg-popover'); expect(select).toContain('bg-popover'); diff --git a/web/src/live-screen-ux.test.tsx b/web/src/live-screen-ux.test.tsx index 9d08b4b..523a4a9 100644 --- a/web/src/live-screen-ux.test.tsx +++ b/web/src/live-screen-ux.test.tsx @@ -917,7 +917,7 @@ describe('live customer screen UX', () => { labels: [{ label: 'checkout.submit', count: 4, actors: 3 }], }), })); - render(); + render(); expect(await screen.findByText(/Last accepted capture/)).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'View click details' })).toHaveAttribute('href', '#experience-evidence'); expect(screen.getByText(/aggregate maps · separate from DOM replay/)).toBeInTheDocument(); @@ -956,7 +956,7 @@ describe('live customer screen UX', () => { visualExperience: vi.fn().mockRejectedValue(new Error('not needed for readiness test')), })); - render(); + render(); expect(await screen.findByText('Complete aggregate friction readiness')).toBeInTheDocument(); expect(screen.getByText('3/4 ready')).toBeInTheDocument(); @@ -972,7 +972,7 @@ describe('live customer screen UX', () => { experienceSnapshots: vi.fn().mockResolvedValue([]), })); - render(); + render(); expect(await screen.findByText('Set up Browser Experience')).toBeInTheDocument(); expect(screen.queryByText('No active surface')).not.toBeInTheDocument(); diff --git a/web/src/owner-ui-feedback.test.tsx b/web/src/owner-ui-feedback.test.tsx index b8f2db4..c0f5389 100644 --- a/web/src/owner-ui-feedback.test.tsx +++ b/web/src/owner-ui-feedback.test.tsx @@ -178,7 +178,7 @@ describe('owner UI feedback regressions', () => { it('shows a mobile capture at its viewport width inside a scrollable evidence frame', async () => { mockedStore.mockReturnValue(experienceStore() as never); - render(); + render(); await screen.findByRole('img', { name: /mobile/ }); const viewport = screen.getByTestId('visual-snapshot-viewport'); @@ -217,7 +217,7 @@ describe('owner UI feedback regressions', () => { .mockImplementationOnce(() => mobileRequest.promise) .mockImplementationOnce(() => desktopRequest.promise); mockedStore.mockReturnValue(experienceStore({ visualExperience }) as never); - render(); + render(); await waitFor(() => expect(visualExperience).toHaveBeenCalledTimes(1)); const deviceSelect = screen.getAllByRole('combobox')[4]!; @@ -271,7 +271,7 @@ describe('owner UI feedback regressions', () => { .mockResolvedValueOnce(desktopResult); const compareVisualExperience = vi.fn(() => comparisonRequest.promise); mockedStore.mockReturnValue(experienceStore({ visualExperience, compareVisualExperience }) as never); - render(); + render(); await screen.findByText('Mobile viewport · 390 × 844'); fireEvent.click(screen.getByRole('button', { name: 'Compare with desktop' })); @@ -306,7 +306,7 @@ describe('owner UI feedback regressions', () => { it('keeps evidence notes below the screenshot and names the exact comparison target', async () => { mockedStore.mockReturnValue(experienceStore() as never); - render(); + render(); const viewport = await screen.findByTestId('visual-snapshot-viewport'); const notes = screen.getByTestId('visual-evidence-notes'); diff --git a/web/src/screens/Data.health.test.tsx b/web/src/screens/Data.health.test.tsx index 90684b1..af440ec 100644 --- a/web/src/screens/Data.health.test.tsx +++ b/web/src/screens/Data.health.test.tsx @@ -12,6 +12,7 @@ vi.mock('../store', async (importOriginal) => ({ const mockedStore = vi.mocked(useStore); const verify = vi.fn(); +const sample = vi.fn(); const health = { schema_version: 1 as const, @@ -94,6 +95,7 @@ const health = { describe('Events data-health control', () => { beforeEach(() => { + sample.mockReset().mockResolvedValue([]); const watermark = health.issue_signatures[0]!.watermark; verify.mockReset().mockResolvedValue({ schema_version: 1, @@ -116,10 +118,28 @@ describe('Events data-health control', () => { verifyDataHealthFix: verify, dataQuality: vi.fn().mockResolvedValue({ issues: [], checked: { terminal_event_specs: 0, evidence_rows: 0 } }), ingestWarnings: vi.fn().mockResolvedValue([]), + sample, }, } as never); }); + it('uses Today and custom exact periods for the event stream', async () => { + render(); + await screen.findAllByText('Event stream'); + + const period = screen.getByRole('group', { name: 'Analytics period' }); + fireEvent.click(within(period).getByRole('button', { name: 'Custom' })); + fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2026-08-01' } }); + fireEvent.change(screen.getByLabelText('End date'), { target: { value: '2026-08-02' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply period' })); + + await waitFor(() => expect(sample).toHaveBeenLastCalledWith('alpha', expect.objectContaining({ + from: '2026-08-01T00:00:00.000Z', + to: '2026-08-03T00:00:00.000Z', + }))); + expect(screen.queryByText('All time')).not.toBeInTheDocument(); + }); + it('shows server-owned trend totals before details and separates improvements from proven health', async () => { render(); diff --git a/web/src/screens/Data.tsx b/web/src/screens/Data.tsx index ac0d960..61ef9c1 100644 --- a/web/src/screens/Data.tsx +++ b/web/src/screens/Data.tsx @@ -16,6 +16,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { DataHealthControl } from '../components/data-health-control'; +import { AnalyticsDateRange } from '../components/AnalyticsDateRange'; +import { useAnalyticsRange } from '../analysis/useAnalyticsRange'; import type { BackfillPreview, DataQualityIssue, EntityRow, EventRevisionPatch, EventRevisionPreview, FilterOp, ObservedEvent, SampleEvent, SampleFilter, @@ -171,18 +173,15 @@ function DataQualityPanel({ ); } -const DATE_PRESETS: Array<{ v: string; label: string }> = [ - { v: '', label: 'All time' }, { v: '-24h', label: 'Last 24h' }, { v: '-7d', label: 'Last 7d' }, { v: '-30d', label: 'Last 30d' }, -]; const OPS: FilterOp[] = ['eq', 'ne', 'contains', 'gt', 'gte', 'lt', 'lte', 'is_set', 'is_not_set']; const OP_LABEL: Record = { eq: 'is', ne: 'is not', contains: 'contains', gt: '>', gte: '≥', lt: '<', lte: '≤', in: 'is any of', is_set: 'is set', is_not_set: 'is not set' }; function EventStream({ initialEvent, initialActor, observed }: { initialEvent?: string; initialActor?: string; observed: ObservedEvent[] }) { const { client, project, env } = useStore(); + const { selection: rangeSelection, resolved: range, setSelection: setRangeSelection } = useAnalyticsRange(); const [eventFilter, setEventFilter] = useState(initialEvent ?? ''); const [actorFilter, setActorFilter] = useState(initialActor); const [props, setProps] = useState([]); - const [range, setRange] = useState(''); const [registered, setRegistered] = useState<'all' | 'reg' | 'wild'>('all'); const [search, setSearch] = useState(''); const [adding, setAdding] = useState(false); @@ -194,18 +193,19 @@ function EventStream({ initialEvent, initialActor, observed }: { initialEvent?: ...(eventFilter && { event: eventFilter }), ...(actorFilter && { distinct_id: actorFilter }), ...(registered !== 'all' && { registered: registered === 'reg' }), - ...(range && { from: range }), + from: range.from, + to: range.to, ...(props.length > 0 && { filters: props }), }), - [project, env, eventFilter, actorFilter, registered, range, JSON.stringify(props)], + [project, env, eventFilter, actorFilter, registered, range.from, range.to, JSON.stringify(props)], ); const q = search.trim().toLowerCase(); const rows = (data ?? []).filter((e) => !q || `${e.event} ${e.distinct_id} ${JSON.stringify(e.properties)}`.toLowerCase().includes(q)); - const hasFilters = Boolean(eventFilter || actorFilter || props.length || range); + const hasFilters = Boolean(eventFilter || actorFilter || props.length); return ( - + }> } center={ @@ -217,10 +217,6 @@ function EventStream({ initialEvent, initialActor, observed }: { initialEvent?: {observed.map((o) => {o.event})} - } @@ -242,9 +238,8 @@ function EventStream({ initialEvent, initialActor, observed }: { initialEvent?:
{eventFilter && setEventFilter('')} />} {actorFilter && setActorFilter(undefined)} />} - {range && d.v === range)?.label ?? range} onRemove={() => setRange('')} />} {props.map((p, i) => setProps((arr) => arr.filter((_, j) => j !== i))} />)} - +
)} diff --git a/web/src/screens/Experience.answer.test.tsx b/web/src/screens/Experience.answer.test.tsx index 4825c3e..c370c50 100644 --- a/web/src/screens/Experience.answer.test.tsx +++ b/web/src/screens/Experience.answer.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; +import { MemoryRouter } from 'react-router-dom'; import { useStore } from '../store'; import { Experience } from './Experience'; @@ -11,6 +12,10 @@ vi.mock('../store', async (importOriginal) => ({ const mockedStore = vi.mocked(useStore); +function renderExperience() { + return render(); +} + function store(client: Record) { return { client, @@ -161,7 +166,7 @@ describe('Experience answer-first control surface', () => { experienceSnapshots: vi.fn().mockResolvedValue([]), })); - render(); + renderExperience(); expect(await screen.findByText('Set up Browser Experience')).toBeInTheDocument(); expect(screen.getByText(/Illustrative/)).toBeInTheDocument(); @@ -193,7 +198,7 @@ describe('Experience answer-first control surface', () => { experienceSnapshots: vi.fn().mockResolvedValue([]), })); - render(); + renderExperience(); fireEvent.click(await screen.findByRole('button', { name: 'Set up manually' })); expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' }); @@ -211,7 +216,7 @@ describe('Experience answer-first control surface', () => { interactionMap: vi.fn(), })); - render(); + renderExperience(); const answer = await screen.findByRole('region', { name: 'Aggregate friction answer' }); expect(await within(answer).findByText(/^The largest observed adjacent reach decrease is pricing → payment/)).toHaveTextContent('21 fewer sessions (34 pp)'); @@ -225,4 +230,38 @@ describe('Experience answer-first control surface', () => { expect(answer.compareDocumentPosition(mapHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); await waitFor(() => expect(screen.getByRole('img', { name: /Checkout, payment, release-42, desktop/ })).toBeInTheDocument()); }); + + it('uses one custom period for the versioned and aggregate experience queries', async () => { + const visualQuery = vi.fn().mockResolvedValue(visualExperience); + const aggregateQuery = vi.fn().mockResolvedValue({ + kind: 'interaction_map', surface, grid: 16, cells: [], labels: [], + }); + mockedStore.mockReturnValue(store({ + experienceSurfaces: vi.fn().mockResolvedValue([surface]), + experienceRoutes: vi.fn().mockResolvedValue([route]), + experienceSnapshots: vi.fn().mockResolvedValue([snapshot]), + visualExperience: visualQuery, + experienceSnapshotImage: vi.fn().mockResolvedValue('blob:checkout-snapshot'), + experienceSession: vi.fn().mockResolvedValue({ events: [], summary: { events: 0, sessions: 0, actors: 0 } }), + compareVisualExperience: vi.fn(), + interactionMap: aggregateQuery, + })); + renderExperience(); + await screen.findByRole('region', { name: 'Aggregate friction answer' }); + + const period = screen.getByRole('group', { name: 'Analytics period' }); + fireEvent.click(period.querySelector('button:last-child')!); + fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2026-08-01' } }); + fireEvent.change(screen.getByLabelText('End date'), { target: { value: '2026-08-04' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply period' })); + + await waitFor(() => expect(visualQuery).toHaveBeenLastCalledWith('alpha', expect.objectContaining({ + date_from: '2026-08-01T00:00:00.000Z', date_to: '2026-08-05T00:00:00.000Z', + }))); + fireEvent.click(screen.getByRole('button', { name: 'Load aggregate clicks' })); + await waitFor(() => expect(aggregateQuery).toHaveBeenLastCalledWith('alpha', expect.objectContaining({ + date_from: '2026-08-01T00:00:00.000Z', date_to: '2026-08-05T00:00:00.000Z', + }))); + expect(screen.queryByRole('combobox', { name: 'Aggregate click period' })).not.toBeInTheDocument(); + }); }); diff --git a/web/src/screens/Experience.tsx b/web/src/screens/Experience.tsx index af37930..cb37c6f 100644 --- a/web/src/screens/Experience.tsx +++ b/web/src/screens/Experience.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Add, GridView, Loader2 } from '@/components/icons'; +import { AnalyticsDateRange } from '@/components/AnalyticsDateRange'; import { Badge } from '@/components/ui/badge'; import { useAsync, useStore } from '../store'; -import { EmptyState, ErrorNote, Loading, Panel, RecoverableError, Stat, fmtNum } from '../components/ui'; +import { EmptyState, ErrorNote, Loading, PageHeading, Panel, RecoverableError, Stat, fmtNum } from '../components/ui'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -22,9 +23,12 @@ import type { VisualExperienceResponse, } from '../api/types'; import { ReplayPanel } from './ReplayPanel'; +import type { ResolvedAnalyticsRange } from '../analysis/ranges'; +import { useAnalyticsRange } from '../analysis/useAnalyticsRange'; export function Experience() { const { client, project, env, availableEnvs, setEnv } = useStore(); + const { selection: rangeSelection, resolved: range, setSelection: setRangeSelection } = useAnalyticsRange(); const configurationRef = useRef(null); const snapshotRef = useRef(null); const { data, error, loading, reload } = useAsync( @@ -53,8 +57,15 @@ export function Experience() { return (
+ } + /> aggregate maps · separate from DOM replay} >
@@ -94,6 +105,7 @@ export function Experience() { routes={data.routes} snapshots={data.snapshots} env={env} + range={range} /> ) : null} @@ -108,7 +120,7 @@ export function Experience() {
)} - {setupReady && } + {setupReady && }
@@ -121,7 +133,7 @@ export function Experience() { {activeSurfaces.length > 0 && } {data.surfaces.length > 0 && } - {activeSurfaces.length > 0 && } + {activeSurfaces.length > 0 && }
@@ -252,11 +264,13 @@ function VisualExplorer({ routes, snapshots, env, + range, }: { surfaces: ExperienceSurface[]; routes: ExperienceRoute[]; snapshots: ExperienceSnapshot[]; env: string; + range: ResolvedAnalyticsRange; }) { const { client, project } = useStore(); const [surface, setSurface] = useState(snapshots[0]?.surface_key ?? ''); @@ -271,7 +285,6 @@ function VisualExplorer({ ); const [version, setVersion] = useState(routeSnapshots[0]?.version ?? ''); const [device, setDevice] = useState<'desktop' | 'mobile'>(routeSnapshots[0]?.device ?? 'desktop'); - const [period, setPeriod] = useState('-30d'); const [mode, setMode] = useState<'clicks' | 'scroll'>('clicks'); const [result, setResult] = useState(null); const [comparison, setComparison] = useState(null); @@ -286,7 +299,7 @@ function VisualExplorer({ const routeOptions = routes.filter((item) => item.surface_key === surface); const comparisonTarget = routeSnapshots.find((item) => item.version === version && item.device !== device) ?? routeSnapshots.find((item) => item.version !== version); - const evidenceIdentity = `${surface}\u0000${route}\u0000${version}\u0000${device}\u0000${period}\u0000${env}`; + const evidenceIdentity = `${surface}\u0000${route}\u0000${version}\u0000${device}\u0000${range.from}\u0000${range.to}\u0000${env}`; const evidenceIdentityRef = useRef(evidenceIdentity); evidenceIdentityRef.current = evidenceIdentity; @@ -327,7 +340,8 @@ function VisualExplorer({ route, version, device, - date_from: period, + date_from: range.from, + date_to: range.to, env, grid: 24, }); @@ -354,7 +368,7 @@ function VisualExplorer({ }; // The selected evidence tuple is the request identity. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [surface, route, version, device, period, env]); + }, [surface, route, version, device, range.from, range.to, env]); const compare = async () => { if (!comparisonTarget) return; @@ -368,11 +382,12 @@ function VisualExplorer({ route, env, grid: 24, - baseline: { version, device, date_from: period }, + baseline: { version, device, date_from: range.from, date_to: range.to }, comparison: { version: comparisonTarget.version, device: comparisonTarget.device, - date_from: period, + date_from: range.from, + date_to: range.to, }, }); if (compareRequestRef.current === requestId && evidenceIdentityRef.current === requestIdentity) { @@ -400,7 +415,7 @@ function VisualExplorer({ ? Snapshot may be stale : Exact version + layout} > -
+
- - -
@@ -1128,10 +1133,9 @@ function SurfacesTable({ ); } -function AggregateClickEvidence({ surfaces, env }: { surfaces: ExperienceSurface[]; env: string }) { +function AggregateClickEvidence({ surfaces, env, range }: { surfaces: ExperienceSurface[]; env: string; range: ResolvedAnalyticsRange }) { const { client, project } = useStore(); const [surface, setSurface] = useState(surfaces[0]?.key ?? ''); - const [period, setPeriod] = useState('7'); const [grid, setGrid] = useState('16'); const [result, setResult] = useState(null); const [busy, setBusy] = useState(false); @@ -1141,6 +1145,10 @@ function AggregateClickEvidence({ surfaces, env }: { surfaces: ExperienceSurface if (!surfaces.some((item) => item.key === surface)) setSurface(surfaces[0]?.key ?? ''); }, [surface, surfaces]); + useEffect(() => { + reset(); + }, [range.from, range.to]); + const reset = () => { setResult(null); setError(null); @@ -1152,7 +1160,8 @@ function AggregateClickEvidence({ surfaces, env }: { surfaces: ExperienceSurface try { setResult(await client!.interactionMap(project!, { surface, - date_from: `-${period}d`, + date_from: range.from, + date_to: range.to, env, grid: Number(grid), })); @@ -1181,10 +1190,6 @@ function AggregateClickEvidence({ surfaces, env }: { surfaces: ExperienceSurface {surfaces.map((item) => {item.name})} - { setMetricKey(value); setResult(null); }} disabled={eligible.length === 0 || busy}>{eligible.map((metric) => {metric.name} · {metric.key})}
-
+ {!range &&
}
diff --git a/web/src/screens/Overview.test.tsx b/web/src/screens/Overview.test.tsx index ec3f2cf..0a7bf18 100644 --- a/web/src/screens/Overview.test.tsx +++ b/web/src/screens/Overview.test.tsx @@ -17,7 +17,7 @@ vi.mock('../productTelemetry', async (importOriginal) => ({ })); vi.mock('../analysis/charts', () => ({ - TrendChart: () =>
Day 1: 4Day 2: 6
, + TrendChart: ({ label }: { label: string }) =>
Day 1: 4Day 2: 6
, })); const mockedStore = vi.mocked(useStore); @@ -133,6 +133,39 @@ describe('goal-aware Attention', () => { window.localStorage.clear(); }); + it('shows the answer before attention and applies a custom period to every Home answer query', async () => { + const client = websiteClient() as Record; + setStore(client); + const view = render(); + + expect(await screen.findByRole('heading', { name: 'Home' })).toBeInTheDocument(); + const period = screen.getByRole('group', { name: 'Analytics period' }); + expect(within(period).getByRole('button', { name: 'Today' })).toBeInTheDocument(); + expect(within(period).getByRole('button', { name: 'Custom' })).toBeInTheDocument(); + + const metrics = screen.getByRole('list', { name: 'Key metrics' }); + const attention = screen.getByRole('region', { name: 'Needs attention' }); + expect(metrics.compareDocumentPosition(attention) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByRole('img', { name: 'Website traffic trend' })).toBeInTheDocument(); + + fireEvent.click(within(period).getByRole('button', { name: 'Custom' })); + fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2026-08-01' } }); + fireEvent.change(screen.getByLabelText('End date'), { target: { value: '2026-08-03' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply period' })); + + await waitFor(() => expect(client.operationalQuery).toHaveBeenLastCalledWith('alpha', expect.objectContaining({ + date_from: '2026-08-01T00:00:00.000Z', + date_to: '2026-08-04T00:00:00.000Z', + }))); + expect(client.query).toHaveBeenLastCalledWith('alpha', expect.objectContaining({ + kind: 'trend', + date_from: '2026-08-01T00:00:00.000Z', + date_to: '2026-08-04T00:00:00.000Z', + })); + expect(screen.getByText('Aug 1–3, 2026')).toBeInTheDocument(); + expect(view.container.querySelector('.text-xs')).toBeNull(); + }); + it('renders the server-owned attention order instead of recomputing it in React', async () => { const client = websiteClient() as Record; const server = controlTowerResponse(); @@ -265,11 +298,11 @@ describe('goal-aware Attention', () => { expect(screen.queryByRole('button', { name: 'Customize dashboard' })).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Biggest loss: Visited → Started signup' })).toBeInTheDocument(); expect(screen.getByText('Overall funnel conversion:')).toBeInTheDocument(); - const outcomes = screen.getByRole('group', { name: 'Key outcomes' }); - expect(outcomes).toHaveClass('grid-cols-2', 'lg:grid-cols-4'); + const outcomes = screen.getByRole('list', { name: 'Key metrics' }); + expect(outcomes).toHaveClass('sm:grid-cols-2', 'xl:grid-cols-4'); expect(outcomes.children).toHaveLength(4); expect(within(outcomes).getByText('8')).toBeInTheDocument(); - expect(within(outcomes).getByText('Last event')).toBeInTheDocument(); + expect(within(outcomes).getByText('Last event · 30d')).toBeInTheDocument(); expect(within(outcomes).getByText('page.viewed')).toBeInTheDocument(); expect(screen.getByText(/Observed · Last 30 days · Trusted · 20 events ·/)).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Funnel snapshot' })).toBeInTheDocument(); @@ -279,11 +312,11 @@ describe('goal-aware Attention', () => { expect(screen.getByRole('heading', { name: 'Recent activity' })).toBeInTheDocument(); expect(screen.getByText('20 events')).toBeInTheDocument(); expect(screen.queryByText('Next action')).not.toBeInTheDocument(); - expect(screen.queryByRole('img', { name: 'Trend chart' })).not.toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Website traffic trend' })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Top sources' })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Top pages' })).not.toBeInTheDocument(); expect(view.container.querySelector('.text-xs')).toBeNull(); - expect(client.query).toHaveBeenCalledTimes(1); + expect(client.query).toHaveBeenCalledTimes(2); expect(client.query).toHaveBeenCalledWith('alpha', expect.objectContaining({ kind: 'funnel', funnel: 'website_signup', @@ -336,11 +369,11 @@ describe('goal-aware Attention', () => { render(); expect(await screen.findByRole('heading', { name: 'Home' })).toBeInTheDocument(); - const outcomes = screen.getByRole('group', { name: 'Key outcomes' }); + const outcomes = screen.getByRole('list', { name: 'Key metrics' }); expect(within(outcomes).getByText('Visitors')).toBeInTheDocument(); expect(within(outcomes).getByText('Sessions')).toBeInTheDocument(); expect(within(outcomes).getByText('Page views')).toBeInTheDocument(); - expect(within(outcomes).getByText('Last event')).toBeInTheDocument(); + expect(within(outcomes).getByText('Last event · 30d')).toBeInTheDocument(); expect(within(outcomes).getAllByText('Not configured')).toHaveLength(3); expect(within(outcomes).getByText('No events')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Customize dashboard' })).not.toBeInTheDocument(); @@ -356,7 +389,7 @@ describe('goal-aware Attention', () => { expect(await screen.findByText('Website answers are temporarily unavailable.')).toBeInTheDocument(); expect(screen.queryByRole('link', { name: /Set up Web/ })).not.toBeInTheDocument(); - const outcomes = screen.getByRole('group', { name: 'Key outcomes' }); + const outcomes = screen.getByRole('list', { name: 'Key metrics' }); expect(within(outcomes).getAllByText('Unavailable').length).toBeGreaterThan(0); expect(within(outcomes).queryByText('Not configured')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Retry website answers' })); diff --git a/web/src/screens/Overview.tsx b/web/src/screens/Overview.tsx index d29f672..a7702a9 100644 --- a/web/src/screens/Overview.tsx +++ b/web/src/screens/Overview.tsx @@ -2,10 +2,14 @@ import { useEffect } from 'react'; import { Link } from 'react-router-dom'; import { ArrowRight } from '@/components/icons'; import { Button } from '@/components/ui/button'; -import { ErrorNote, Loading, fmtNum, fmtRelative } from '@/components/ui'; +import { ErrorNote, KpiRail, Loading, PageHeading, fmtNum, fmtRelative } from '@/components/ui'; import { AnswerCanvas, type EvidenceTrust, type KpiItem } from '@/components/analytics'; +import { AnalyticsDateRange } from '@/components/AnalyticsDateRange'; import { DisclosureSummary } from '@/components/disclosure'; +import { TrendChart } from '../analysis/charts'; import { formatDurationMs, webPageMetric, type WebAnalyticsResult } from '../analysis/operations'; +import type { AnalyticsRangeSelection, ResolvedAnalyticsRange } from '../analysis/ranges'; +import { useAnalyticsRange } from '../analysis/useAnalyticsRange'; import type { AttentionItem, ControlTowerAction, ControlTowerResult, Funnel, MeasurementTrust, Metric, ObservedEvent, ProjectSchema } from '../api/types'; import type { FunnelQueryResult, TrendQueryResult } from '../analysis/visualization'; import type { ProjectMode } from '../analysis/navigation'; @@ -41,6 +45,7 @@ interface ProductAnswer { interface WebsiteAnswer { metric: Metric | null; overview: WebAnalyticsResult | null; + trend: TrendQueryResult | null; overviewUnavailable: boolean; trust: MeasurementTrust | null; trustUnavailable: boolean; @@ -48,7 +53,8 @@ interface WebsiteAnswer { export function Overview() { const { account, client, project, env } = useStore(); - const homeScope = `${project ?? ''}\u0000${env}`; + const { selection, resolved: range, setSelection } = useAnalyticsRange(); + const homeScope = `${project ?? ''}\u0000${env}\u0000${range.from}\u0000${range.to}`; const home = useAsync(async () => { try { const [controlTower, intent, metrics, funnels, schema] = await Promise.all([ @@ -74,8 +80,9 @@ export function Overview() { productAnswersEnabled ? primaryMetric : null, productAnswersEnabled ? revenueMetric : null, homeFunnel, + range, ), - readWebsiteAnswer(client!, project!, env, websiteAnswersEnabled ? pageMetric : null), + readWebsiteAnswer(client!, project!, env, websiteAnswersEnabled ? pageMetric : null, range), ]); return { scope: homeScope, @@ -91,7 +98,7 @@ export function Overview() { } catch (caught) { return { scope: homeScope, value: null, error: (caught as Error).message }; } - }, [project, env]); + }, [project, env, range.from, range.to]); const scopedHome = home.data?.scope === homeScope ? home.data : null; const homeData = scopedHome?.value ?? null; @@ -113,11 +120,11 @@ export function Overview() { const { intent, product, website, schema, controlTower } = homeData; const mode = intent?.project_mode ?? null; const attention = controlTower.attention; - if (mode === 'website') return ; - if (mode === 'product') return ; + if (mode === 'website') return ; + if (mode === 'product') return ; if (mode === 'both' && intent) { const websiteFirst = controlTower.home_answer_surface === 'website'; - return ; + return ; } // A missing intent row is legacy/unset. Keep the project useful and never @@ -126,13 +133,18 @@ export function Overview() {
- + {website.overview + ? + : }
Project settings @@ -141,9 +153,7 @@ export function Overview() { Project mode is not set. Choose Website, Product, or Both later in Setup. Nothing has been inferred from historical data.

- {website.overview - ? - : } +
); } @@ -157,26 +167,23 @@ function AttentionQueue({ result, items, telemetryUserId, onRetry }: { const visibleItems = items.length > 0 ? items.slice(0, 3) : [guardrailItem(result)]; const remainingItems = items.slice(3); return ( -
-
-
-

Needs attention

-

Highest-impact server-backed signal first.

-
- {items.length} +
+
+

Needs attention

+ {items.length}
-
+
{visibleItems.map((item, index) => ( ))}
{remainingItems.length > 0 && ( -
+
View all {items.length} signals +{remainingItems.length} -
+
{remainingItems.map((item) => ( ))} @@ -194,20 +201,22 @@ function AttentionCard({ item, primary, telemetryUserId, onRetry }: { onRetry: () => void; }) { return ( -
-
- {severityLabel(item.severity)} - {item.evidence.freshness === 'fresh' ? fmtRelative(item.evidence.as_of) : item.evidence.freshness} +
+
+
+ {severityLabel(item.severity)} + + {item.evidence.freshness === 'fresh' ? fmtRelative(item.evidence.as_of) : item.evidence.freshness} +
+

{item.title}

+

{item.reason}

+ {item.delta && ( +

+ {item.rule_id === 'funnel.biggest_loss' ? 'Overall funnel conversion:' : 'Change:'}{' '} + {attentionDeltaLabel(item.delta)} +

+ )}
-

{item.title}

-

{item.reason}

- {item.delta && ( -

- {item.rule_id === 'funnel.biggest_loss' ? 'Overall funnel conversion:' : 'Change:'}{' '} - {attentionDeltaLabel(item.delta)} -

- )} -

Impact: {item.impact}

); @@ -245,7 +254,7 @@ function AttentionAction({ action, primary, telemetryUserId, onRetry }: { }) { if (action.kind === 'navigate') { return ( - ; + return ; } - return ; + return ; } function severityLabel(severity: AttentionItem['severity']) { @@ -273,26 +282,26 @@ function actionTelemetry(href: string): TelemetryHomeAction { return 'open_current_answer'; } -function WebsiteHome({ answer, product, schema, env, controlTower, attention, telemetryUserId, onRetry }: { answer: WebsiteAnswer; product: ProductAnswer; schema: ProjectSchema | null; env: string; controlTower: ControlTowerResult; attention: AttentionItem[]; telemetryUserId?: string | null; onRetry: () => void }) { +function WebsiteHome({ answer, product, schema, env, range, selection, onSelectionChange, controlTower, attention, telemetryUserId, onRetry }: { answer: WebsiteAnswer; product: ProductAnswer; schema: ProjectSchema | null; env: string; range: ResolvedAnalyticsRange; selection: AnalyticsRangeSelection; onSelectionChange: (selection: AnalyticsRangeSelection) => void; controlTower: ControlTowerResult; attention: AttentionItem[]; telemetryUserId?: string | null; onRetry: () => void }) { const lead = websiteLead(answer); return (
- + + -
); } -function ProductHome({ answer, schema, env, controlTower, attention, telemetryUserId, onRetry }: { answer: ProductAnswer; schema: ProjectSchema | null; env: string; controlTower: ControlTowerResult; attention: AttentionItem[]; telemetryUserId?: string | null; onRetry: () => void }) { +function ProductHome({ answer, schema, env, range, selection, onSelectionChange, controlTower, attention, telemetryUserId, onRetry }: { answer: ProductAnswer; schema: ProjectSchema | null; env: string; range: ResolvedAnalyticsRange; selection: AnalyticsRangeSelection; onSelectionChange: (selection: AnalyticsRangeSelection) => void; controlTower: ControlTowerResult; attention: AttentionItem[]; telemetryUserId?: string | null; onRetry: () => void }) { const lead = answer.metric ? `${answer.metric.name} is the clearest active outcome available for this project.` : 'Events may be arriving, but no active outcome is defined yet.'; return (
- + + -
); } @@ -303,6 +312,9 @@ function BothHome({ websiteFirst, schema, env, + range, + selection, + onSelectionChange, controlTower, telemetryUserId, attention, @@ -313,6 +325,9 @@ function BothHome({ websiteFirst: boolean; schema: ProjectSchema | null; env: string; + range: ResolvedAnalyticsRange; + selection: AnalyticsRangeSelection; + onSelectionChange: (selection: AnalyticsRangeSelection) => void; controlTower: ControlTowerResult; telemetryUserId?: string | null; attention: AttentionItem[]; @@ -328,13 +343,15 @@ function BothHome({
-
All Website @@ -348,8 +365,9 @@ function BothHome({ : 'Add stable identity evidence before comparing acquisition with product outcomes.'}
{websiteFirst - ? - : } + ? + : } +
); } @@ -358,7 +376,7 @@ const WEBSITE_KPIS = [ { id: 'visitors', label: 'Visitors' }, { id: 'sessions', label: 'Sessions' }, { id: 'page_views', label: 'Page views' }, - { id: 'last_event', label: 'Last event' }, + { id: 'last_event', label: 'Last event · 30d' }, { id: 'average_duration', label: 'Average duration' }, { id: 'engaged_rate', label: 'Engagement rate' }, { id: 'bounce_rate', label: 'Bounce rate' }, @@ -368,7 +386,7 @@ const PRODUCT_KPIS = [ { id: 'outcome', label: 'Primary outcome' }, { id: 'people', label: 'Observed people' }, { id: 'activation', label: 'Activation' }, - { id: 'last_event', label: 'Last event' }, + { id: 'last_event', label: 'Last event · 30d' }, { id: 'events', label: 'Event volume' }, ] as const; const REVENUE_KPI = { id: 'revenue', label: 'Revenue' } as const; @@ -377,7 +395,7 @@ function productDashboardDefinitions(answer: ProductAnswer) { return answer.revenueMetric ? [...PRODUCT_KPIS, REVENUE_KPI] : PRODUCT_KPIS; } -function WebsiteAnswerCanvas({ answer, product, schema, env, onRetry }: { answer: WebsiteAnswer; product: ProductAnswer; schema: ProjectSchema | null; env: string; onRetry: () => void }) { +function WebsiteAnswerCanvas({ answer, product, schema, env, range, onRetry }: { answer: WebsiteAnswer; product: ProductAnswer; schema: ProjectSchema | null; env: string; range: ResolvedAnalyticsRange; onRetry: () => void }) { const activity = recentObservedEvents(schema); const lastEvent = activity?.[0] ?? null; const answerUnavailable = Boolean(answer.metric && !answer.overview); @@ -391,7 +409,7 @@ function WebsiteAnswerCanvas({ answer, product, schema, env, onRetry }: { answer return ( <> - + - + {answer.trend && ( + + )} + - + - + {answer.trend && ( + + )} + ({ + label: item.label, + value: item.value ?? item.fallback ?? 'Unavailable', + detail: item.note, + }))} />; +} + +function HomeTrend({ result, title, label, range }: { result: TrendQueryResult; title: string; label: string; range: ResolvedAnalyticsRange }) { return ( -
- {items.map((item, index) => ( -
0 ? 'lg:border-l' : 'lg:border-l-0'} lg:border-b-0`} - > -
{item.label}
-
- {item.value ?? item.fallback ?? 'Unavailable'} -
- {item.note &&
{item.note}
} -
- ))} -
+ +
+

{title}

+ {range.label} +
+
+ +
+
); } -function HomeEvidence({ trust, eventCount, env }: { trust: EvidenceTrust; eventCount: number | null; env: string }) { +function HomeEvidence({ trust, eventCount, env, range }: { trust: EvidenceTrust; eventCount: number | null; env: string; range: ResolvedAnalyticsRange }) { const trustLabel = trust === 'trusted' ? 'Trusted' : trust === 'partial' ? 'Partial' : 'Unavailable'; return (
- Observed · Last 30 days · {trustLabel} · {eventCount === null ? 'event count unavailable' : `${eventCount.toLocaleString()} events`} · {env} + Observed · {range.label} · {trustLabel} · {eventCount === null ? 'event count unavailable' : `${eventCount.toLocaleString()} events`} · {env}
); } @@ -536,7 +563,10 @@ function HomeSummary({ funnel, funnelResult, activity }: {
-

Recent activity

+
+

Recent activity

+ Last 30 days +
{activity === null ? (

Event activity is unavailable right now.

) : activity.length === 0 ? ( @@ -582,15 +612,13 @@ function funnelStepValue(actors: number, conversionFromStart: number | null) { return `${Math.round(conversionFromStart * 1_000) / 10}% from start`; } -function PageHeader({ title, answer }: { title: string; answer: string }) { - return ( -
-
-

{title}

-

{answer}

-
-
- ); +function PageHeader({ title, answer, range, selection, onSelectionChange }: { title: string; answer: string; range: ResolvedAnalyticsRange; selection: AnalyticsRangeSelection; onSelectionChange: (selection: AnalyticsRangeSelection) => void }) { + return } + />; } function homeAnswerTelemetry(data: { @@ -631,20 +659,24 @@ async function readWebsiteAnswer( project: string, env: string, metric: Metric | null, + range: ResolvedAnalyticsRange, ): Promise { - if (!metric) return { metric: null, overview: null, overviewUnavailable: false, trust: null, trustUnavailable: false }; - const base = { metric: metric.key, date_from: '-30d', filters: [], env }; - const [overviewResult, trustResult] = await Promise.all([ + if (!metric) return { metric: null, overview: null, trend: null, overviewUnavailable: false, trust: null, trustUnavailable: false }; + const base = { metric: metric.key, date_from: range.from, date_to: range.to, filters: [], env }; + const [overviewResult, trend, trustResult] = await Promise.all([ client.operationalQuery(project, { kind: 'web_analytics', ...base, dimensions: ['source', 'route', 'campaign'] }) .then((overview) => ({ overview, unavailable: false })) .catch(() => ({ overview: null, unavailable: true })), - client.measurementTrust(project, { metric_key: metric.key, env, since_days: 30, target_filters: [] }) + client.query(project, { kind: 'trend', metric: metric.key, date_from: range.from, date_to: range.to, interval: 'day', filters: [], env }) + .then((result) => result.kind === 'trend' ? result : null).catch(() => null), + client.measurementTrust(project, { metric_key: metric.key, env, since_days: Math.min(range.days, 365), target_filters: [] }) .then((trust) => ({ trust, unavailable: false })) .catch(() => ({ trust: null, unavailable: true })), ]); return { metric, overview: overviewResult.overview, + trend, overviewUnavailable: overviewResult.unavailable, trust: trustResult.trust, trustUnavailable: trustResult.unavailable, @@ -658,23 +690,24 @@ async function readProductAnswer( metric: Metric | null, revenueMetric: Metric | null, funnel: Funnel | null, + range: ResolvedAnalyticsRange, ): Promise { const [trend, revenueTrend, trustResult, funnelResult] = await Promise.all([ metric - ? client.query(project, { kind: 'trend', metric: metric.key, date_from: '-30d', date_to: null, interval: 'day', filters: [], env }) + ? client.query(project, { kind: 'trend', metric: metric.key, date_from: range.from, date_to: range.to, interval: 'day', filters: [], env }) .then((result) => result.kind === 'trend' ? result : null).catch(() => null) : Promise.resolve(null), revenueMetric - ? client.query(project, { kind: 'trend', metric: revenueMetric.key, date_from: '-30d', date_to: null, interval: 'day', filters: [], env }) + ? client.query(project, { kind: 'trend', metric: revenueMetric.key, date_from: range.from, date_to: range.to, interval: 'day', filters: [], env }) .then((result) => result.kind === 'trend' ? result : null).catch(() => null) : Promise.resolve(null), metric - ? client.measurementTrust(project, { metric_key: metric.key, env, since_days: 30, target_filters: [] }) + ? client.measurementTrust(project, { metric_key: metric.key, env, since_days: Math.min(range.days, 365), target_filters: [] }) .then((trust) => ({ trust, unavailable: false })) .catch(() => ({ trust: null, unavailable: true })) : Promise.resolve({ trust: null, unavailable: false }), funnel - ? client.query(project, { kind: 'funnel', funnel: funnel.key, date_from: '-30d', date_to: null, env }) + ? client.query(project, { kind: 'funnel', funnel: funnel.key, date_from: range.from, date_to: range.to, env }) .then((result) => result.kind === 'funnel' ? result : null).catch(() => null) : Promise.resolve(null), ]); diff --git a/web/src/screens/Person.test.tsx b/web/src/screens/Person.test.tsx new file mode 100644 index 0000000..73d8d8d --- /dev/null +++ b/web/src/screens/Person.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useStore } from '../store'; +import { Person } from './Person'; + +vi.mock('../store', async (importOriginal) => ({ + ...(await importOriginal()), + useStore: vi.fn(), +})); + +const personSummary = vi.fn(); +const mockedStore = vi.mocked(useStore); + +describe('Person profile', () => { + beforeEach(() => { + vi.clearAllMocks(); + personSummary.mockResolvedValue({ + requested_distinct_id: 'actor-7', + distinct_id: 'actor-7', + env: 'prod', + window: { from: '2026-07-07T00:00:00Z', to: '2026-08-06T00:00:00Z' }, + summary: { + first_seen: '2026-08-01T10:00:00Z', last_seen: '2026-08-05T11:00:00Z', + total_events: 12, distinct_events: 2, active_days: 3, sessions: 2, session_count: 2, + registered_share: 1, top_events: [{ event: 'page.viewed', count: 8 }], + }, + identity: { + status: 'unknown', raw_actor_count: 1, raw_distinct_ids: ['actor-7'], + raw_distinct_ids_truncated: false, links: [], links_truncated: false, + }, + entity: null, + activity: { events: [], next_cursor: null, registered_only: true, properties_masked: true }, + capabilities: { + identity_entity: { available: false, reason: 'No identity entity.', source: null }, + activity_properties: { available: false, reason: 'Properties are masked.', source: null }, + pinned_properties: { available: false, reason: 'No pinned properties.', source: null }, + session_count: { source: 'canonical_browser_sessions', unavailable_value: null, project_capability: true }, + purge: { scope: 'exact_raw_distinct_id', canonical_expansion: false, warning: 'Exact raw ID only.' }, + }, + }); + mockedStore.mockReturnValue({ + project: 'alpha', env: 'prod', client: { personSummary }, + } as never); + }); + + it('uses the shared custom period and a compact readable profile', async () => { + const view = render( + + } /> + , + ); + await screen.findByRole('heading', { name: 'Actor profile' }); + + const period = screen.getByRole('group', { name: 'Analytics period' }); + fireEvent.click(period.querySelector('button:last-child')!); + fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2026-08-01' } }); + fireEvent.change(screen.getByLabelText('End date'), { target: { value: '2026-08-04' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply period' })); + + await waitFor(() => expect(personSummary).toHaveBeenLastCalledWith('alpha', 'actor-7', expect.objectContaining({ + env: 'prod', from: '2026-08-01T00:00:00.000Z', to: '2026-08-05T00:00:00.000Z', + }))); + expect(screen.queryByText('Last 30 days')).not.toBeInTheDocument(); + expect(view.container.querySelector('.text-xs')).toBeNull(); + }); +}); diff --git a/web/src/screens/Person.tsx b/web/src/screens/Person.tsx index e510824..201acf0 100644 --- a/web/src/screens/Person.tsx +++ b/web/src/screens/Person.tsx @@ -1,38 +1,38 @@ import { useEffect, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { ArrowLeft, Copy } from '@/components/icons'; +import { AnalyticsDateRange } from '@/components/AnalyticsDateRange'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { EmptyState, ErrorNote, Loading, Panel, Stat, fmtNum } from '@/components/ui'; +import { EmptyState, ErrorNote, KpiRail, Loading, Panel, fmtNum } from '@/components/ui'; import { actorStatusLabel, - rangeDateFrom, - type AnalyticsRange, type PersonResult, } from '../analysis/operations'; +import { useAnalyticsRange } from '../analysis/useAnalyticsRange'; import { useAsync, useStore } from '../store'; import { IdentityBadge } from './Users'; export function Person() { const { distinctId = '' } = useParams(); const { client, project, env } = useStore(); - const [range, setRange] = useState('30d'); + const { selection: rangeSelection, resolved: range, setSelection: setRangeSelection } = useAnalyticsRange(); const [cursorStack, setCursorStack] = useState>([null]); const cursor = cursorStack.at(-1) ?? undefined; const page = cursorStack.length - 1; const person = useAsync( () => client!.personSummary(project!, distinctId, { env, - from: rangeDateFrom(range), + from: range.from, + to: range.to, limit: 50, ...(cursor ? { cursor } : {}), }), - [project, env, distinctId, range, cursor], + [project, env, distinctId, range.from, range.to, cursor], ); - useEffect(() => setCursorStack([null]), [project, env, distinctId, range]); + useEffect(() => setCursorStack([null]), [project, env, distinctId, range.from, range.to]); if (person.loading) return ; if (person.error) return {person.error}; @@ -43,40 +43,30 @@ export function Person() {
- - Users + + People
-

Actor profile

+

Actor profile

- +
-
- - - - -
+
@@ -96,7 +86,7 @@ export function Person() {
{data.summary.top_events.map((event) => (
- {event.event} + {event.event} {event.count}
))} @@ -108,7 +98,7 @@ export function Person() { -
+
Entity properties and pinned traits are unavailable. Poolstatis does not infer identity or contactability from email, name or ID shape.
@@ -129,12 +119,12 @@ export function Person() {
{event.event}
-
+
raw {event.raw_distinct_id} {event.session_id && session {event.session_id}}
- +
))}
@@ -147,7 +137,7 @@ export function Person() { > Previous - Page {page + 1} + Page {page + 1} ); })}
} - -
-
-
{funnelSurface ? 'Funnel answer' : 'Current answer'}
-

{template.title}

-

{template.question}

-
- +
+
+

{template.title}

+

{template.question}

+ +
+ + {(!selectedKey || capability.status === 'unavailable') && {!selectedKey || capability.status === 'unavailable' ? (
{capability.status === 'unavailable' ? 'This answer is not available yet' : 'Choose a registered outcome'}
@@ -332,21 +336,18 @@ export function ProductAnalytics({ surface = 'product' }: { surface?: 'product' : isFunnel ? 'Save a funnel with a goal and active metric steps.' : 'Activate a metric with a concrete purpose.'}

- ) : !currentRun && !currentRunError && !running ? ( -
-
Ready to read real data
-

Run the prebuilt answer for {selectedKey}. Poolstatis will not substitute demo values.

-
) : null} - {currentRunError &&
{currentRunError}
} - {renderState === 'loading' && } - {renderState === 'empty' && currentRun && ( +
} + {currentRunError && {currentRunError}} + {renderState === 'loading' && } + {renderState === 'empty' && currentRun && ( +
No observations in this exact period

Change the range or inspect the outcome definition.

- )} -
+ + )} {funnelSurface && currentRun?.result.kind === 'funnel' && ( {funnelSurface ? 'Edit funnel analysis' : 'Edit analysis'} - {funnelSurface ? 'Saved funnel and exact range' : 'Range, metric, view and breakdown'} + {funnelSurface ? 'Saved funnel and query settings' : 'Metric, view and breakdown'}
@@ -436,7 +437,7 @@ export function ProductAnalytics({ surface = 'product' }: { surface?: 'product'
) : (
-
+
- - - {!isFunnel && !isRetention && ( - - - onRange(value as AnalyticsRange)}> - - - {RANGE_OPTIONS.map((item) => {item.label})} - - - : undefined} + actions={showRange ? : undefined} /> ); } @@ -1107,7 +1091,7 @@ function UnavailableDimension({ label, unavailable }: { function SessionDetail({ session, metric, range, onClose }: { session: WebSessionSummary; metric: string; - range: AnalyticsRange; + range: ResolvedAnalyticsRange; onClose: () => void; }) { const { client, project, env } = useStore(); @@ -1116,11 +1100,12 @@ function SessionDetail({ session, metric, range, onClose }: { metric, session_id: session.session_id, actor_id: session.actor_id, - date_from: rangeDateFrom(range), + date_from: range.from, + date_to: range.to, filters: [], page_limit: 100, env, - }), [project, env, metric, session.session_id, session.actor_id, range]); + }), [project, env, metric, session.session_id, session.actor_id, range.from, range.to]); return (
@@ -1247,10 +1232,11 @@ async function readWebTrust( project: string, env: string, metric: string, + days: number, ): Promise { if (typeof client.measurementTrust !== 'function') return { result: null, unavailable: true }; try { - return { result: await client.measurementTrust(project, { metric_key: metric, env, since_days: 30, target_filters: [] }), unavailable: false }; + return { result: await client.measurementTrust(project, { metric_key: metric, env, since_days: Math.min(days, 365), target_filters: [] }), unavailable: false }; } catch { return { result: null, unavailable: true }; }