From 59daf1321c3652030d5a7d38a5046bba327a2a93 Mon Sep 17 00:00:00 2001 From: Mantra <87142457+mantrakp04@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:50:35 -0700 Subject: [PATCH] [codex] Add analytics overview filters (#1496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds richer analytics overview metrics and filterable dashboard breakdowns. - adds hourly overview series for the 1-day range - adds country, referrer, browser, OS, and device filters to internal metrics - adds bounce rate, session duration, top countries, top browsers, top operating systems, and device breakdowns - updates the overview dashboard with filter chips, top-list cards, animated metric states, and 1-day hourly chart support - captures user agent on page-view analytics events, with a server-side fallback for older clients ## Validation Attempted targeted tests: `pnpm test run apps/backend/src/app/api/latest/internal/metrics/route.test.ts 'apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.test.ts'` This did not reach Vitest in the temporary split worktree because `node_modules` is not installed there and the repo pre-step failed at `pnpm exec tsx ./scripts/generate-sdks.ts`. --- ## Summary by cubic Adds analytics overview filters with optional date‑range bounds and 1‑day hourly charts, plus smoother, accessible animations across charts and top lists. Improves correctness and stability with deterministic caching, normalized inputs, client‑only user‑agent capture, and globe/layout fixes. - **New Features** - Filterable analytics overview (country, referrer, browser, OS, device) with normalized inputs and optional `since`/`until`; API/admin/dashboard accept `AnalyticsOverviewFilters` with deterministic cache keys. - 1‑day hourly charts (page views, visitors) and a metric mode toggle (DAU, Visitors, Revenue); animated top‑lists and sparklines powered by `motion` with reduced‑motion support. - UI: filter chips/menu, clearer tooltips (incl. user metric cards), optional interactive globe with dynamic camera distance; exported `TooltipPortal` from `@hexclave/ui`. - **Refactors & Bug Fixes** - Event ingest: client sends `user_agent`; removed server‑side fallback; added user‑agent filter‑fragment builder and tests. - Metrics correctness: aligned hourly bounds to start of UTC hour; derived 1‑day revenue total from daily series; resilient chart x‑axis formatting; country filter options use analytics `top_regions`; fixed‑'en' locale for top‑lists; added date‑range parsing/validation for filters. - UI/runtime: smoother pill/tab slider animations with guards for missing Web APIs; added `containedHeight` to `PageLayout` and wired into sidebar/session replays; globe disables zoom when non‑interactive. - Misc: instrumentation runs only in Node (`process.env.NEXT_RUNTIME === "nodejs"`); analytics/overview page redirects with URL‑encoded `projectId`; Docker: include `@hexclave/template` in `turbo prune` to fix CI builds. Written for commit 7fcd3558a588a1091e45aa931f91a658a85c374d. Summary will update on new commits. Review in cubic ## Summary by CodeRabbit * **New Features** * Analytics filters (country, referrer, browser, OS, device); hourly signup and active-user series; expanded hourly/daily analytics payloads and top-lists UI. * Chart metric modes (DAU, Visitors, Revenue), optional page-views series, interactive globe support, animated Top Lists, and sparkline animations. * **Improvements** * Better user-agent capture/normalization for batched events and page-view tracking; reduced-motion aware animations; enhanced tooltips and UI slider/tab indicators. * Added motion library dependency. * **Tests** * New unit tests for analytics filters and chart metric mode behavior. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: mantra --- .../api/latest/internal/metrics/route.test.ts | 49 +- .../app/api/latest/internal/metrics/route.tsx | 698 ++++++++- apps/dashboard/package.json | 1 + .../(overview)/analytics-chart-mode.test.ts | 14 + .../(overview)/analytics-chart-mode.ts | 12 + .../[projectId]/(overview)/animation-utils.ts | 7 + .../(overview)/globe-section-with-data.tsx | 7 +- .../projects/[projectId]/(overview)/globe.tsx | 35 +- .../[projectId]/(overview)/line-chart.tsx | 221 ++- .../[projectId]/(overview)/metrics-page.tsx | 1352 +++++++++++++---- .../[projectId]/(overview)/page-client.tsx | 2 +- .../[projectId]/(overview)/top-lists.tsx | 431 ++++++ .../[projectId]/analytics/overview/page.tsx | 10 + .../projects/[projectId]/page-layout.tsx | 2 + .../session-replays/page-client.tsx | 30 +- .../projects/[projectId]/sidebar-layout.tsx | 6 +- .../users/[userId]/user-page-metric-card.tsx | 193 ++- .../src/components/ui/simple-tooltip.tsx | 8 +- apps/dashboard/src/instrumentation.ts | 7 +- .../src/lib/hexclave-app-internals.ts | 71 +- docker/backend/Dockerfile | 2 +- docker/local-emulator/Dockerfile | 2 +- docker/server/Dockerfile | 2 +- .../src/components/pill-toggle.tsx | 224 ++- .../src/components/tabs.tsx | 93 +- .../shared/src/interface/admin-interface.ts | 51 +- .../shared/src/interface/admin-metrics.ts | 35 +- .../apps/implementations/admin-app-impl.ts | 27 +- .../apps/implementations/event-tracker.ts | 1 + packages/ui/src/components/ui/tooltip.tsx | 4 +- pnpm-lock.yaml | 62 + 31 files changed, 3043 insertions(+), 616 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.test.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/animation-utils.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/top-lists.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/overview/page.tsx diff --git a/apps/backend/src/app/api/latest/internal/metrics/route.test.ts b/apps/backend/src/app/api/latest/internal/metrics/route.test.ts index 9be2a91ad..b3fe285dc 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.test.ts +++ b/apps/backend/src/app/api/latest/internal/metrics/route.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { getMetricsWindowBounds, isMetricsRevenueInvoiceStatus } from "./route"; +import { + buildAnalyticsOverviewUserAgentFilterFragmentsForTest, + getMetricsWindowBounds, + isMetricsRevenueInvoiceStatus, + normalizeAnalyticsOverviewFilters, +} from "./route"; describe("internal metrics helpers", () => { it("only counts paid and succeeded invoices as revenue", () => { @@ -17,4 +22,46 @@ describe("internal metrics helpers", () => { expect(since.toISOString()).toBe("2026-03-14T00:00:00.000Z"); expect(untilExclusive.toISOString()).toBe("2026-04-14T00:00:00.000Z"); }); + + it("normalizes analytics overview filters before adding them to ClickHouse params", () => { + expect(normalizeAnalyticsOverviewFilters({ + country_code: " us ", + referrer: " https://example.com ", + browser: "", + os: " macOS ", + device: " Desktop ", + since: " 2026-06-01T00:00:00.000Z ", + until: "", + })).toMatchInlineSnapshot(` + { + "browser": undefined, + "country_code": "US", + "device": "Desktop", + "os": "macOS", + "referrer": "https://example.com", + "since": "2026-06-01T00:00:00.000Z", + "until": undefined, + } + `); + }); + + it("builds deterministic user-agent filter fragments without a raw user-agent allowlist", () => { + expect(buildAnalyticsOverviewUserAgentFilterFragmentsForTest({ + browser: "Chrome", + os: "macOS", + device: "Desktop", + })).toMatchInlineSnapshot(` + { + "hasBrowserFilter": true, + "hasDeviceFilter": true, + "hasOsFilter": true, + "params": { + "browserFilter": "Chrome", + "deviceFilter": "Desktop", + "osFilter": "macOS", + }, + "usesRawUserAgentAllowlist": false, + } + `); + }); }); diff --git a/apps/backend/src/app/api/latest/internal/metrics/route.tsx b/apps/backend/src/app/api/latest/internal/metrics/route.tsx index d5d1e753a..01528e4c9 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -20,7 +20,7 @@ import { MetricsPaymentsOverviewSchema, MetricsRecentUserSchema, } from "@hexclave/shared/dist/interface/admin-metrics"; -import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; +import { captureError, HexclaveAssertionError, StatusError } from "@hexclave/shared/dist/utils/errors"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupRecord, yupString } from "@hexclave/shared/dist/schema-fields"; import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud"; @@ -362,6 +362,53 @@ async function loadTotalUsers(tenancy: Tenancy, now: Date, includeAnonymous: boo return out; } +async function loadHourlyUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise { + const latestHour = new Date(now); + latestHour.setUTCMinutes(0, 0, 0); + const since = new Date(latestHour.getTime() - 23 * 60 * 60 * 1000); + const untilExclusive = new Date(latestHour.getTime() + 60 * 60 * 1000); + const clickhouseClient = getClickhouseAdminClientForMetrics(); + + const result = await clickhouseClient.query({ + query: ` + SELECT + toStartOfHour(signed_up_at) AS hour, + count() AS hourly_users + FROM analytics_internal.users FINAL + WHERE project_id = {projectId:String} + AND branch_id = {branchId:String} + AND sync_is_deleted = 0 + AND signed_up_at >= {since:DateTime} + AND signed_up_at < {untilExclusive:DateTime} + AND ({includeAnonymous:UInt8} = 1 OR is_anonymous = 0) + GROUP BY hour + ORDER BY hour + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }); + const rows = await result.json() as { hour: string, hourly_users: string | number }[]; + + const countByHour = new Map(); + for (const row of rows) { + countByHour.set(new Date(row.hour).toISOString().slice(0, 13), Number(row.hourly_users)); + } + + const out: DataPoints = []; + for (let i = 0; i < 24; i++) { + const hour = new Date(since.getTime() + i * 60 * 60 * 1000); + const key = hour.toISOString().slice(0, 13); + out.push({ date: `${key}:00:00.000Z`, activity: countByHour.get(key) ?? 0 }); + } + return out; +} + async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false) { const todayUtc = new Date(now); todayUtc.setUTCHours(0, 0, 0, 0); @@ -415,6 +462,53 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou return out; } +async function loadHourlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise { + const latestHour = new Date(now); + latestHour.setUTCMinutes(0, 0, 0); + const since = new Date(latestHour.getTime() - 23 * 60 * 60 * 1000); + const untilExclusive = new Date(latestHour.getTime() + 60 * 60 * 1000); + const clickhouseClient = getClickhouseAdminClientForMetrics(); + const result = await clickhouseClient.query({ + query: ` + SELECT + toStartOfHour(event_at) AS hour, + uniqExact(assumeNotNull(user_id)) AS dau + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND user_id IS NOT NULL + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0) + GROUP BY hour + ORDER BY hour ASC + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }); + + const rows: { hour: string, dau: number }[] = await result.json(); + const dauByHour = new Map(); + for (const row of rows) { + dauByHour.set(new Date(row.hour).toISOString().slice(0, 13), Number(row.dau)); + } + + const out: DataPoints = []; + for (let i = 0; i < 24; i += 1) { + const hour = new Date(since.getTime() + i * 60 * 60 * 1000); + const key = hour.toISOString().slice(0, 13); + out.push({ date: `${key}:00:00.000Z`, activity: dauByHour.get(key) ?? 0 }); + } + return out; +} + async function loadDailyActiveSplitFromClickhouse(options: { tenancy: Tenancy, now: Date, @@ -1057,12 +1151,143 @@ async function loadSessionReplayAggregates(tenancy: Tenancy, since: Date): Promi }; } -async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymous: boolean) { +const DIRECT_REFERRER_LABEL = "(direct)"; + +export type AnalyticsOverviewFilters = { + country_code?: string, + referrer?: string, + browser?: string, + os?: string, + device?: string, + // ISO 8601 datetimes bounding the top-N breakdowns (referrers, regions, + // browsers/OS/devices). Clamped to the analytics window server-side; the + // daily/hourly series intentionally stay full-window so the dashboard can + // compute previous-period deltas client-side. + since?: string, + until?: string, +}; + +export function normalizeAnalyticsOverviewFilters(filters: AnalyticsOverviewFilters): AnalyticsOverviewFilters { + const countryCode = filters.country_code?.trim().toUpperCase(); + const referrer = filters.referrer?.trim(); + const browser = filters.browser?.trim(); + const os = filters.os?.trim(); + const device = filters.device?.trim(); + const since = filters.since?.trim(); + const until = filters.until?.trim(); + return { + country_code: countryCode || undefined, + referrer: referrer || undefined, + browser: browser || undefined, + os: os || undefined, + device: device || undefined, + since: since || undefined, + until: until || undefined, + }; +} + +function parseAnalyticsRangeBound(value: string | undefined, paramName: string): Date | undefined { + if (value == null) return undefined; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new StatusError(400, `Invalid ${paramName}: expected an ISO 8601 datetime, got ${JSON.stringify(value)}`); + } + return parsed; +} + +const analyticsOverviewUserAgentSql = "toString(e.data.user_agent)"; +const analyticsOverviewViewportWidthSql = "toInt32(toInt64OrZero(toString(e.data.viewport_width)))"; + +const analyticsOverviewBrowserSql = `multiIf( + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'edg/') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'edge/') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'edga/') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'edgios/') > 0, 'Edge', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'opr/') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'opera') > 0, 'Opera', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'samsungbrowser') > 0, 'Samsung Internet', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'firefox') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'fxios') > 0, 'Firefox', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'crios') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'chrome') > 0, 'Chrome', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'safari') > 0, 'Safari', + 'Other' +)`; + +const analyticsOverviewOsSql = `multiIf( + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'windows') > 0, 'Windows', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'android') > 0, 'Android', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'iphone') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'ipad') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'ipod') > 0, 'iOS', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'mac os') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'macintosh') > 0, 'macOS', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'cros') > 0, 'ChromeOS', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'linux') > 0, 'Linux', + 'Other' +)`; + +const analyticsOverviewDeviceSql = `multiIf( + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'ipad') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'tablet') > 0 OR (positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'android') > 0 AND positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'mobile') = 0), 'Tablet', + positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'mobile') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'iphone') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'ipod') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'android') > 0, 'Mobile', + ${analyticsOverviewViewportWidthSql} > 0 AND ${analyticsOverviewViewportWidthSql} < 600, 'Mobile', + ${analyticsOverviewViewportWidthSql} >= 600 AND ${analyticsOverviewViewportWidthSql} < 1024, 'Tablet', + 'Desktop' +)`; + +function buildAnalyticsOverviewUserAgentFilterFragments(filters: AnalyticsOverviewFilters): { + browserFragment: string, + osFragment: string, + deviceFragment: string, + params: Record, +} { + return { + browserFragment: filters.browser ? `AND ${analyticsOverviewBrowserSql} = {browserFilter:String}` : "", + osFragment: filters.os ? `AND ${analyticsOverviewOsSql} = {osFilter:String}` : "", + deviceFragment: filters.device ? `AND ${analyticsOverviewDeviceSql} = {deviceFilter:String}` : "", + params: { + ...(filters.browser ? { browserFilter: filters.browser } : {}), + ...(filters.os ? { osFilter: filters.os } : {}), + ...(filters.device ? { deviceFilter: filters.device } : {}), + }, + }; +} + +export function buildAnalyticsOverviewUserAgentFilterFragmentsForTest(filters: AnalyticsOverviewFilters): { + hasBrowserFilter: boolean, + hasOsFilter: boolean, + hasDeviceFilter: boolean, + params: Record, + usesRawUserAgentAllowlist: boolean, +} { + const fragments = buildAnalyticsOverviewUserAgentFilterFragments(filters); + const combinedFragments = [ + fragments.browserFragment, + fragments.osFragment, + fragments.deviceFragment, + ].join("\n"); + return { + hasBrowserFilter: fragments.browserFragment.length > 0, + hasOsFilter: fragments.osFragment.length > 0, + hasDeviceFilter: fragments.deviceFragment.length > 0, + params: fragments.params, + usesRawUserAgentAllowlist: combinedFragments.includes("matchingUAs") || combinedFragments.includes("IN {"), + }; +} + +async function loadAnalyticsOverview( + tenancy: Tenancy, + now: Date, + includeAnonymous: boolean, + filters: AnalyticsOverviewFilters = {}, +) { const todayUtc = new Date(now); todayUtc.setUTCHours(0, 0, 0, 0); const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); + // Optional date range for the top-N breakdowns, clamped to the analytics + // window (ClickHouse only has detail for the last METRICS_WINDOW_DAYS). + // The daily/hourly series stay full-window — see AnalyticsOverviewFilters. + const requestedRangeSince = parseAnalyticsRangeBound(filters.since, "filter_since"); + const requestedRangeUntil = parseAnalyticsRangeBound(filters.until, "filter_until"); + const rangeSince = new Date(Math.max(since.getTime(), requestedRangeSince?.getTime() ?? since.getTime())); + const rangeUntilExclusive = new Date(Math.min(untilExclusive.getTime(), requestedRangeUntil?.getTime() ?? untilExclusive.getTime())); + if (rangeSince.getTime() >= rangeUntilExclusive.getTime()) { + throw new StatusError(400, "filter_since must be before filter_until after clamping to the analytics window"); + } + const clickhouseClient = getClickhouseAdminClientForMetrics(); // Session replay aggregates come from Postgres and have nothing to do with @@ -1082,24 +1307,43 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo dailyPageViews: DataPoints, dailyClicks: DataPoints, dailyVisitors: DataPoints, + hourlyPageViews: DataPoints, + hourlyActiveUsers: DataPoints, + hourlyVisitors: DataPoints, + dailyBounceRate: DataPoints, + dailyAvgSession: DataPoints, visitors: number, onlineLive: number, + bounceRate: number, + avgSessionSeconds: number, topReferrers: { referrer: string, visitors: number }[], topRegion: { country_code: string | null, region_code: string | null, count: number } | null, + topRegions: { country_code: string, count: number }[], + topBrowsers: { name: string, visitors: number }[], + topOperatingSystems: { name: string, visitors: number }[], + topDevices: { name: string, visitors: number }[], } | null = null; - try { + // Explicit installed-check instead of inferring "analytics not enabled" from + // a failed ClickHouse query: when the app isn't installed we skip ClickHouse + // entirely and return the token-refresh fallback payload; when it IS + // installed, every ClickHouse error propagates to the caller so the + // dashboard renders its error state instead of plausible-looking zeros. + const analyticsInstalled = tenancy.config.apps.installed["analytics"]?.enabled ?? false; + + if (analyticsInstalled) try { // The `event_at >= since` bound on the inner subquery is load-bearing: // without it the GROUP BY hash table holds one row per ever-seen user. // Edge case: anonymous page-views by users with no token-refresh in the // last 30 days now coalesce to non-anonymous. The proper fix is to stamp // `is_anonymous` on page-view/click events at ingest and drop this join // entirely (the coalesce below short-circuits on the first non-null arg). - const analyticsUserJoin = ` + const buildAnalyticsUserJoin = (includeCountry: boolean) => ` LEFT JOIN ( SELECT user_id, argMax(coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0), event_at) AS latest_is_anonymous + ${includeCountry ? ", argMax(CAST(data.ip_info.country_code, 'Nullable(String)'), event_at) AS latest_country" : ""} FROM analytics_internal.events WHERE event_type = '$token-refresh' AND project_id = {projectId:String} @@ -1111,37 +1355,74 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo ) AS token_refresh_users ON e.user_id = token_refresh_users.user_id `; + const analyticsUserJoinForFilteredEvents = buildAnalyticsUserJoin(filters.country_code != null); + const analyticsUserJoinWithCountry = buildAnalyticsUserJoin(true); const nonAnonymousAnalyticsUserFilter = "({includeAnonymous:UInt8} = 1 OR coalesce(CAST(e.data.is_anonymous, 'Nullable(UInt8)'), token_refresh_users.latest_is_anonymous, 0) = 0)"; - const [dailyEventResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult] = await Promise.all([ + const analyticsContributingUserFilter = `e.user_id IS NOT NULL AND ${nonAnonymousAnalyticsUserFilter}`; + + // Build per-dimension filter fragments; callers below opt out of the + // fragment matching their own dimension so top-N queries don't collapse to + // a single row (e.g. top_referrers must not also filter by referrer). + const referrerFragment = filters.referrer + ? (filters.referrer === DIRECT_REFERRER_LABEL + ? `AND CAST(e.data.referrer, 'String') = ''` + : `AND CAST(e.data.referrer, 'String') = {referrerFilter:String}`) + : ''; + const countryFragment = filters.country_code + ? `AND upper(coalesce(token_refresh_users.latest_country, '')) = {countryFilter:String}` + : ''; + const userAgentFilterFragments = buildAnalyticsOverviewUserAgentFilterFragments(filters); + const uaFragment = [ + userAgentFilterFragments.browserFragment, + userAgentFilterFragments.osFragment, + userAgentFilterFragments.deviceFragment, + ].join(" "); + + const sharedExtraFilters = `${referrerFragment} ${countryFragment} ${uaFragment}`.trim(); + const filterParams = { + ...(filters.referrer && filters.referrer !== DIRECT_REFERRER_LABEL ? { referrerFilter: filters.referrer } : {}), + ...(filters.country_code ? { countryFilter: filters.country_code } : {}), + ...userAgentFilterFragments.params, + }; + const onlineFilteredUserFragment = sharedExtraFilters + ? ` + AND user_id IN ( + SELECT assumeNotNull(e.user_id) + FROM analytics_internal.events AS e + ${filters.country_code != null ? analyticsUserJoinWithCountry : ""} + WHERE e.event_type = '$page-view' + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.user_id IS NOT NULL + AND e.event_at >= {since:DateTime} + AND e.event_at < {untilExclusive:DateTime} + ${sharedExtraFilters} + GROUP BY e.user_id + ) + ` + : ''; + const [dailyEventResult, hourlyEventResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult, sessionResult, userAgentResult] = await Promise.all([ // Combined daily aggregates: page-view count, click count, and unique // visitors per day — one scan over the page-view/click event types. clickhouseClient.query({ query: ` SELECT toDate(e.event_at) AS day, - countIf( - e.event_type = '$page-view' - AND e.user_id IS NOT NULL - AND ${nonAnonymousAnalyticsUserFilter} - ) AS pv, - countIf( - e.event_type = '$click' - AND e.user_id IS NOT NULL - AND ${nonAnonymousAnalyticsUserFilter} - ) AS cl, + countIf(e.event_type = '$page-view') AS pv, + countIf(e.event_type = '$click') AS cl, uniqExactIf( assumeNotNull(e.user_id), e.event_type = '$page-view' - AND e.user_id IS NOT NULL - AND ${nonAnonymousAnalyticsUserFilter} ) AS visitors FROM analytics_internal.events AS e - ${analyticsUserJoin} + ${analyticsUserJoinForFilteredEvents} WHERE e.event_type IN ('$page-view', '$click') AND e.project_id = {projectId:String} AND e.branch_id = {branchId:String} AND e.event_at >= {since:DateTime} AND e.event_at < {untilExclusive:DateTime} + AND ${analyticsContributingUserFilter} + ${sharedExtraFilters} GROUP BY day ORDER BY day ASC `, @@ -1151,25 +1432,63 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, }, format: "JSONEachRow", }), clickhouseClient.query({ query: ` SELECT + toStartOfHour(e.event_at) AS hour, + countIf(e.event_type = '$page-view') AS pv, uniqExactIf( assumeNotNull(e.user_id), - e.user_id IS NOT NULL - AND ${nonAnonymousAnalyticsUserFilter} + e.event_type IN ('$page-view', '$click') + ) AS active_users, + uniqExactIf( + assumeNotNull(e.user_id), + e.event_type = '$page-view' ) AS visitors FROM analytics_internal.events AS e - ${analyticsUserJoin} + ${analyticsUserJoinForFilteredEvents} + WHERE e.event_type IN ('$page-view', '$click') + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.event_at >= {hourlySince:DateTime} + AND e.event_at < {untilExclusive:DateTime} + AND ${analyticsContributingUserFilter} + ${sharedExtraFilters} + GROUP BY hour + ORDER BY hour ASC + `, + query_params: { + hourlySince: formatClickhouseDateTimeParam((() => { + const latestHour = new Date(now); + latestHour.setUTCMinutes(0, 0, 0); + return new Date(latestHour.getTime() - 23 * 60 * 60 * 1000); + })()), + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, + }, + format: "JSONEachRow", + }), + clickhouseClient.query({ + query: ` + SELECT + uniqExact(assumeNotNull(e.user_id)) AS visitors + FROM analytics_internal.events AS e + ${analyticsUserJoinForFilteredEvents} WHERE e.event_type = '$page-view' AND e.project_id = {projectId:String} AND e.branch_id = {branchId:String} - AND e.user_id IS NOT NULL AND e.event_at >= {since:DateTime} AND e.event_at < {untilExclusive:DateTime} + AND ${analyticsContributingUserFilter} + ${sharedExtraFilters} `, query_params: { since: formatClickhouseDateTimeParam(since), @@ -1177,6 +1496,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, }, format: "JSONEachRow", }), @@ -1184,18 +1504,17 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo query: ` SELECT nullIf(CAST(e.data.referrer, 'String'), '') AS referrer, - uniqExactIf( - assumeNotNull(e.user_id), - e.user_id IS NOT NULL - AND ${nonAnonymousAnalyticsUserFilter} - ) AS visitors + uniqExact(assumeNotNull(e.user_id)) AS visitors FROM analytics_internal.events AS e - ${analyticsUserJoin} + ${analyticsUserJoinForFilteredEvents} WHERE e.event_type = '$page-view' AND e.project_id = {projectId:String} AND e.branch_id = {branchId:String} - AND e.event_at >= {since:DateTime} - AND e.event_at < {untilExclusive:DateTime} + AND e.event_at >= {rangeSince:DateTime} + AND e.event_at < {rangeUntilExclusive:DateTime} + AND ${analyticsContributingUserFilter} + ${countryFragment} + ${uaFragment} GROUP BY referrer HAVING visitors > 0 ORDER BY visitors DESC @@ -1204,40 +1523,48 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo query_params: { since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + rangeSince: formatClickhouseDateTimeParam(rangeSince), + rangeUntilExclusive: formatClickhouseDateTimeParam(rangeUntilExclusive), projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, }, format: "JSONEachRow", }), + // Top regions come from the same page-view population as the rest of the + // analytics overview, but intentionally omit the country filter so the + // country card still shows a distribution when one country is selected. clickhouseClient.query({ query: ` SELECT - CAST(data.ip_info.country_code, 'Nullable(String)') AS country_code, - CAST(data.ip_info.region_code, 'Nullable(String)') AS region_code, - uniqExactIf( - assumeNotNull(user_id), - user_id IS NOT NULL - AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0) - ) AS visitors - FROM analytics_internal.events - WHERE event_type = '$token-refresh' - AND project_id = {projectId:String} - AND branch_id = {branchId:String} - AND user_id IS NOT NULL - AND event_at >= {since:DateTime} - AND event_at < {untilExclusive:DateTime} - GROUP BY country_code, region_code + upper(coalesce(token_refresh_users.latest_country, '')) AS country_code, + uniqExact(assumeNotNull(e.user_id)) AS visitors + FROM analytics_internal.events AS e + ${analyticsUserJoinWithCountry} + WHERE e.event_type = '$page-view' + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.event_at >= {rangeSince:DateTime} + AND e.event_at < {rangeUntilExclusive:DateTime} + AND ${analyticsContributingUserFilter} + AND coalesce(token_refresh_users.latest_country, '') != '' + ${referrerFragment} + ${uaFragment} + GROUP BY country_code HAVING visitors > 0 ORDER BY visitors DESC - LIMIT 1 + LIMIT ${TOP_REGIONS_PAGE_SIZE} `, query_params: { since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + rangeSince: formatClickhouseDateTimeParam(rangeSince), + rangeUntilExclusive: formatClickhouseDateTimeParam(rangeUntilExclusive), projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, }, format: "JSONEachRow", }), @@ -1253,13 +1580,129 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo AND event_at >= {onlineSince:DateTime} AND event_at < {untilExclusive:DateTime} AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0) + ${onlineFilteredUserFragment} `, query_params: { onlineSince: formatClickhouseDateTimeParam(new Date(now.getTime() - 5 * 60 * 1000)), + since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, + }, + format: "JSONEachRow", + }), + // Session aggregates keyed by session_replay_segment_id (one row per + // browser tab/session): bounce rate (single-page-view sessions) and + // average session duration per day. + clickhouseClient.query({ + query: ` + WITH matching_sessions AS ( + SELECT + e.session_replay_segment_id AS sid + FROM analytics_internal.events AS e + ${analyticsUserJoinForFilteredEvents} + WHERE e.session_replay_segment_id IS NOT NULL + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.event_at >= {since:DateTime} + AND e.event_at < {untilExclusive:DateTime} + AND e.event_type = '$page-view' + AND ${analyticsContributingUserFilter} + ${sharedExtraFilters} + GROUP BY sid + ), + sessions AS ( + SELECT + e.session_replay_segment_id AS sid, + toDate(min(e.event_at)) AS session_day, + countIf(e.event_type = '$page-view') AS pv, + dateDiff('second', min(e.event_at), max(e.event_at)) AS duration_s + FROM analytics_internal.events AS e + WHERE e.session_replay_segment_id IN (SELECT sid FROM matching_sessions) + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.event_at >= {since:DateTime} + AND e.event_at < {untilExclusive:DateTime} + AND e.event_type IN ('$page-view', '$click') + GROUP BY sid + ) + SELECT + session_day AS day, + count() AS sessions, + countIf(pv = 1) AS bounced, + avg(duration_s) AS avg_duration_s + FROM sessions + GROUP BY day + ORDER BY day ASC + `, + query_params: { + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, + }, + format: "JSONEachRow", + }), + // User-Agent buckets pulled from the same `$page-view` event stream so + // visitor counts line up with the referrer / region cards on the overview. + // `data.user_agent` is captured client-side (navigator.userAgent) only — + // there is no server-side fallback — so older rows that pre-date capture + // simply return empty here. + clickhouseClient.query({ + query: ` + SELECT + tupleElement(facet, 1) AS dimension, + tupleElement(facet, 2) AS name, + uniqExact(assumeNotNull(user_id)) AS visitors + FROM ( + SELECT + e.user_id AS user_id, + ${analyticsOverviewBrowserSql} AS browser, + ${analyticsOverviewOsSql} AS os, + ${analyticsOverviewDeviceSql} AS device + FROM analytics_internal.events AS e + ${analyticsUserJoinForFilteredEvents} + WHERE e.event_type = '$page-view' + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.event_at >= {rangeSince:DateTime} + AND e.event_at < {rangeUntilExclusive:DateTime} + AND ${analyticsContributingUserFilter} + AND ${analyticsOverviewUserAgentSql} != '' + ${referrerFragment} + ${countryFragment} + ) + ARRAY JOIN [ + ('browser', browser), + ('os', os), + ('device', device) + ] AS facet + WHERE ({browserFilterEnabled:UInt8} = 0 OR tupleElement(facet, 1) = 'browser' OR browser = {browserFilter:String}) + AND ({osFilterEnabled:UInt8} = 0 OR tupleElement(facet, 1) = 'os' OR os = {osFilter:String}) + AND ({deviceFilterEnabled:UInt8} = 0 OR tupleElement(facet, 1) = 'device' OR device = {deviceFilter:String}) + GROUP BY dimension, name + HAVING visitors > 0 + ORDER BY dimension ASC, visitors DESC + `, + query_params: { + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + rangeSince: formatClickhouseDateTimeParam(rangeSince), + rangeUntilExclusive: formatClickhouseDateTimeParam(rangeUntilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, + browserFilterEnabled: filters.browser ? 1 : 0, + browserFilter: filters.browser ?? "", + osFilterEnabled: filters.os ? 1 : 0, + osFilter: filters.os ?? "", + deviceFilterEnabled: filters.device ? 1 : 0, + deviceFilter: filters.device ?? "", }, format: "JSONEachRow", }), @@ -1275,55 +1718,147 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo clByDay.set(key, Number(row.cl)); visitorByDay.set(key, Number(row.visitors)); } + const hourlyEventRows: { hour: string, pv: number, active_users: number, visitors: number }[] = await hourlyEventResult.json(); + const pageViewsByHour = new Map(); + const activeUsersByHour = new Map(); + const visitorsByHour = new Map(); + for (const row of hourlyEventRows) { + const key = new Date(row.hour).toISOString().slice(0, 13); + pageViewsByHour.set(key, Number(row.pv)); + activeUsersByHour.set(key, Number(row.active_users)); + visitorsByHour.set(key, Number(row.visitors)); + } + const hourlyPageViews: DataPoints = []; + const hourlyActiveUsers: DataPoints = []; + const hourlyVisitors: DataPoints = []; + const latestHour = new Date(now); + latestHour.setUTCMinutes(0, 0, 0); + for (let i = 23; i >= 0; i--) { + const hour = new Date(latestHour.getTime() - i * 60 * 60 * 1000); + const key = hour.toISOString().slice(0, 13); + const date = `${key}:00:00.000Z`; + hourlyPageViews.push({ date, activity: pageViewsByHour.get(key) ?? 0 }); + hourlyActiveUsers.push({ date, activity: activeUsersByHour.get(key) ?? 0 }); + hourlyVisitors.push({ date, activity: visitorsByHour.get(key) ?? 0 }); + } const totalVisitorRows: { visitors: number }[] = await totalVisitorResult.json(); const visitors = Number(totalVisitorRows[0]?.visitors ?? 0); + const sessionRows: { day: string, sessions: string | number, bounced: string | number, avg_duration_s: string | number | null }[] = await sessionResult.json(); + const sessionsByDay = new Map(); + for (const row of sessionRows) { + const key = row.day.split('T')[0]; + sessionsByDay.set(key, { + sessions: Number(row.sessions), + bounced: Number(row.bounced), + avg_duration_s: Number(row.avg_duration_s ?? 0), + }); + } + const dailyPageViews: DataPoints = []; const dailyClicks: DataPoints = []; const dailyVisitors: DataPoints = []; + const dailyBounceRate: DataPoints = []; + const dailyAvgSession: DataPoints = []; + let totalSessions = 0; + let totalBounced = 0; + let totalDurationWeighted = 0; for (let i = 0; i <= METRICS_WINDOW_DAYS; i++) { const day = new Date(since.getTime() + i * ONE_DAY_MS); const key = day.toISOString().split('T')[0]; dailyPageViews.push({ date: key, activity: pvByDay.get(key) ?? 0 }); dailyClicks.push({ date: key, activity: clByDay.get(key) ?? 0 }); dailyVisitors.push({ date: key, activity: visitorByDay.get(key) ?? 0 }); + const s = sessionsByDay.get(key); + const sessions = s?.sessions ?? 0; + const bounced = s?.bounced ?? 0; + const avgDuration = s?.avg_duration_s ?? 0; + dailyBounceRate.push({ date: key, activity: sessions > 0 ? Number(((bounced / sessions) * 100).toFixed(1)) : 0 }); + dailyAvgSession.push({ date: key, activity: Math.round(avgDuration) }); + totalSessions += sessions; + totalBounced += bounced; + totalDurationWeighted += avgDuration * sessions; } + // Weighted (not arithmetic mean of dailies) so a high-traffic day counts + // more than a 1-session day at 100% bounce. + const bounceRate = totalSessions > 0 ? Number(((totalBounced / totalSessions) * 100).toFixed(1)) : 0; + const avgSessionSeconds = totalSessions > 0 ? Number((totalDurationWeighted / totalSessions).toFixed(1)) : 0; const referrers: { referrer: string | null, visitors: number }[] = await referrerResult.json(); - const topRegionRows: { country_code: string | null, region_code: string | null, visitors: number }[] = await topRegionResult.json(); + const topRegionRows: { country_code: string, visitors: number }[] = await topRegionResult.json(); const onlineRows: { online: number }[] = await onlineResult.json(); + const userAgentRows: { dimension: string, name: string, visitors: number | string }[] = await userAgentResult.json(); + const browserCounts = new Map(); + const osCounts = new Map(); + const deviceCounts = new Map(); + for (const row of userAgentRows) { + const visitors = Number(row.visitors); + if (!Number.isFinite(visitors) || visitors <= 0) continue; + if (row.dimension === "browser") { + browserCounts.set(row.name, visitors); + } else if (row.dimension === "os") { + osCounts.set(row.name, visitors); + } else if (row.dimension === "device") { + deviceCounts.set(row.name, visitors); + } + } + const toSortedTop = (m: Map, limit: number) => + Array.from(m.entries()) + .map(([name, visitors]) => ({ name, visitors })) + .sort((a, b) => b.visitors - a.visitors) + .slice(0, limit); + const topBrowsers = toSortedTop(browserCounts, 10); + const topOperatingSystems = toSortedTop(osCounts, 10); + const topDevices = toSortedTop(deviceCounts, 3); + const topRegions = topRegionRows + .map((row) => ({ country_code: row.country_code, count: Number(row.visitors) })) + .filter((row) => row.country_code !== "" && row.count > 0); + clickhouseAggregates = { dailyPageViews, dailyClicks, dailyVisitors, + hourlyPageViews, + hourlyActiveUsers, + hourlyVisitors, + dailyBounceRate, + dailyAvgSession, visitors, onlineLive: Number(onlineRows[0]?.online ?? 0), + bounceRate, + avgSessionSeconds, topReferrers: referrers.map((row) => ({ referrer: row.referrer ?? '(direct)', visitors: Number(row.visitors), })), topRegion: topRegionRows[0] ? { country_code: topRegionRows[0].country_code, - region_code: topRegionRows[0].region_code, + region_code: null, count: Number(topRegionRows[0].visitors), } : null, + topRegions, + topBrowsers, + topOperatingSystems, + topDevices, }; } catch (error) { - // Only swallow real ClickHouse errors — that's the "analytics not enabled - // for this project" path. Anything else is a real bug and should propagate. - if (!(error instanceof ClickHouseError)) { - throw error; - } - captureError("internal-metrics-analytics-overview-clickhouse-fallback", new HexclaveAssertionError( - "Falling back to empty analytics overview due to ClickHouse query failure.", + // The analytics app is installed, so a ClickHouse failure here is a real + // error — capture with context, then propagate so the dashboard shows its + // error state instead of silently rendering an empty overview. + captureError("internal-metrics-analytics-overview-clickhouse", new HexclaveAssertionError( + "Analytics overview ClickHouse queries failed for a project with the analytics app installed.", { cause: error, projectId: tenancy.project.id, branchId: tenancy.branchId, }, )); - // Leave clickhouseAggregates as null — handled in the response builder below. + // Rethrowing skips the `await replayPromise` below, so observe it here to + // keep a concurrent Postgres failure from becoming an unhandled rejection. + // (anonymousVisitorsPromise swallows its own failures and never rejects.) + replayPromise.catch(() => {}); + throw error; } // Postgres-backed session replay query has its own error surface — let it @@ -1340,6 +1875,9 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo daily_page_views: [] as DataPoints, daily_clicks: [] as DataPoints, daily_visitors: [] as DataPoints, + hourly_page_views: [] as DataPoints, + hourly_active_users: [] as DataPoints, + hourly_visitors: [] as DataPoints, daily_anonymous_visitors_fallback: anonymousVisitorsResult.dailyVisitors, daily_revenue: [] as Array<{ date: string, new_cents: number, refund_cents: number }>, total_revenue_cents: replayResult.totalRevenueCents, @@ -1348,10 +1886,17 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo visitors: 0, anonymous_visitors_fallback: anonymousVisitorsResult.visitors, avg_session_seconds: replayResult.avgSessionSeconds, + bounce_rate: 0, + daily_bounce_rate: [] as DataPoints, + daily_avg_session_seconds: [] as DataPoints, online_live: 0, revenue_per_visitor: 0, top_referrers: [], top_region: null, + top_regions: [], + top_browsers: [], + top_operating_systems: [], + top_devices: [], }; } @@ -1366,6 +1911,9 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo daily_page_views: clickhouseAggregates.dailyPageViews, daily_clicks: clickhouseAggregates.dailyClicks, daily_visitors: clickhouseAggregates.dailyVisitors, + hourly_page_views: clickhouseAggregates.hourlyPageViews, + hourly_active_users: clickhouseAggregates.hourlyActiveUsers, + hourly_visitors: clickhouseAggregates.hourlyVisitors, daily_anonymous_visitors_fallback: anonymousVisitorsResult.dailyVisitors, daily_revenue: [] as Array<{ date: string, new_cents: number, refund_cents: number }>, total_revenue_cents: replayResult.totalRevenueCents, @@ -1373,13 +1921,20 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo recent_replays: replayResult.recent, visitors: clickhouseAggregates.visitors, anonymous_visitors_fallback: anonymousVisitorsResult.visitors, - avg_session_seconds: replayResult.avgSessionSeconds, + avg_session_seconds: clickhouseAggregates.avgSessionSeconds, + bounce_rate: clickhouseAggregates.bounceRate, + daily_bounce_rate: clickhouseAggregates.dailyBounceRate, + daily_avg_session_seconds: clickhouseAggregates.dailyAvgSession, online_live: clickhouseAggregates.onlineLive, revenue_per_visitor: effectiveVisitors > 0 ? Number(((replayResult.totalRevenueCents / 100) / effectiveVisitors).toFixed(2)) : 0, top_referrers: clickhouseAggregates.topReferrers, top_region: clickhouseAggregates.topRegion, + top_regions: clickhouseAggregates.topRegions, + top_browsers: clickhouseAggregates.topBrowsers, + top_operating_systems: clickhouseAggregates.topOperatingSystems, + top_devices: clickhouseAggregates.topDevices, }; } @@ -1471,6 +2026,7 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now const RECENT_LIST_PAGE_SIZE = 100; const TOP_REFERRERS_PAGE_SIZE = 100; +const TOP_REGIONS_PAGE_SIZE = 100; export const GET = createSmartRouteHandler({ metadata: { @@ -1483,6 +2039,15 @@ export const GET = createSmartRouteHandler({ }), query: yupObject({ include_anonymous: yupString().oneOf(["true", "false"]).optional(), + filter_country_code: yupString().optional(), + filter_referrer: yupString().optional(), + filter_browser: yupString().optional(), + filter_os: yupString().optional(), + filter_device: yupString().optional(), + // ISO 8601 datetimes bounding the analytics top-N breakdowns (referrers, + // regions, browsers/OS/devices); clamped to the analytics window. + filter_since: yupString().optional(), + filter_until: yupString().optional(), }), }), response: yupObject({ @@ -1493,6 +2058,8 @@ export const GET = createSmartRouteHandler({ live_users: yupNumber().integer().defined(), daily_users: DataPointsSchema, daily_active_users: DataPointsSchema, + hourly_users: DataPointsSchema, + hourly_active_users: DataPointsSchema, users_by_country: yupRecord(yupString().defined(), yupNumber().defined()).defined(), active_users_by_country: MetricsActiveUsersByCountrySchema, // recently_registered/active are CRUD User objects passed through from @@ -1511,10 +2078,21 @@ export const GET = createSmartRouteHandler({ handler: async (req) => { const now = new Date(); const includeAnonymous = req.query.include_anonymous === "true"; + const analyticsFilters = normalizeAnalyticsOverviewFilters({ + country_code: req.query.filter_country_code || undefined, + referrer: req.query.filter_referrer || undefined, + browser: req.query.filter_browser || undefined, + os: req.query.filter_os || undefined, + device: req.query.filter_device || undefined, + since: req.query.filter_since || undefined, + until: req.query.filter_until || undefined, + }); const [ dailyUsers, dailyActiveUsers, + hourlyUsers, + hourlyActiveUsers, usersByCountry, activeUsersByCountry, liveUsers, @@ -1529,6 +2107,8 @@ export const GET = createSmartRouteHandler({ ] = await Promise.all([ loadTotalUsers(req.auth.tenancy, now, includeAnonymous), loadDailyActiveUsers(req.auth.tenancy, now, includeAnonymous), + loadHourlyUsers(req.auth.tenancy, now, includeAnonymous), + loadHourlyActiveUsers(req.auth.tenancy, now, includeAnonymous), loadUsersByCountry(req.auth.tenancy, now, includeAnonymous), loadActiveUsersByCountry(req.auth.tenancy, now, includeAnonymous), loadLiveUsersCount(req.auth.tenancy, now, includeAnonymous), @@ -1549,7 +2129,7 @@ export const GET = createSmartRouteHandler({ loadAuthOverview(req.auth.tenancy, includeAnonymous, now), loadPaymentsOverview(req.auth.tenancy, now), loadEmailOverview(req.auth.tenancy, now), - loadAnalyticsOverview(req.auth.tenancy, now, includeAnonymous), + loadAnalyticsOverview(req.auth.tenancy, now, includeAnonymous, analyticsFilters), loadDailyRevenue(req.auth.tenancy, now), ] as const); @@ -1567,6 +2147,8 @@ export const GET = createSmartRouteHandler({ live_users: liveUsers, daily_users: dailyUsers, daily_active_users: dailyActiveUsers, + hourly_users: hourlyUsers, + hourly_active_users: hourlyActiveUsers, users_by_country: usersByCountry, active_users_by_country: activeUsersByCountry, recently_registered: recentlyRegistered, diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 0b6eb845e..3a77f0016 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -90,6 +90,7 @@ "jose": "^6.1.3", "libsodium-wrappers": "^0.8.2", "lodash": "^4.17.21", + "motion": "^12.39.0", "next": "16.1.7", "next-themes": "^0.2.1", "posthog-js": "^1.336.1", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.test.ts new file mode 100644 index 000000000..d9f975736 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { toggleAnalyticsChartMetricMode } from "./analytics-chart-mode"; + +describe("toggleAnalyticsChartMetricMode", () => { + it("clears the active metric when it is selected again", () => { + expect(toggleAnalyticsChartMetricMode("dau", "dau")).toBe("default"); + }); + + it("selects the requested metric when another metric or the overview is active", () => { + expect(toggleAnalyticsChartMetricMode("default", "visitors")).toBe("visitors"); + expect(toggleAnalyticsChartMetricMode("revenue", "dau")).toBe("dau"); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.ts new file mode 100644 index 000000000..795fad659 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.ts @@ -0,0 +1,12 @@ +export type AnalyticsChartMode = "default" | "dau" | "visitors" | "revenue"; +export type AnalyticsChartMetricMode = Exclude; + +export const ANALYTICS_CHART_METRIC_MODE_ORDER: readonly AnalyticsChartMetricMode[] = [ + "dau", + "visitors", + "revenue", +]; + +export function toggleAnalyticsChartMetricMode(currentMode: AnalyticsChartMode, metricMode: AnalyticsChartMetricMode): AnalyticsChartMode { + return currentMode === metricMode ? "default" : metricMode; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/animation-utils.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/animation-utils.ts new file mode 100644 index 000000000..0c4828d44 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/animation-utils.ts @@ -0,0 +1,7 @@ +export function easeOutCubic(progress: number): number { + return 1 - Math.pow(1 - progress, 3); +} + +export function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx index d0f37728c..1518b5b0a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx @@ -17,10 +17,10 @@ function captureGlobeErrorOnce(error: Error) { captureError("metrics-globe-error-boundary", error); } -export function GlobeSectionWithData({ includeAnonymous }: { includeAnonymous: boolean }) { +export function GlobeSectionWithData({ includeAnonymous, interactive }: { includeAnonymous: boolean, interactive?: boolean }) { return ( - + ); } @@ -30,7 +30,7 @@ function GlobeErrorComponent(props: { error: Error }) { return
Error initializing globe visualization. Please try updating your browser or enabling WebGL.
; } -function GlobeSectionWithMetrics({ includeAnonymous }: { includeAnonymous: boolean }) { +function GlobeSectionWithMetrics({ includeAnonymous, interactive }: { includeAnonymous: boolean, interactive?: boolean }) { const adminApp = useAdminApp(); const data = (adminApp as any)[hexclaveAppInternalsSymbol].useMetrics(includeAnonymous); @@ -41,6 +41,7 @@ function GlobeSectionWithMetrics({ includeAnonymous }: { includeAnonymous: boole countryData={data.users_by_country} totalUsers={data.total_users} activeUsersByCountry={data.active_users_by_country ?? {}} + interactive={interactive} /> ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx index 85d0c3b0e..90ff20932 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx @@ -367,7 +367,7 @@ type SatelliteHandle = { lastCountryCheckAt: number, }; -export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, children }: {countryData: Record, totalUsers: number, activeUsersByCountry?: Record, satelliteCount?: number, children?: React.ReactNode}) { +export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record, totalUsers: number, activeUsersByCountry?: Record, satelliteCount?: number, interactive?: boolean, children?: React.ReactNode}) { const hasWaitedForIdle = useWaitForIdle(1000, 5000); if (!hasWaitedForIdle) { return ; @@ -379,6 +379,7 @@ export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, sa totalUsers={totalUsers} activeUsersByCountry={activeUsersByCountry ?? {}} satelliteCount={satelliteCount ?? 2} + interactive={interactive ?? false} /> ); @@ -473,7 +474,7 @@ function GlobeLoading(props: { devReason: string, className?: string }) { ); } -function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, children }: {countryData: Record, totalUsers: number, activeUsersByCountry: Record, satelliteCount: number, children?: React.ReactNode}) { +function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record, totalUsers: number, activeUsersByCountry: Record, satelliteCount: number, interactive: boolean, children?: React.ReactNode}) { const countries = use(countriesPromise); const projectId = useProjectId(); const globeRef = useRef(undefined); @@ -686,15 +687,24 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate if (!globeRef.current || !shouldShowGlobe) return; const controls = globeRef.current.controls(); - controls.maxDistance = cameraDistance; - controls.minDistance = cameraDistance; + if (interactive) { + controls.enableZoom = true; + controls.minDistance = cameraDistance; + // Large containers can push cameraDistance past 600; keep min <= max so + // OrbitControls doesn't end up with an inverted zoom range. + controls.maxDistance = Math.max(600, cameraDistance); + } else { + controls.enableZoom = false; + controls.maxDistance = cameraDistance; + controls.minDistance = cameraDistance; + } globeRef.current.camera().position.z = cameraDistance; // Update border size and trigger re-render when size changes const visualDiameter = calculateGlobeVisualDiameter(globeRef); setBorderSizeFromGlobe(visualDiameter); resumeRender(); - }, [cameraDistance, shouldShowGlobe, globeSize]); + }, [cameraDistance, shouldShowGlobe, globeSize, interactive]); const totalUsersInCountries = Object.values(countryData).reduce((acc, curr) => acc + curr, 0); @@ -1058,7 +1068,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate }, []); return ( -
+
+
{/* Border container - same approach as globe */}
{/* Inner square div - contain behavior (square, fills either width or height) */} @@ -1160,10 +1170,15 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate const controls = current.controls(); controls.autoRotate = false; controls.autoRotateSpeed = 0.5; - controls.maxDistance = cameraDistance; - controls.minDistance = cameraDistance; + if (interactive) { + controls.minDistance = cameraDistance; + controls.maxDistance = 600; + } else { + controls.maxDistance = cameraDistance; + controls.minDistance = cameraDistance; + } controls.dampingFactor = 0.15; - controls.enableZoom = false; + controls.enableZoom = interactive; controls.enableRotate = true; current.camera().position.z = cameraDistance; // Little Saint James Island, U.S. Virgin Islands diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index 45c4253ea..3e408c802 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -14,6 +14,7 @@ import { import { useRouter } from "@/components/router"; import { cn, + SimpleTooltip, Typography } from "@/components/ui"; import { Calendar } from "@/components/ui/calendar"; @@ -21,7 +22,7 @@ import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from " import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; import { UserAvatar } from '@hexclave/next'; import { fromNow, isWeekend } from '@hexclave/shared/dist/utils/dates'; -import { useId, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import { Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts"; export type CustomDateRange = { @@ -29,7 +30,7 @@ export type CustomDateRange = { to: Date, }; -export type TimeRange = '7d' | '30d' | 'all' | 'custom'; +export type TimeRange = '1d' | '7d' | '30d' | 'all' | 'custom'; export type LineChartDisplayConfig = { name: string, @@ -114,6 +115,19 @@ function parseChartDate(dateValue: string): Date { return parsed; } +function formatChartXAxisTick(value: string): string { + let date: Date; + try { + date = parseChartDate(value); + } catch { + return value; + } + if (value.includes("T")) { + return date.toLocaleTimeString("en-US", { hour: "numeric" }); + } + return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`; +} + function formatDateRangeLabel(range: CustomDateRange | null): string { if (range == null) { return "Pick date range"; @@ -133,6 +147,9 @@ function filterPointsByTimeRange( if (timeRange === '7d') { return datapoints.slice(-7); } + if (timeRange === '1d') { + return datapoints.slice(-1); + } if (timeRange === '30d') { return datapoints.slice(-30); } @@ -238,6 +255,7 @@ export function ActivityBarChart({ }) { const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); + const chartMotion = useChartMotionProps(); return ( {datapoints.map((entry, index) => { const isWeekendDay = isWeekend(parseChartDate(entry.date)); @@ -311,17 +329,7 @@ export function ActivityBarChart({ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10, }} - tickFormatter={(value) => { - const date = parseChartDate(value); - if (!isNaN(date.getTime())) { - const month = date.toLocaleDateString("en-US", { - month: "short", - }); - const day = date.getDate(); - return `${month} ${day}`; - } - return value; - }} + tickFormatter={(value) => formatChartXAxisTick(value)} /> @@ -427,6 +435,7 @@ export function StackedBarChartDisplay({ }) { const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); + const chartMotion = useChartMotionProps(); const windowSize = Math.max(4, Math.round(datapoints.length / 2.5)); const totals = datapoints.map(p => p.new + p.retained + p.reactivated); @@ -480,7 +489,7 @@ export function StackedBarChartDisplay({ allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} /> - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -494,7 +503,7 @@ export function StackedBarChartDisplay({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -508,7 +517,7 @@ export function StackedBarChartDisplay({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -531,7 +540,7 @@ export function StackedBarChartDisplay({ strokeDasharray="2.5 3.5" dot={false} activeDot={false} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -544,7 +553,7 @@ export function StackedBarChartDisplay({ strokeDasharray="2.5 3.5" dot={false} activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -576,13 +585,7 @@ export function StackedBarChartDisplay({ axisLine={false} interval={datapoints.length <= 7 ? 0 : "equidistantPreserveStart"} tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }} - tickFormatter={(value) => { - const date = parseChartDate(value); - if (!isNaN(date.getTime())) { - return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`; - } - return value; - }} + tickFormatter={(value) => formatChartXAxisTick(value)} /> @@ -595,12 +598,60 @@ export type ComposedDataPoint = { date: string, new_cents: number, refund_cents: number, + page_views: number, visitors: number, dau: number, + _showPageViews?: boolean, _showVisitors?: boolean, _showRevenue?: boolean, }; +const OVERVIEW_CHART_ANIMATION_MS = 520; + +type ChartMotionProps = { + isAnimationActive: boolean, + animationBegin: number, + animationDuration: number, + animationEasing: "ease-out", +}; + +const enabledChartMotion: ChartMotionProps = { + isAnimationActive: true, + animationBegin: 0, + animationDuration: OVERVIEW_CHART_ANIMATION_MS, + animationEasing: "ease-out", +}; + +const disabledChartMotion: ChartMotionProps = { + isAnimationActive: false, + animationBegin: 0, + animationDuration: 0, + animationEasing: "ease-out", +}; + +function usePrefersReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + if (typeof window.matchMedia !== "function") { + return; + } + + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches); + + updatePrefersReducedMotion(); + mediaQuery.addEventListener("change", updatePrefersReducedMotion); + return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion); + }, []); + + return prefersReducedMotion; +} + +function useChartMotionProps(): ChartMotionProps { + return usePrefersReducedMotion() ? disabledChartMotion : enabledChartMotion; +} + export type VisitorsHoverDataPoint = { date: string, page_views: number, @@ -634,6 +685,10 @@ const composedChartConfig: ChartConfig = { label: "Unique Visitors", theme: { light: "hsl(210, 84%, 64%)", dark: "hsl(210, 84%, 72%)" }, }, + page_views: { + label: "Page Views", + theme: { light: "hsl(189, 84%, 54%)", dark: "hsl(189, 84%, 68%)" }, + }, revenue: { label: "Revenue", theme: { light: "hsl(268, 82%, 66%)", dark: "hsl(268, 82%, 74%)" }, @@ -652,6 +707,7 @@ function ComposedTooltip({ active, payload }: TooltipProps) { : row.date; const visitorsEnabled = row._showVisitors !== false; + const pageViewsEnabled = row._showPageViews !== false; const revenueEnabled = row._showRevenue !== false; const revenueDollars = (row.new_cents / 100); const revenuePerVisitor = visitorsEnabled && revenueEnabled && row.visitors > 0 ? (revenueDollars / row.visitors) : null; @@ -683,6 +739,16 @@ function ComposedTooltip({ active, payload }: TooltipProps) {
+
+
+ + Page views +
+ + {pageViewsEnabled ? row.page_views.toLocaleString() : "—"} + +
+
@@ -724,12 +790,14 @@ function HighlightedLineDot({ cx, cy, fill }: HighlightDotProps) { export function ComposedAnalyticsChart({ datapoints, showVisitors = true, + showPageViews = true, showRevenue = true, height, compact = false, }: { datapoints: ComposedDataPoint[], showVisitors?: boolean, + showPageViews?: boolean, showRevenue?: boolean, height?: number, compact?: boolean, @@ -737,11 +805,12 @@ export function ComposedAnalyticsChart({ const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); const [hoveredX, setHoveredX] = useState(null); + const chartMotion = useChartMotionProps(); const taggedDatapoints = useMemo( - () => datapoints.map(d => ({ ...d, _showVisitors: showVisitors, _showRevenue: showRevenue })), - [datapoints, showVisitors, showRevenue], + () => datapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })), + [datapoints, showPageViews, showVisitors, showRevenue], ); - const maxVisitors = Math.max(...datapoints.map(d => Math.max(showVisitors ? d.visitors : 0, d.dau)), 1); + const maxVisitors = Math.max(...datapoints.map(d => Math.max(showPageViews ? d.page_views : 0, showVisitors ? d.visitors : 0, d.dau)), 1); const maxRevenueCents = Math.max(...datapoints.map(d => showRevenue ? d.new_cents : 0), 1); const visitorTicks = niceAxisTicks(Math.ceil(maxVisitors * 1.1), 5); const revenueTicks = niceAxisTicks(Math.ceil(maxRevenueCents * 1.15), 5); @@ -778,6 +847,9 @@ export function ComposedAnalyticsChart({ + + + @@ -801,6 +873,26 @@ export function ComposedAnalyticsChart({ allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} /> + + {showPageViews && hoveredIndex != null && hoveredX != null && ( + + )} : false} - isAnimationActive={false} + {...chartMotion} /> {showVisitors && hoveredIndex != null && hoveredX != null && ( } - isAnimationActive={false} + {...chartMotion} /> {hoveredIndex != null && hoveredX != null && ( : false} - isAnimationActive={false} + {...chartMotion} /> {showRevenue && hoveredIndex != null && hoveredX != null && ( { - const date = parseChartDate(value); - if (!isNaN(date.getTime())) { - return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`; - } - return value; - }} + tickFormatter={(value) => formatChartXAxisTick(value)} /> @@ -987,6 +1073,7 @@ export function TimeRangeToggle({ const customDateRangeHandler = onCustomDateRangeChange; const options: { id: TimeRange, label: string }[] = [ + { id: '1d', label: '1d' }, { id: '7d', label: '7d' }, { id: '30d', label: '30d' }, { id: 'all', label: 'All' }, @@ -1010,6 +1097,7 @@ export function TimeRangeToggle({ glassmorphic={false} onSelect={(selectedId) => { if ( + selectedId === '1d' || selectedId === '7d' || selectedId === '30d' || selectedId === 'all' || @@ -1182,6 +1270,8 @@ export function TabbedMetricsCard({ totalAllTime, showTotal = false, stackedLegendItems, + chartDataIsPreFiltered = false, + headerTooltip, }: { config: LineChartDisplayConfig, chartData: DataPoint[], @@ -1198,11 +1288,15 @@ export function TabbedMetricsCard({ totalAllTime?: number, showTotal?: boolean, stackedLegendItems?: Array<{ key: string, label: string, color: string }>, + chartDataIsPreFiltered?: boolean, + headerTooltip?: string, }) { const [view, setView] = useState<'chart' | 'list'>('chart'); - const filteredDatapoints = filterDatapointsByTimeRange(chartData, timeRange, customDateRange); - const filteredStackedDatapoints = stackedChartData ? filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange) : null; + const filteredDatapoints = chartDataIsPreFiltered ? chartData : filterDatapointsByTimeRange(chartData, timeRange, customDateRange); + const filteredStackedDatapoints = stackedChartData + ? (chartDataIsPreFiltered ? stackedChartData : filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange)) + : null; // Calculate total for the selected time range const total = filteredDatapoints.reduce((sum, point) => sum + point.activity, 0); @@ -1255,6 +1349,9 @@ export function TabbedMetricsCard({ gradient={tabsGradient} className="flex-1 min-w-0 border-0 [&>button]:rounded-none [&>button]:px-3 [&>button]:py-3.5 [&>button]:text-xs" /> + {headerTooltip && ( + + )} {view === 'chart' && showTotal && ( @@ -1791,6 +1888,7 @@ export function CorrelationCard({ const chartConfig: ChartConfig = Object.fromEntries( series.map(s => [s.key, { label: s.label, color: s.color }]) ); + const chartMotion = useChartMotionProps(); return ( @@ -1833,11 +1931,7 @@ export function CorrelationCard({ tickMargin={6} interval="equidistantPreserveStart" tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }} - tickFormatter={(value) => { - const date = parseChartDate(value); - if (isNaN(date.getTime())) return value; - return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - }} + tickFormatter={(value) => formatChartXAxisTick(value)} /> ))} @@ -1982,9 +2076,11 @@ export function DonutChartDisplay({
- - Auth Methods - + + + Auth Methods + + {!compact && (
Login distribution @@ -2092,6 +2188,7 @@ export function EmailStackedBarChartDisplay({ }) { const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); + const chartMotion = useChartMotionProps(); const windowSize = Math.max(4, Math.round(datapoints.length / 2.5)); const totals = datapoints.map(p => p.ok + p.error + p.in_progress); @@ -2154,7 +2251,7 @@ export function EmailStackedBarChartDisplay({ }; const colorVar = dataKey === "ok" ? "ok" : dataKey === "in_progress" ? "in_progress" : "error"; return ( - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -2182,7 +2279,7 @@ export function EmailStackedBarChartDisplay({ strokeDasharray="2.5 3.5" dot={false} activeDot={false} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -2195,7 +2292,7 @@ export function EmailStackedBarChartDisplay({ strokeDasharray="2.5 3.5" dot={false} activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -2318,6 +2415,7 @@ export function VisitorsHoverChart({ compact?: boolean, }) { const [hoveredIndex, setHoveredIndex] = useState(null); + const chartMotion = useChartMotionProps(); const windowSize = Math.max(4, Math.round(datapoints.length / 2.5)); const totals = datapoints.map((p) => p.page_views); const avgValues = rollingAvg(totals, windowSize); @@ -2363,7 +2461,7 @@ export function VisitorsHoverChart({ allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} /> - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -2386,7 +2484,7 @@ export function VisitorsHoverChart({ strokeDasharray="2.5 3.5" dot={false} activeDot={false} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -2399,7 +2497,7 @@ export function VisitorsHoverChart({ strokeDasharray="2.5 3.5" dot={false} activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -2528,6 +2626,7 @@ export function RevenueHoverChart({ compact?: boolean, }) { const [hoveredIndex, setHoveredIndex] = useState(null); + const chartMotion = useChartMotionProps(); const windowSize = Math.max(4, Math.round(datapoints.length / 2.5)); const totals = datapoints.map((p) => p.new_cents + p.refund_cents); const avgValues = rollingAvg(totals, windowSize); @@ -2578,7 +2677,7 @@ export function RevenueHoverChart({ allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} /> - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -2592,7 +2691,7 @@ export function RevenueHoverChart({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -2606,7 +2705,7 @@ export function RevenueHoverChart({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -2629,7 +2728,7 @@ export function RevenueHoverChart({ strokeDasharray="2.5 3.5" dot={false} activeDot={false} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -2642,7 +2741,7 @@ export function RevenueHoverChart({ strokeDasharray="2.5 3.5" dot={false} activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx index ca0766417..7b2153abf 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx @@ -1,27 +1,54 @@ 'use client'; import { AppIcon } from "@/components/app-square"; -import { DesignAnalyticsCard, DesignCategoryTabs, DesignChartLegend, useInfiniteListWindow } from "@/components/design-components"; +import { DesignAnalyticsCard, DesignCategoryTabs, DesignChartLegend, DesignPillToggle, useInfiniteListWindow } from "@/components/design-components"; import { Link } from "@/components/link"; import { useRouter } from "@/components/router"; -import { cn, Typography } from "@/components/ui"; +import { cn, SimpleTooltip, Typography } from "@/components/ui"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { ALL_APPS_FRONTEND, type AppId, getAppPath } from "@/lib/apps-frontend"; import { getEnabledAppIds } from "@/lib/apps-utils"; import { + type AnalyticsOverviewFilters, type MetricsEmailOverview, type MetricsRecentEmail, - type MetricsTopReferrer, useMetricsOrThrow, } from "@/lib/hexclave-app-internals"; -import { CompassIcon, EnvelopeIcon, EnvelopeOpenIcon, GlobeIcon, SquaresFourIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; +import { + ChartLineIcon, + CompassIcon, + DesktopIcon, + DeviceMobileIcon, + DeviceTabletIcon, + EnvelopeIcon, + EnvelopeOpenIcon, + FunnelIcon, + GearIcon, + GlobeIcon, + MonitorIcon, + SquaresFourIcon, + WarningCircleIcon, + XCircleIcon, + XIcon, +} from "@phosphor-icons/react"; import useResizeObserver from '@react-hook/resize-observer'; import { useUser } from "@hexclave/next"; import { ALL_APPS } from "@hexclave/shared/dist/apps/apps-config"; import { stringCompare } from "@hexclave/shared/dist/utils/strings"; +import { LayoutGroup, motion, useReducedMotion, type Transition } from "motion/react"; import { ErrorBoundary } from "next/dist/client/components/error-boundary"; -import { type ElementType, Suspense, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { type ElementType, type ReactNode, Suspense, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { AnalyticsEventLimitBanner } from "../analytics/shared"; import { PageLayout } from "../page-layout"; import { useAdminApp, useProjectId } from "../use-admin-app"; +import { UserPageMetricCard } from "../users/[userId]/user-page-metric-card"; import { GlobeSectionWithData } from "./globe-section-with-data"; import { ComposedAnalyticsChart, @@ -43,6 +70,13 @@ import { VisitorsHoverDataPoint } from "./line-chart"; import { MetricsErrorFallback, MetricsLoadingFallback } from "./metrics-loading"; +import { ReferrersWithAnalyticsCard, TopNamedListCard, TopRegionsCard } from "./top-lists"; +import { + ANALYTICS_CHART_METRIC_MODE_ORDER, + toggleAnalyticsChartMetricMode, + type AnalyticsChartMetricMode, + type AnalyticsChartMode, +} from "./analytics-chart-mode"; const dailySignUpsConfig: LineChartDisplayConfig = { name: 'Daily Sign-Ups', @@ -71,6 +105,168 @@ function formatCompact(n: number): string { return n.toLocaleString(); } +function pagesPerVisitor(pageViews: number, visitors: number): number { + return visitors > 0 ? pageViews / visitors : 0; +} + +function formatPagesPerVisitor(value: number): string { + if (!Number.isFinite(value)) return "0.0"; + return value.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }); +} + +const OVERVIEW_HEADER_COMPACT_SCROLL_TOP = 24; +const OVERVIEW_HEADER_MORPH_MS = 520; +const OVERVIEW_HEADER_TITLE_EXIT_MS = 150; +const overviewHeaderLayoutTransition: Transition = { + duration: OVERVIEW_HEADER_MORPH_MS / 1000, + ease: [0.32, 0.72, 0, 1], +}; +const reducedOverviewHeaderLayoutTransition: Transition = { + duration: 0, +}; + +const scrollableOverflowValues = new Set(["auto", "scroll", "overlay"]); + +function findScrollContainer(element: HTMLElement): HTMLElement | null { + let current = element.parentElement; + while (current != null) { + const overflowY = window.getComputedStyle(current).overflowY; + if (scrollableOverflowValues.has(overflowY) && current.scrollHeight > current.clientHeight) { + return current; + } + current = current.parentElement; + } + + return null; +} + +function useOverviewHeaderCompacted(enabled: boolean) { + const sentinelRef = useRef(null); + const [compacted, setCompacted] = useState(false); + + useEffect(() => { + if (!enabled) { + setCompacted(false); + return; + } + + const sentinel = sentinelRef.current; + if (sentinel == null) return; + + const scrollContainer = findScrollContainer(sentinel); + + const observer = new IntersectionObserver((entries) => { + const entry = entries[0]; + const nextCompacted = !entry.isIntersecting; + setCompacted((current) => current === nextCompacted ? current : nextCompacted); + }, { + root: scrollContainer, + rootMargin: `-${OVERVIEW_HEADER_COMPACT_SCROLL_TOP}px 0px 0px 0px`, + threshold: 0, + }); + + observer.observe(sentinel); + + return () => { + observer.disconnect(); + }; + }, [enabled]); + + return { compacted, sentinelRef }; +} + +function useRenderWhileClosing(open: boolean, durationMs: number): boolean { + const [shouldRender, setShouldRender] = useState(open); + + useEffect(() => { + if (open) { + setShouldRender(true); + return; + } + + const timeout = setTimeout(() => setShouldRender(false), durationMs); + return () => clearTimeout(timeout); + }, [durationMs, open]); + + return open || shouldRender; +} + +function useDelayedTrue(value: boolean, delayMs: number): boolean { + const [delayedValue, setDelayedValue] = useState(value); + + useEffect(() => { + if (!value) { + setDelayedValue(false); + return; + } + + const timeout = setTimeout(() => setDelayedValue(true), delayMs); + return () => clearTimeout(timeout); + }, [delayMs, value]); + + return delayedValue; +} + +const BROWSER_SLUGS = new Map([ + ["chrome", "googlechrome"], + ["google chrome", "googlechrome"], + ["firefox", "firefox"], + ["safari", "safari"], + ["edge", "microsoftedge"], + ["microsoft edge", "microsoftedge"], + ["opera", "opera"], + ["samsung internet", "samsung"], + ["brave", "brave"], + ["vivaldi", "vivaldi"], + ["duckduckgo", "duckduckgo"], +]); + +const OS_SLUGS = new Map([ + ["macos", "apple"], + ["ios", "apple"], + ["ipados", "apple"], + ["windows", "windows11"], + ["android", "android"], + ["linux", "linux"], + ["ubuntu", "ubuntu"], + ["chromeos", "googlechrome"], +]); + +function BrandIcon({ slug }: { slug: string | undefined }) { + const [failed, setFailed] = useState(false); + if (!slug || failed) { + return ; + } + return ( + // eslint-disable-next-line @next/next/no-img-element + setFailed(true)} + className="h-3.5 w-3.5 shrink-0 object-contain opacity-90 [filter:invert(0)] dark:[filter:invert(1)_hue-rotate(180deg)]" + /> + ); +} + +function browserIcon(name: string): ReactNode { + return ; +} + +function osIcon(name: string): ReactNode { + return ; +} + +function deviceIcon(name: string): ReactNode { + const key = name.toLowerCase().trim(); + if (key === "mobile") return ; + if (key === "tablet") return ; + return ; +} + function calculatePeriodDelta(currentValue: number, previousValue: number): number | undefined { if (!Number.isFinite(currentValue) || !Number.isFinite(previousValue)) { return undefined; @@ -113,57 +309,465 @@ function SetupAppPrompt({ ); } -type AnalyticsStatPill = { - label: string, - value: string, - delta?: number, -}; +const FILTER_DIMENSIONS: Array = ["country_code", "referrer", "browser", "os", "device"]; -function StatCard({ - stat, - compact = false, +const FILTER_DIMENSION_LABELS = new Map([ + ["country_code", "Country"], + ["referrer", "Referrer"], + ["browser", "Browser"], + ["os", "OS"], + ["device", "Device"], +]); + +function analyticsFiltersKey(filters: AnalyticsOverviewFilters): string { + const params = new URLSearchParams(); + for (const dimension of FILTER_DIMENSIONS) { + const value = filters[dimension]; + if (value != null) { + params.set(dimension, value); + } + } + if (filters.since != null) params.set("since", filters.since); + if (filters.until != null) params.set("until", filters.until); + return params.toString(); +} + +// Matches getDateKey in line-chart.tsx: custom-range picker dates are +// local-midnight Dates, and the daily series keys are "YYYY-MM-DD". +function localDateKey(date: Date): string { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +// Server-side date bounds for the top-N breakdowns (referrers, regions, +// browsers/OS/devices), derived from the chart time range. Quantized to the +// current UTC hour (1d) / UTC day (7d) so the metrics cache key stays stable +// across renders instead of changing every millisecond. +function analyticsDateRangeForTimeRange( + timeRange: TimeRange, + customDateRange: CustomDateRange | null, +): Pick { + switch (timeRange) { + case "1d": { + const latestHour = new Date(); + latestHour.setUTCMinutes(0, 0, 0); + return { since: new Date(latestHour.getTime() - 23 * 60 * 60 * 1000).toISOString() }; + } + case "7d": { + const todayUtc = new Date(); + todayUtc.setUTCHours(0, 0, 0, 0); + return { since: new Date(todayUtc.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString() }; + } + case "30d": + case "all": { + return {}; + } + case "custom": { + if (customDateRange == null) { + return {}; + } + const untilExclusive = new Date(new Date(`${localDateKey(customDateRange.to)}T00:00:00.000Z`).getTime() + 24 * 60 * 60 * 1000); + return { + since: `${localDateKey(customDateRange.from)}T00:00:00.000Z`, + until: untilExclusive.toISOString(), + }; + } + } +} + +function getFilterDimensionLabel(dimension: keyof AnalyticsOverviewFilters): string { + const label = FILTER_DIMENSION_LABELS.get(dimension); + if (label == null) { + throw new Error(`Missing analytics filter dimension label: ${dimension}`); + } + return label; +} + +function hasAnalyticsFilters(filters: AnalyticsOverviewFilters): boolean { + return FILTER_DIMENSIONS.some((dimension) => filters[dimension] != null); +} + +function FilterChipsBar({ + filters, + onClear, + onClearAll, }: { - stat: AnalyticsStatPill, - compact?: boolean, + filters: AnalyticsOverviewFilters, + onClear: (dimension: keyof AnalyticsOverviewFilters) => void, + onClearAll: () => void, }) { + const entries = FILTER_DIMENSIONS.flatMap((dimension) => { + const value = filters[dimension]; + return value != null ? [{ dimension, value }] : []; + }); + if (entries.length === 0) return null; + return ( - -
- - {stat.label} +
+ {entries.map(({ dimension, value }) => ( + + {getFilterDimensionLabel(dimension)}: + {value} + -
- - {stat.value} - - {stat.delta != null && ( - 0 ? "text-emerald-600 dark:text-emerald-400" : stat.delta < 0 ? "text-red-500 dark:text-red-400" : "text-muted-foreground" - )}> - {stat.delta > 0 ? "+" : ""}{stat.delta}% - - )} -
-
- + ))} + {entries.length > 1 && ( + + )} +
); } -type AnalyticsChartMode = 'default' | 'dau' | 'visitors' | 'revenue'; +type FilterOption = { + value: string, + label: string, +}; + +type FilterDimensionConfig = { + key: keyof AnalyticsOverviewFilters, + label: string, + options: FilterOption[], +}; + +function FilterMenuButton({ active }: { active: boolean }) { + return ( + + + + ); +} + +function FilterMenu({ + filters, + onToggle, +}: { + filters: AnalyticsOverviewFilters, + onToggle: (dimension: keyof AnalyticsOverviewFilters, value: string) => void, +}) { + const active = hasAnalyticsFilters(filters); + const [open, setOpen] = useState(false); + return ( + + + + { + onToggle(dimension, value); + setOpen(false); + }} + /> + + + ); +} + +function FilterMenuContent({ + filters, + onSelect, +}: { + filters: AnalyticsOverviewFilters, + onSelect: (dimension: keyof AnalyticsOverviewFilters, value: string) => void, +}) { + const adminApp = useAdminApp(); + // Read unfiltered metrics here so the menu keeps offering the full value set. + // The visible overview preloads filtered data separately before swapping. + const data = useMetricsOrThrow(adminApp, false); + const analytics = data.analytics_overview; + + const dimensions = useMemo(() => [ + { key: "country_code", label: "Country", options: analytics.top_regions.slice(0, 15).map((r) => ({ value: r.country_code.toUpperCase(), label: r.country_code.toUpperCase() })) }, + { key: "referrer", label: "Referrer", options: analytics.top_referrers.slice(0, 15).map((r) => ({ value: r.referrer, label: r.referrer || "(direct)" })) }, + { key: "browser", label: "Browser", options: analytics.top_browsers.slice(0, 15).map((b) => ({ value: b.name, label: b.name })) }, + { key: "os", label: "OS", options: analytics.top_operating_systems.slice(0, 15).map((o) => ({ value: o.name, label: o.name })) }, + { key: "device", label: "Device", options: analytics.top_devices.slice(0, 15).map((d) => ({ value: d.name, label: d.name })) }, + ], [analytics.top_browsers, analytics.top_devices, analytics.top_operating_systems, analytics.top_referrers, analytics.top_regions]); + + const firstAvailableDimension = dimensions.find((dimension) => dimension.options.length > 0)?.key ?? "country_code"; + const [selectedDimension, setSelectedDimension] = useState(firstAvailableDimension); + const selectedConfig = dimensions.find((dimension) => dimension.key === selectedDimension); + if (selectedConfig == null) { + throw new Error(`Missing analytics filter dimension: ${selectedDimension}`); + } + const selectedFilterValue = filters[selectedConfig.key]; + + return ( + + + + Filter analytics by + + +
+
+ {dimensions.map((dimension) => { + const isSelected = dimension.key === selectedDimension; + const activeValue = filters[dimension.key]; + return ( + + ); + })} +
+
+
+
+
{selectedConfig.label}
+ {selectedFilterValue != null && ( +
+ Current: {selectedFilterValue} +
+ )} +
+ {selectedFilterValue != null && ( + + )} +
+
+ {selectedConfig.options.length === 0 ? ( +
+ No values +
+ ) : ( + selectedConfig.options.map((option) => { + const isActive = selectedFilterValue === option.value; + return ( + + ); + }) + )} +
+
+
+
+
+ ); +} + +function ViewToggle({ view, onChange }: { view: "overview" | "globe", onChange: (view: "overview" | "globe") => void }) { + return ( + { + if (id === "overview" || id === "globe") { + onChange(id); + return; + } + throw new Error(`Unsupported project overview view selected: ${id}`); + }} + /> + ); +} + +function OverviewHeaderChrome({ + title, + actions, + compacted, + layoutCompacted, + renderTitle, + layoutTransition, + animateLayout, +}: { + title: string, + actions: ReactNode, + compacted: boolean, + layoutCompacted: boolean, + renderTitle: boolean, + layoutTransition: Transition, + animateLayout: boolean, +}) { + return ( + + +
+
+ {renderTitle && ( +
+ + {title} + +
+ )} + + {actions} + +
+ + ); +} + +function OverviewHeader({ title, actions, sticky }: { title: string, actions: ReactNode, sticky: boolean }) { + const { compacted, sentinelRef } = useOverviewHeaderCompacted(sticky); + const renderTitle = useRenderWhileClosing(!compacted, OVERVIEW_HEADER_TITLE_EXIT_MS); + const shouldReduceMotion = useReducedMotion(); + const delayedCompacted = useDelayedTrue(compacted, shouldReduceMotion ? 0 : OVERVIEW_HEADER_TITLE_EXIT_MS); + const layoutCompacted = sticky && (shouldReduceMotion ? compacted : delayedCompacted); + const layoutTransition = shouldReduceMotion ? reducedOverviewHeaderLayoutTransition : overviewHeaderLayoutTransition; + + return ( + <> + {sticky && ( +
+ )} +
+ + + +
+ + ); +} + +function GlobeView({ includeAnonymous }: { includeAnonymous: boolean }) { + // Fills the height granted by PageLayout's containedHeight mode (the globe + // tab sets it) instead of guessing the chrome height with 100vh math, which + // left a slight page scroll whenever the guess was off. + return ( +
+ +
+ ); +} function AnalyticsInChartPill({ label, value, delta, color, + isHighlighted, isSelected, controlsId, tabId, - onActivate, + onToggle, onHoverPreview, onHoverEnd, onArrowNavigate, @@ -172,26 +776,30 @@ function AnalyticsInChartPill({ value: string, delta?: number, color: string, + isHighlighted: boolean, isSelected: boolean, controlsId: string, tabId: string, - onActivate: () => void, + onToggle: () => void, onHoverPreview: () => void, onHoverEnd: () => void, onArrowNavigate: (direction: 'next' | 'prev' | 'first' | 'last') => void, }) { + const tooltipByLabel = new Map([ + ["Daily Active Users", "Shows active users by day so you can see current product usage."], + ["Visitors", "Sums each day's unique visitors across the selected period, so returning visitors count once per day."], + ["Revenue", "Shows new revenue from payments for the selected period."], + ]); + return ( + updatePrefersReducedMotion(); + mediaQuery.addEventListener("change", updatePrefersReducedMotion); + + return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion); + }, []); + + useEffect(() => { + const toggle = toggleRef.current; + const selectedButton = optionRefs.current.get(selected); + + if (!toggle || !selectedButton) { + setSliderMetrics(null); + return; + } + + const updateSliderMetrics = () => { + setSliderMetrics({ + left: selectedButton.offsetLeft, + width: selectedButton.offsetWidth, + }); + }; + + updateSliderMetrics(); + + if (typeof ResizeObserver === "undefined") return; + const resizeObserver = new ResizeObserver(updateSliderMetrics); + resizeObserver.observe(toggle); + resizeObserver.observe(selectedButton); + + return () => resizeObserver.disconnect(); + }, [options, selected]); + + const body = ( +
+ {sliderMetrics != null && ( +
+ )} + {options.map((option) => { + const isActive = selected === option.id; + const Icon = option.icon; + + const pill = ( + + ); + + if (!showLabels) { + return ( + + + {pill} + + + + {option.label} + + + ); + } - if (!showLabels) { - return ( - - - {pill} - - - - {option.label} - - - - ); - } - - return pill; - })} -
- + return pill; + })} +
); + + // Tooltips require a TooltipProvider in scope. Wrap defensively so callers + // outside an existing provider (e.g. inside a PageLayout actions slot) work. + return showLabels ? body : {body}; } diff --git a/packages/dashboard-ui-components/src/components/tabs.tsx b/packages/dashboard-ui-components/src/components/tabs.tsx index f528abf78..b56c9b0a1 100644 --- a/packages/dashboard-ui-components/src/components/tabs.tsx +++ b/packages/dashboard-ui-components/src/components/tabs.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { cn, Spinner } from "@hexclave/ui"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import { useGlassmorphicDefault } from "./card"; @@ -39,6 +39,13 @@ type GradientClass = { underline: string, }; +type SliderMetrics = { + left: number, + width: number, +}; + +const sliderTransition = "transform 200ms ease-out, width 200ms ease-out"; + const tabSizeClasses = new Map([ ["sm", { button: "px-3 py-2 text-xs", badge: "text-[10px] px-1.5 py-0.5" }], ["md", { button: "px-4 py-3 text-sm", badge: "text-xs px-1.5 py-0.5" }], @@ -119,6 +126,10 @@ export function DesignCategoryTabs({ const sizeClass = getMapValueOrThrow(tabSizeClasses, size, "tabSizeClasses"); const gradientClass = getMapValueOrThrow(gradientClasses, gradient, "gradientClasses"); const [loadingCategoryId, setLoadingCategoryId] = useState(null); + const [sliderMetrics, setSliderMetrics] = useState(null); + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + const tabListRef = useRef(null); + const tabButtonRefs = useRef(new Map()); const handleSelect = (categoryId: string) => { const result = onSelect(categoryId); @@ -130,6 +141,43 @@ export function DesignCategoryTabs({ } }; + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches); + + updatePrefersReducedMotion(); + mediaQuery.addEventListener("change", updatePrefersReducedMotion); + + return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion); + }, []); + + useEffect(() => { + const tabList = tabListRef.current; + const selectedButton = tabButtonRefs.current.get(selectedCategory); + + if (!tabList || !selectedButton) { + setSliderMetrics(null); + return; + } + + const updateSliderMetrics = () => { + setSliderMetrics({ + left: selectedButton.offsetLeft, + width: selectedButton.offsetWidth, + }); + }; + + updateSliderMetrics(); + + if (typeof ResizeObserver === "undefined") return; + const resizeObserver = new ResizeObserver(updateSliderMetrics); + resizeObserver.observe(tabList); + resizeObserver.observe(selectedButton); + + return () => resizeObserver.disconnect(); + }, [categories, selectedCategory]); + return (
+ {glassmorphic && sliderMetrics != null && ( +
+ )} + {!glassmorphic && sliderMetrics != null && ( +
+ )} {categories.map((category) => { const isActive = selectedCategory === category.id; const badgeValue = category.badgeCount ?? category.count; @@ -154,18 +226,22 @@ export function DesignCategoryTabs({ return ( ); })} diff --git a/packages/shared/src/interface/admin-interface.ts b/packages/shared/src/interface/admin-interface.ts index bc34332cf..f0c3dd4cc 100644 --- a/packages/shared/src/interface/admin-interface.ts +++ b/packages/shared/src/interface/admin-interface.ts @@ -361,11 +361,29 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { ); } - async getMetrics(includeAnonymous: boolean = false): Promise { + async getMetrics( + includeAnonymous: boolean = false, + filters?: { + country_code?: string, + referrer?: string, + browser?: string, + os?: string, + device?: string, + since?: string, + until?: string, + }, + ): Promise { const params = new URLSearchParams(); if (includeAnonymous) { params.append('include_anonymous', 'true'); } + if (filters?.country_code) params.append('filter_country_code', filters.country_code); + if (filters?.referrer) params.append('filter_referrer', filters.referrer); + if (filters?.browser) params.append('filter_browser', filters.browser); + if (filters?.os) params.append('filter_os', filters.os); + if (filters?.device) params.append('filter_device', filters.device); + if (filters?.since) params.append('filter_since', filters.since); + if (filters?.until) params.append('filter_until', filters.until); const queryString = params.toString(); const response = await this.sendAdminRequest( `/internal/metrics${queryString ? `?${queryString}` : ''}`, @@ -374,7 +392,36 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { }, null, ); - return (await response.json()) as MetricsResponse; + const body = (await response.json()) as MetricsResponse; + // The yup schema's .optional().default(...) fallbacks only run during + // backend response validation, not on this client-side cast — apply them + // here too so the one-release-cycle tolerance for older servers that the + // schema comments promise actually holds for dashboard consumers. The + // Partial views widen the static type (which claims these are always + // defined) to match what an older server can actually send. + const rawBody: Partial = body; + const rawAnalytics: Partial = body.analytics_overview; + return { + ...body, + live_users: rawBody.live_users ?? 0, + hourly_users: rawBody.hourly_users ?? [], + hourly_active_users: rawBody.hourly_active_users ?? [], + analytics_overview: { + ...body.analytics_overview, + hourly_page_views: rawAnalytics.hourly_page_views ?? [], + hourly_active_users: rawAnalytics.hourly_active_users ?? [], + hourly_visitors: rawAnalytics.hourly_visitors ?? [], + daily_anonymous_visitors_fallback: rawAnalytics.daily_anonymous_visitors_fallback ?? [], + anonymous_visitors_fallback: rawAnalytics.anonymous_visitors_fallback ?? 0, + top_regions: rawAnalytics.top_regions ?? [], + bounce_rate: rawAnalytics.bounce_rate ?? 0, + daily_bounce_rate: rawAnalytics.daily_bounce_rate ?? [], + daily_avg_session_seconds: rawAnalytics.daily_avg_session_seconds ?? [], + top_browsers: rawAnalytics.top_browsers ?? [], + top_operating_systems: rawAnalytics.top_operating_systems ?? [], + top_devices: rawAnalytics.top_devices ?? [], + }, + }; } async getUserActivity(userId: string): Promise { diff --git a/packages/shared/src/interface/admin-metrics.ts b/packages/shared/src/interface/admin-metrics.ts index 8f137900f..f5f8eca2f 100644 --- a/packages/shared/src/interface/admin-metrics.ts +++ b/packages/shared/src/interface/admin-metrics.ts @@ -88,16 +88,31 @@ export const MetricsTopReferrerSchema = yupObject({ visitors: yupNumber().integer().defined(), }).defined(); +// Named-count breakdowns used by the analytics overview for top browsers, +// operating systems, and device classes (Desktop / Mobile / Tablet). +export const MetricsNamedCountSchema = yupObject({ + name: yupString().defined(), + visitors: yupNumber().integer().defined(), +}).defined(); + export const MetricsTopRegionSchema = yupObject({ country_code: yupString().nullable().defined(), region_code: yupString().nullable().defined(), count: yupNumber().integer().defined(), }).defined(); +export const MetricsTopCountrySchema = yupObject({ + country_code: yupString().defined(), + count: yupNumber().integer().defined(), +}).defined(); + export const MetricsAnalyticsOverviewSchema = yupObject({ daily_page_views: MetricsDataPointsSchema, daily_clicks: MetricsDataPointsSchema, daily_visitors: MetricsDataPointsSchema, + hourly_page_views: yupArray(MetricsDataPointSchema).optional().default([]), + hourly_active_users: yupArray(MetricsDataPointSchema).optional().default([]), + hourly_visitors: yupArray(MetricsDataPointSchema).optional().default([]), // Token-refresh-derived anonymous-visitor fallback. Populated only when the // analytics app isn't installed (no `$page-view` events) — counts DISTINCT // anonymous users per day from the events table. See @@ -117,8 +132,20 @@ export const MetricsAnalyticsOverviewSchema = yupObject({ revenue_per_visitor: yupNumber().defined(), top_referrers: yupArray(MetricsTopReferrerSchema).defined(), top_region: MetricsTopRegionSchema.nullable().defined(), - // dev-fallback fields (only present in non-production environments) - bounce_rate: yupNumber().optional(), + top_regions: yupArray(MetricsTopCountrySchema).optional().default([]), + // Weighted across the window: sum(bounced)/sum(sessions) * 100. .optional() + // for one release cycle so older servers (that don't return it yet) don't + // hard-fail validation; default to 0 so consumers can read unconditionally. + bounce_rate: yupNumber().optional().default(0), + daily_bounce_rate: yupArray(MetricsDataPointSchema).optional().default([]), + daily_avg_session_seconds: yupArray(MetricsDataPointSchema).optional().default([]), + // User-Agent-derived breakdowns for the analytics overview. Computed from the + // `data.user_agent` blob on `$page-view` events (captured client-side only, + // no server-side fallback). Optional + default-[] for one release cycle + // so older clients / servers without UA capture don't fail validation. + top_browsers: yupArray(MetricsNamedCountSchema).optional().default([]), + top_operating_systems: yupArray(MetricsNamedCountSchema).optional().default([]), + top_devices: yupArray(MetricsNamedCountSchema).optional().default([]), conversion_rate: yupNumber().optional(), deltas: yupMixed().optional(), }).defined(); @@ -176,6 +203,8 @@ export const MetricsResponseBodySchema = yupObject({ live_users: yupNumber().integer().optional().default(0), daily_users: MetricsDataPointsSchema, daily_active_users: MetricsDataPointsSchema, + hourly_users: yupArray(MetricsDataPointSchema).optional().default([]), + hourly_active_users: yupArray(MetricsDataPointSchema).optional().default([]), users_by_country: yupRecord(yupString().defined(), yupNumber().defined()).defined(), active_users_by_country: MetricsActiveUsersByCountrySchema, // recently_registered/active are CRUD User objects passed through from the @@ -206,6 +235,8 @@ export type MetricsEmailOverview = yup.InferType; export type MetricsTopReferrer = yup.InferType; export type MetricsTopRegion = yup.InferType; +export type MetricsTopCountry = yup.InferType; +export type MetricsNamedCount = yup.InferType; export type MetricsAnalyticsOverview = yup.InferType; export type MetricsLoginMethodEntry = yup.InferType; export type MetricsRecentUser = yup.InferType; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index 307b49078..1541042e7 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -100,8 +100,12 @@ export class _HexclaveAdminAppImplIncomplete { return await this._interface.getSvixToken(); }); - private readonly _metricsCache = createCache(async ([includeAnonymous]: [boolean]) => { - return await this._interface.getMetrics(includeAnonymous); + // Cache key serializes filters via URLSearchParams (sorted keys) so + // DependenciesMap (identity-keyed per array slot) treats two equal + // filter objects as the same deterministic string entry. + private readonly _metricsCache = createCache(async ([includeAnonymous, filtersKey]: [boolean, string]) => { + const filters = filtersKey ? Object.fromEntries(new URLSearchParams(filtersKey)) : undefined; + return await this._interface.getMetrics(includeAnonymous, filters); }); private readonly _userActivityCache = createCache(async ([userId]: [string]) => { return await this._interface.getUserActivity(userId); @@ -568,8 +572,7 @@ export class _HexclaveAdminAppImplIncomplete true), this._metricsUserCountsCache.refresh([]), ]); } @@ -578,8 +581,20 @@ export class _HexclaveAdminAppImplIncomplete { - return useAsyncCache(this._metricsCache, [includeAnonymous] as const, "adminApp.useMetrics()") as MetricsResponse; + useMetrics: ( + includeAnonymous: boolean = false, + filters?: { country_code?: string, referrer?: string, browser?: string, os?: string, device?: string, since?: string, until?: string }, + ): MetricsResponse => { + const filtersKey = (() => { + if (filters == null) return ""; + const params = new URLSearchParams(); + for (const key of ["browser", "country_code", "device", "os", "referrer", "since", "until"] as const) { + const v = filters[key]; + if (v != null) params.set(key, v); + } + return params.toString(); + })(); + return useAsyncCache(this._metricsCache, [includeAnonymous, filtersKey] as const, "adminApp.useMetrics()") as MetricsResponse; }, useUserActivity: (userId: string): UserActivityResponse => { return useAsyncCache(this._userActivityCache, [userId] as const, "adminApp.useUserActivity()") as UserActivityResponse; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts index 79ac75c43..dad432bc2 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts @@ -124,6 +124,7 @@ export class EventTracker { viewport_height: window.innerHeight, screen_width: screenObject.width, screen_height: screenObject.height, + user_agent: typeof navigator !== "undefined" ? navigator.userAgent : null, }, }); } diff --git a/packages/ui/src/components/ui/tooltip.tsx b/packages/ui/src/components/ui/tooltip.tsx index 4697e50ce..1bc61c341 100644 --- a/packages/ui/src/components/ui/tooltip.tsx +++ b/packages/ui/src/components/ui/tooltip.tsx @@ -21,6 +21,8 @@ const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; +const TooltipPortal = TooltipPrimitive.Portal; + const TooltipContent = forwardRefIfNeeded< React.ElementRef, React.ComponentPropsWithoutRef @@ -37,4 +39,4 @@ const TooltipContent = forwardRefIfNeeded< )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +export { Tooltip, TooltipTrigger, TooltipContent, TooltipPortal, TooltipProvider }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f40213330..e81fbf253 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + motion: + specifier: ^12.39.0 + version: 12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: specifier: 16.1.7 version: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -13005,6 +13008,20 @@ packages: frame-ticker@1.0.3: resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==} + framer-motion@12.39.0: + resolution: {integrity: sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + freestyle-sandboxes@0.1.6: resolution: {integrity: sha512-zfyJy+DgmheFjCAPYMklo7rpzvuxNP46rB0a9WfNBEmitYGE23nlbjyTy8qdrmVuCVCoMIDQQzzJRkyuh0Szqg==} deprecated: This package has been deprecated. Please use freestyle instead. @@ -15055,6 +15072,26 @@ packages: monaco-editor@0.52.2: resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + motion-dom@12.39.0: + resolution: {integrity: sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.39.0: + resolution: {integrity: sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -32920,6 +32957,16 @@ snapshots: dependencies: simplesignal: 2.1.7 + framer-motion@12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + motion-dom: 12.39.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.3.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + freestyle-sandboxes@0.1.6: {} fresh@0.5.2: {} @@ -35583,6 +35630,21 @@ snapshots: monaco-editor@0.52.2: {} + motion-dom@12.39.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + framer-motion: 12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.3.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + mri@1.2.0: {} ms@2.0.0: {}