From ba947ecc0dae2c80140b8d02e3ae8ee7a8832d6a Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 27 May 2026 11:35:44 -0700 Subject: [PATCH 1/5] Add analytics overview filters --- .../latest/analytics/events/batch/route.tsx | 44 +- .../api/latest/internal/metrics/route.test.ts | 60 +- .../app/api/latest/internal/metrics/route.tsx | 619 +++++++++- .../(overview)/analytics-chart-mode.test.ts | 14 + .../(overview)/analytics-chart-mode.ts | 12 + .../(overview)/globe-section-with-data.tsx | 7 +- .../projects/[projectId]/(overview)/globe.tsx | 28 +- .../[projectId]/(overview)/line-chart.tsx | 402 ++++++- .../[projectId]/(overview)/metrics-page.tsx | 1066 +++++++++++++---- .../[projectId]/(overview)/top-lists.tsx | 435 +++++++ .../[projectId]/analytics/overview/page.tsx | 10 + apps/dashboard/src/instrumentation.ts | 17 +- apps/dashboard/src/lib/stack-app-internals.ts | 18 +- .../src/components/pill-toggle.tsx | 102 +- .../src/components/tabs.tsx | 91 +- .../src/interface/admin-interface.ts | 16 +- .../src/interface/admin-metrics.ts | 35 +- .../apps/implementations/admin-app-impl.ts | 20 +- .../apps/implementations/event-tracker.ts | 1 + 19 files changed, 2606 insertions(+), 391 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)/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/analytics/events/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx index 02bae18aa1..944c561241 100644 --- a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx +++ b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx @@ -101,7 +101,7 @@ export const POST = createSmartRouteHandler({ inserted: yupNumber().defined(), }).defined(), }), - async handler({ auth, body }) { + async handler({ auth, body }, fullReq) { if (!auth.tenancy.config.apps.installed["analytics"]?.enabled) { throw new KnownErrors.AnalyticsNotEnabled(); } @@ -134,18 +134,36 @@ export const POST = createSmartRouteHandler({ const clickhouseClient = getClickhouseAdminClient(); - const rows = body.events.map((event) => ({ - event_type: event.event_type, - event_at: new Date(event.event_at_ms), - data: stripLoneSurrogates(event.data), - project_id: projectId, - branch_id: branchId, - user_id: userId, - team_id: null, - refresh_token_id: refreshTokenId, - session_replay_id: recentSession?.id ?? null, - session_replay_segment_id: body.session_replay_segment_id, - })); + // Server-side fallback: stamp the request's User-Agent into the event data + // blob when the client didn't already include one. This lets the analytics + // overview aggregate device/browser/OS breakdowns even for older clients. + const headerUserAgent = (() => { + const raw = fullReq.headers["user-agent"]; + if (Array.isArray(raw)) return raw[0] ?? null; + return (raw as string | undefined) ?? null; + })(); + + const rows = body.events.map((event) => { + const baseData = (typeof event.data === "object" && !Array.isArray(event.data)) + ? (event.data as Record) + : {}; + const existingUa = baseData.user_agent; + const mergedData = (existingUa == null || existingUa === "") + ? { ...baseData, user_agent: headerUserAgent } + : baseData; + return ({ + event_type: event.event_type, + event_at: new Date(event.event_at_ms), + data: stripLoneSurrogates(mergedData), + project_id: projectId, + branch_id: branchId, + user_id: userId, + team_id: null, + refresh_token_id: refreshTokenId, + session_replay_id: recentSession?.id ?? null, + session_replay_segment_id: body.session_replay_segment_id, + }); + }); await clickhouseClient.insert({ table: "analytics_internal.events", 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 9be2a91ad2..92a0529352 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 { + classifyUserAgent, + getMetricsWindowBounds, + isMetricsRevenueInvoiceStatus, + normalizeAnalyticsOverviewFilters, +} from "./route"; describe("internal metrics helpers", () => { it("only counts paid and succeeded invoices as revenue", () => { @@ -17,4 +22,57 @@ 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 ", + })).toMatchInlineSnapshot(` + { + "browser": undefined, + "country_code": "US", + "device": "Desktop", + "os": "macOS", + "referrer": "https://example.com", + } + `); + }); + + it("classifies user agents for analytics overview breakdowns", () => { + expect(classifyUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + 1440, + )).toMatchInlineSnapshot(` + { + "browser": "Chrome", + "device": "Desktop", + "os": "macOS", + } + `); + + expect(classifyUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + 390, + )).toMatchInlineSnapshot(` + { + "browser": "Safari", + "device": "Mobile", + "os": "iOS", + } + `); + + expect(classifyUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0", + 1366, + )).toMatchInlineSnapshot(` + { + "browser": "Edge", + "device": "Desktop", + "os": "Windows", + } + `); + }); }); 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 b2a10b36a6..be635086dd 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -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,7 +1151,131 @@ async function loadSessionReplayAggregates(tenancy: Tenancy, since: Date): Promi }; } -async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymous: boolean) { +const DIRECT_REFERRER_LABEL = "(direct)"; + +// Lightweight User-Agent classifier. Returns short, human-friendly labels for +// the three analytics-overview breakdowns. Intentionally keyword-only — we +// don't pull `ua-parser-js` into the backend just for top-of-list grouping. +// +// Order matters: tests run top-to-bottom and return on first match (Edge before +// Chrome, mobile-flavored before desktop). +export function classifyUserAgent(ua: string, viewportWidth: number | null): { + browser: string, + os: string, + device: "Desktop" | "Mobile" | "Tablet", +} { + const lower = ua.toLowerCase(); + + // Browser + let browser = "Other"; + if (lower.includes("edg/") || lower.includes("edge/") || lower.includes("edga/") || lower.includes("edgios/")) browser = "Edge"; + else if (lower.includes("opr/") || lower.includes("opera")) browser = "Opera"; + else if (lower.includes("samsungbrowser")) browser = "Samsung Internet"; + else if (lower.includes("firefox") || lower.includes("fxios")) browser = "Firefox"; + else if (lower.includes("crios") || lower.includes("chrome")) browser = "Chrome"; + else if (lower.includes("safari")) browser = "Safari"; + + // OS + let os = "Other"; + if (lower.includes("windows")) os = "Windows"; + else if (lower.includes("android")) os = "Android"; + else if (lower.includes("iphone") || lower.includes("ipad") || lower.includes("ipod")) os = "iOS"; + else if (lower.includes("mac os") || lower.includes("macintosh")) os = "macOS"; + else if (lower.includes("cros")) os = "ChromeOS"; + else if (lower.includes("linux")) os = "Linux"; + + // Device — UA tokens first, viewport width as fallback for ambiguous cases. + let device: "Desktop" | "Mobile" | "Tablet" = "Desktop"; + if (lower.includes("ipad") || lower.includes("tablet") || (lower.includes("android") && !lower.includes("mobile"))) { + device = "Tablet"; + } else if (lower.includes("mobile") || lower.includes("iphone") || lower.includes("ipod") || lower.includes("android")) { + device = "Mobile"; + } else if (viewportWidth != null && Number.isFinite(viewportWidth) && viewportWidth > 0) { + if (viewportWidth < 600) device = "Mobile"; + else if (viewportWidth < 1024) device = "Tablet"; + } + + return { browser, os, device }; +} + +export type AnalyticsOverviewFilters = { + country_code?: string, + referrer?: string, + browser?: string, + os?: string, + device?: 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(); + return { + country_code: countryCode || undefined, + referrer: referrer || undefined, + browser: browser || undefined, + os: os || undefined, + device: device || undefined, + }; +} + +async function resolveUserAgentFilterSet( + tenancy: Tenancy, + since: Date, + untilExclusive: Date, + filters: AnalyticsOverviewFilters, +): Promise { + const wantBrowser = filters.browser?.trim(); + const wantOs = filters.os?.trim(); + const wantDevice = filters.device?.trim(); + if (!wantBrowser && !wantOs && !wantDevice) return null; + + const clickhouseClient = getClickhouseAdminClientForMetrics(); + const result = await clickhouseClient.query({ + query: ` + SELECT + toString(e.data.user_agent) AS ua, + toInt32(toInt64OrZero(toString(e.data.viewport_width))) AS vw + FROM analytics_internal.events AS e + 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 toString(e.data.user_agent) != '' + GROUP BY ua, vw + LIMIT 5000 + `, + query_params: { + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + }, + format: "JSONEachRow", + }); + const rows: { ua: string, vw: number | string }[] = await result.json(); + const matching = new Set(); + for (const row of rows) { + if (!row.ua) continue; + const vw = Number(row.vw); + const { browser, os, device } = classifyUserAgent(row.ua, Number.isFinite(vw) && vw > 0 ? vw : null); + if (wantBrowser && browser !== wantBrowser) continue; + if (wantOs && os !== wantOs) continue; + if (wantDevice && device !== wantDevice) continue; + matching.add(row.ua); + } + return Array.from(matching); +} + +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); @@ -1082,13 +1300,28 @@ 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 { + // Pre-resolve UA filter set in TS (browser/os/device classification lives in + // TS via classifyUserAgent — pushing it into SQL would duplicate the rules). + const matchingUserAgents = await resolveUserAgentFilterSet(tenancy, since, untilExclusive, filters); + // 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 @@ -1099,7 +1332,8 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo LEFT JOIN ( SELECT user_id, - argMax(coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0), event_at) AS latest_is_anonymous + argMax(coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0), event_at) AS latest_is_anonymous, + 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} @@ -1112,7 +1346,50 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo ON e.user_id = token_refresh_users.user_id `; 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([ + + // 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 uaFragment = matchingUserAgents != null + ? (matchingUserAgents.length === 0 + ? `AND 1 = 0` + : `AND CAST(e.data.user_agent, 'String') IN {matchingUAs:Array(String)}`) + : ''; + + 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 } : {}), + ...(matchingUserAgents != null && matchingUserAgents.length > 0 + ? { matchingUAs: matchingUserAgents } + : {}), + }; + const onlineFilteredUserFragment = sharedExtraFilters + ? ` + AND user_id IN ( + SELECT assumeNotNull(e.user_id) + FROM analytics_internal.events AS e + ${analyticsUserJoin} + 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({ @@ -1142,6 +1419,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo AND e.branch_id = {branchId:String} AND e.event_at >= {since:DateTime} AND e.event_at < {untilExclusive:DateTime} + ${sharedExtraFilters} GROUP BY day ORDER BY day ASC `, @@ -1151,6 +1429,51 @@ 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' + AND e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS pv, + uniqExactIf( + assumeNotNull(e.user_id), + e.event_type IN ('$page-view', '$click') + AND e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS active_users, + 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} + WHERE e.event_type IN ('$page-view', '$click') + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.user_id IS NOT NULL + AND e.event_at >= {hourlySince:DateTime} + AND e.event_at < {untilExclusive:DateTime} + ${sharedExtraFilters} + GROUP BY hour + ORDER BY hour ASC + `, + query_params: { + hourlySince: formatClickhouseDateTimeParam(new Date(now.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", }), @@ -1170,6 +1493,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo AND e.user_id IS NOT NULL AND e.event_at >= {since:DateTime} AND e.event_at < {untilExclusive:DateTime} + ${sharedExtraFilters} `, query_params: { since: formatClickhouseDateTimeParam(since), @@ -1177,6 +1501,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, }, format: "JSONEachRow", }), @@ -1196,6 +1521,8 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo AND e.branch_id = {branchId:String} AND e.event_at >= {since:DateTime} AND e.event_at < {untilExclusive:DateTime} + ${countryFragment} + ${uaFragment} GROUP BY referrer HAVING visitors > 0 ORDER BY visitors DESC @@ -1207,30 +1534,37 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo 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, + upper(coalesce(token_refresh_users.latest_country, '')) AS country_code, uniqExactIf( - assumeNotNull(user_id), - user_id IS NOT NULL - AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0) + assumeNotNull(e.user_id), + e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} ) 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 + FROM analytics_internal.events AS e + ${analyticsUserJoin} + 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.user_id IS NOT NULL + 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), @@ -1238,6 +1572,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, + ...filterParams, }, format: "JSONEachRow", }), @@ -1253,13 +1588,113 @@ 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 + ${analyticsUserJoin} + WHERE e.session_replay_segment_id IS NOT NULL + 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 e.event_type = '$page-view' + AND ${nonAnonymousAnalyticsUserFilter} + ${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", + }), + // Raw User-Agent buckets, classified in TS afterward. 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) with a server-side header fallback, + // so older rows that pre-date the capture simply return empty here. + clickhouseClient.query({ + query: ` + SELECT + toString(e.data.user_agent) AS ua, + toInt32(toInt64OrZero(toString(e.data.viewport_width))) AS vw, + uniqExactIf( + assumeNotNull(e.user_id), + e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS visitors + FROM analytics_internal.events AS e + ${analyticsUserJoin} + 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.user_id IS NOT NULL + AND toString(e.data.user_agent) != '' + ${referrerFragment} + ${countryFragment} + GROUP BY ua, vw + HAVING visitors > 0 + ORDER BY visitors DESC + LIMIT 500 + `, + query_params: { + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + includeAnonymous: includeAnonymous ? 1 : 0, + ...(filters.referrer && filters.referrer !== DIRECT_REFERRER_LABEL ? { referrerFilter: filters.referrer } : {}), + ...(filters.country_code ? { countryFilter: filters.country_code } : {}), }, format: "JSONEachRow", }), @@ -1275,39 +1710,140 @@ 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.setMinutes(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(); + // Bucket raw UA rows into browser / OS / device labels. We sum visitor + // counts because the ClickHouse query already deduped by `(ua, vw)` — two + // rows that classify to the same label are distinct visitor cohorts. + const userAgentRows: { ua: string, vw: number | string, visitors: number | string }[] = await userAgentResult.json(); + const browserCounts = new Map(); + const osCounts = new Map(); + const deviceCounts = new Map(); + const wantBrowser = filters.browser?.trim(); + const wantOs = filters.os?.trim(); + const wantDevice = filters.device?.trim(); + for (const row of userAgentRows) { + if (!row.ua) continue; + const vw = Number(row.vw); + const visitors = Number(row.visitors); + if (!Number.isFinite(visitors) || visitors <= 0) continue; + const { browser, os, device } = classifyUserAgent(row.ua, Number.isFinite(vw) && vw > 0 ? vw : null); + if ((!wantOs || os === wantOs) && (!wantDevice || device === wantDevice)) { + browserCounts.set(browser, (browserCounts.get(browser) ?? 0) + visitors); + } + if ((!wantBrowser || browser === wantBrowser) && (!wantDevice || device === wantDevice)) { + osCounts.set(os, (osCounts.get(os) ?? 0) + visitors); + } + if ((!wantBrowser || browser === wantBrowser) && (!wantOs || os === wantOs)) { + deviceCounts.set(device, (deviceCounts.get(device) ?? 0) + 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 @@ -1340,6 +1876,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 +1887,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 +1912,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 +1922,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 +2027,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 +2040,11 @@ 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(), }), }), response: yupObject({ @@ -1493,6 +2055,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 +2075,19 @@ 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, + }); const [ dailyUsers, dailyActiveUsers, + hourlyUsers, + hourlyActiveUsers, usersByCountry, activeUsersByCountry, liveUsers, @@ -1529,6 +2102,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 +2124,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 +2142,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/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 0000000000..d9f9757366 --- /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 0000000000..795fad659c --- /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)/globe-section-with-data.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx index 88f9478f0c..5c2814244f 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)[stackAppInternalsSymbol].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 c8f28aa51a..bce5037311 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,21 @@ 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 = 150; + controls.maxDistance = 600; + } else { + 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); @@ -1160,10 +1167,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 = 150; + 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 de728313eb..ba4ad23f38 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 '@stackframe/stack'; import { fromNow, isWeekend } from '@stackframe/stack-shared/dist/utils/dates'; -import { useId, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useRef, 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,17 @@ function parseChartDate(dateValue: string): Date { return parsed; } +function formatChartXAxisTick(value: string): string { + const date = parseChartDate(value); + if (Number.isNaN(date.getTime())) { + 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 +145,9 @@ function filterPointsByTimeRange( if (timeRange === '7d') { return datapoints.slice(-7); } + if (timeRange === '1d') { + return datapoints.slice(-1); + } if (timeRange === '30d') { return datapoints.slice(-30); } @@ -311,17 +326,197 @@ 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)} + /> + + + ); +} + +export function MiniActivityBarChart({ + datapoints, + config, + height = 84, +}: { + datapoints: DataPoint[], + config: LineChartDisplayConfig, + height?: number, +}) { + const id = useId(); + const [hoveredIndex, setHoveredIndex] = useState(null); + + return ( + + updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex)} + onMouseLeave={() => setHoveredIndex(null)} + > + + } + cursor={{ + fill: "var(--color-activity)", + opacity: 0.18, + radius: 4, }} + offset={16} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + + {datapoints.map((entry, index) => { + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + formatChartXAxisTick(value)} + /> + + + ); +} + +export type MiniNamedBarChartPoint = { + name: string, + activity: number, + display: string, +}; + +function MiniNamedBarTooltip({ active, payload }: TooltipProps) { + if (!active || !payload?.length) return null; + + const row = payload[0]?.payload as MiniNamedBarChartPoint | undefined; + if (row == null) return null; + + return ( +
+
+ {row.name} + {row.display} +
+
+ ); +} + +export function MiniNamedBarChart({ + datapoints, + config, + height = 84, +}: { + datapoints: MiniNamedBarChartPoint[], + config: LineChartDisplayConfig, + height?: number, +}) { + const id = useId(); + const [hoveredIndex, setHoveredIndex] = useState(null); + + return ( + + updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex)} + onMouseLeave={() => setHoveredIndex(null)} + > + + } + cursor={{ + fill: "var(--color-activity)", + opacity: 0.18, + radius: 4, + }} + offset={16} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + + {datapoints.map((entry, index) => { + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + @@ -576,13 +771,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 +784,79 @@ 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 = 260; + +function interpolateNumber(from: number | undefined, to: number, progress: number): number { + return (from ?? 0) + (to - (from ?? 0)) * progress; +} + +function easeOutCubic(progress: number): number { + return 1 - Math.pow(1 - progress, 3); +} + +function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function useAnimatedComposedDatapoints(datapoints: ComposedDataPoint[]): ComposedDataPoint[] { + const [animatedDatapoints, setAnimatedDatapoints] = useState(datapoints); + const previousDatapointsRef = useRef(datapoints); + + useEffect(() => { + if (prefersReducedMotion()) { + previousDatapointsRef.current = datapoints; + setAnimatedDatapoints(datapoints); + return; + } + + const previousByDate = new Map(previousDatapointsRef.current.map((point) => [point.date, point])); + const previousByIndex: Array = previousDatapointsRef.current; + const startedAt = performance.now(); + let frameId: number | null = null; + + const renderFrame = (now: number) => { + const linearProgress = Math.min(1, (now - startedAt) / OVERVIEW_CHART_ANIMATION_MS); + const progress = easeOutCubic(linearProgress); + setAnimatedDatapoints(datapoints.map((point, index) => { + const previous = previousByDate.get(point.date) ?? previousByIndex[index]; + return { + ...point, + page_views: interpolateNumber(previous?.page_views, point.page_views, progress), + visitors: interpolateNumber(previous?.visitors, point.visitors, progress), + dau: interpolateNumber(previous?.dau, point.dau, progress), + new_cents: interpolateNumber(previous?.new_cents, point.new_cents, progress), + refund_cents: interpolateNumber(previous?.refund_cents, point.refund_cents, progress), + }; + })); + + if (linearProgress < 1) { + frameId = requestAnimationFrame(renderFrame); + return; + } + + previousDatapointsRef.current = datapoints; + setAnimatedDatapoints(datapoints); + }; + + frameId = requestAnimationFrame(renderFrame); + return () => { + if (frameId != null) { + cancelAnimationFrame(frameId); + } + }; + }, [datapoints]); + + return animatedDatapoints; +} + export type VisitorsHoverDataPoint = { date: string, page_views: number, @@ -634,12 +890,22 @@ 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%)" }, }, }; +const overviewChartAnimation = { + isAnimationActive: true, + animationDuration: OVERVIEW_CHART_ANIMATION_MS, + animationEasing: "ease-out" as const, +}; + function ComposedTooltip({ active, payload }: TooltipProps) { if (!active || !payload?.length) return null; @@ -652,6 +918,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 +950,16 @@ function ComposedTooltip({ active, payload }: TooltipProps) { +
+
+ + Page views +
+ + {pageViewsEnabled ? row.page_views.toLocaleString() : "—"} + +
+
@@ -724,12 +1001,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,12 +1016,13 @@ export function ComposedAnalyticsChart({ const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); const [hoveredX, setHoveredX] = useState(null); + const animatedDatapoints = useAnimatedComposedDatapoints(datapoints); const taggedDatapoints = useMemo( - () => datapoints.map(d => ({ ...d, _showVisitors: showVisitors, _showRevenue: showRevenue })), - [datapoints, showVisitors, showRevenue], + () => animatedDatapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })), + [animatedDatapoints, showPageViews, showVisitors, showRevenue], ); - const maxVisitors = Math.max(...datapoints.map(d => Math.max(showVisitors ? d.visitors : 0, d.dau)), 1); - const maxRevenueCents = Math.max(...datapoints.map(d => showRevenue ? d.new_cents : 0), 1); + const maxVisitors = Math.max(...animatedDatapoints.map(d => Math.max(showPageViews ? d.page_views : 0, showVisitors ? d.visitors : 0, d.dau)), 1); + const maxRevenueCents = Math.max(...animatedDatapoints.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); const visitorsMax = visitorTicks[visitorTicks.length - 1] ?? maxVisitors; @@ -759,7 +1039,7 @@ export function ComposedAnalyticsChart({ data={taggedDatapoints} margin={{ top: 10, right: 4, left: 4, bottom: 0 }} onMouseMove={(state) => { - updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex); + updateHoveredIndexFromChartState(state, animatedDatapoints.length, setHoveredIndex); setHoveredX(getActiveCoordinateX(state)); }} onMouseLeave={() => { @@ -778,6 +1058,9 @@ export function ComposedAnalyticsChart({ + + + @@ -801,6 +1084,26 @@ export function ComposedAnalyticsChart({ allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} /> + + {showPageViews && hoveredIndex != null && hoveredX != null && ( + + )} : false} - isAnimationActive={false} + {...overviewChartAnimation} /> {showVisitors && hoveredIndex != null && hoveredX != null && ( } - isAnimationActive={false} + {...overviewChartAnimation} strokeLinecap="round" strokeLinejoin="round" style={{ clipPath: `url(#visitors-highlight-clip-${id})` }} @@ -840,7 +1143,7 @@ export function ComposedAnalyticsChart({ strokeOpacity={hoveredIndex == null ? 0.95 : 0.24} dot={false} activeDot={} - isAnimationActive={false} + {...overviewChartAnimation} /> {hoveredIndex != null && hoveredX != null && ( } - isAnimationActive={false} + {...overviewChartAnimation} strokeLinecap="round" strokeLinejoin="round" style={{ clipPath: `url(#dau-highlight-clip-${id})` }} @@ -869,7 +1172,7 @@ export function ComposedAnalyticsChart({ strokeDasharray="4 4" dot={false} activeDot={showRevenue ? : false} - isAnimationActive={false} + {...overviewChartAnimation} /> {showRevenue && hoveredIndex != null && hoveredX != null && ( } - isAnimationActive={false} + {...overviewChartAnimation} strokeLinecap="round" strokeLinejoin="round" style={{ clipPath: `url(#revenue-highlight-clip-${id})` }} @@ -910,15 +1213,9 @@ export function ComposedAnalyticsChart({ tickMargin={compact ? 4 : 6} axisLine={false} padding={{ left: 8, right: 8 }} - interval={datapoints.length <= 7 ? 0 : "equidistantPreserveStart"} + interval={animatedDatapoints.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)} /> @@ -987,6 +1284,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 +1308,7 @@ export function TimeRangeToggle({ glassmorphic={false} onSelect={(selectedId) => { if ( + selectedId === '1d' || selectedId === '7d' || selectedId === '30d' || selectedId === 'all' || @@ -1182,6 +1481,8 @@ export function TabbedMetricsCard({ totalAllTime, showTotal = false, stackedLegendItems, + chartDataIsPreFiltered = false, + headerTooltip, }: { config: LineChartDisplayConfig, chartData: DataPoint[], @@ -1198,11 +1499,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 +1560,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 && ( @@ -1833,11 +2141,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)} />
- - Auth Methods - + + + Auth Methods + + {!compact && (
Login distribution 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 f542fef158..5d42a45a35 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,53 @@ '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/stack-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 "@stackframe/stack"; import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; 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 +69,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 +104,131 @@ 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_WIDGET_ANIMATION_MS = 260; + +function easeOutCubic(progress: number): number { + return 1 - Math.pow(1 - progress, 3); +} + +function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function useAnimatedSeriesValues(series: T[]): T[] { + const [animatedSeries, setAnimatedSeries] = useState(series); + const previousSeriesRef = useRef(series); + + useEffect(() => { + if (prefersReducedMotion()) { + previousSeriesRef.current = series; + setAnimatedSeries(series); + return; + } + + const previousSeries = previousSeriesRef.current; + const startedAt = performance.now(); + let frameId: number | null = null; + + const renderFrame = (now: number) => { + const linearProgress = Math.min(1, (now - startedAt) / OVERVIEW_WIDGET_ANIMATION_MS); + const progress = easeOutCubic(linearProgress); + setAnimatedSeries(series.map((point, index) => { + const previous = previousSeries[index]?.value ?? 0; + return { + ...point, + value: previous + (point.value - previous) * progress, + }; + })); + + if (linearProgress < 1) { + frameId = requestAnimationFrame(renderFrame); + return; + } + + previousSeriesRef.current = series; + setAnimatedSeries(series); + }; + + frameId = requestAnimationFrame(renderFrame); + return () => { + if (frameId != null) { + cancelAnimationFrame(frameId); + } + }; + }, [series]); + + return animatedSeries; +} + +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 +271,307 @@ 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) { + params.set(dimension, value); + } + } + return params.toString(); +} + +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]); +} + +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 ? [{ 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 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 countryRowsToFilterOptions(usersByCountry: Record): FilterOption[] { + return Object.entries(usersByCountry) + .filter(([code, count]) => code.length > 0 && Number.isFinite(count) && count > 0) + .sort((a, b) => b[1] - a[1] || stringCompare(a[0], b[0])) + .slice(0, 15) + .map(([code]) => ({ value: code.toUpperCase(), label: code.toUpperCase() })); +} + +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: countryRowsToFilterOptions(data.users_by_country) }, + { 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, data.users_by_country]); + + 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 ( + + ); + }) + )} +
+
-
- + + ); } -type AnalyticsChartMode = 'default' | 'dau' | 'visitors' | 'revenue'; +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 GlobeView({ includeAnonymous }: { includeAnonymous: boolean }) { + return ( +
+ +
+ ); +} function AnalyticsInChartPill({ label, value, delta, color, + isHighlighted, isSelected, controlsId, tabId, - onActivate, + onToggle, onHoverPreview, onHoverEnd, onArrowNavigate, @@ -172,26 +580,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."], + ["Unique Visitors", "Shows unique visitors from analytics events for the selected period."], + ["Revenue", "Shows new revenue from payments for the selected period."], + ]); + return (
); + + // 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 15cced2410..5d905165d4 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 "@stackframe/stack-ui"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-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,41 @@ export function DesignCategoryTabs({ } }; + useEffect(() => { + 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(); + + 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 +224,22 @@ export function DesignCategoryTabs({ return ( ); })} diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index bc34332cf9..71df733337 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -361,11 +361,25 @@ 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, + }, + ): 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); const queryString = params.toString(); const response = await this.sendAdminRequest( `/internal/metrics${queryString ? `?${queryString}` : ''}`, diff --git a/packages/stack-shared/src/interface/admin-metrics.ts b/packages/stack-shared/src/interface/admin-metrics.ts index 9c49104474..345cc67863 100644 --- a/packages/stack-shared/src/interface/admin-metrics.ts +++ b/packages/stack-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, with a + // server-side header 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/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 380b26d14d..7891f4d8a0 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -100,8 +100,11 @@ export class _StackAdminAppImplIncomplete { return await this._interface.getSvixToken(); }); - private readonly _metricsCache = createCache(async ([includeAnonymous]: [boolean]) => { - return await this._interface.getMetrics(includeAnonymous); + // Cache key serializes filters via JSON so DependenciesMap (identity-keyed + // per array slot) treats two equal filter objects as the same entry. + private readonly _metricsCache = createCache(async ([includeAnonymous, filtersKey]: [boolean, string]) => { + const filters = filtersKey ? JSON.parse(filtersKey) : undefined; + return await this._interface.getMetrics(includeAnonymous, filters); }); private readonly _userActivityCache = createCache(async ([userId]: [string]) => { return await this._interface.getUserActivity(userId); @@ -568,8 +571,8 @@ export class _StackAdminAppImplIncomplete { - 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 }, + ): MetricsResponse => { + const hasFilter = filters && Object.values(filters).some((v) => !!v); + const filtersKey = hasFilter ? JSON.stringify(filters) : ""; + 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/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index 1e2b0a1115..6f6cd3495d 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-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, }, }); } From 3e99ccc38379c170d1472fa285ea074f60acad10 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 27 May 2026 12:07:38 -0700 Subject: [PATCH 2/5] Export tooltip portal --- packages/stack-ui/src/components/ui/tooltip.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/stack-ui/src/components/ui/tooltip.tsx b/packages/stack-ui/src/components/ui/tooltip.tsx index 46bbed592c..477fb5a7eb 100644 --- a/packages/stack-ui/src/components/ui/tooltip.tsx +++ b/packages/stack-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 }; From 3f03d947ce92db1a77534c4c92fde3948435a922 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 27 May 2026 14:00:08 -0700 Subject: [PATCH 3/5] Refactor globe controls and enhance tooltip descriptions - Updated the globe control's minimum distance to use a dynamic camera distance. - Improved the layout of the globe section to prevent overflow. - Enhanced tooltip descriptions across various metrics for clarity and consistency, including adjustments to the "Unique Visitors" and "Avg. Session Time" tooltips. - Added tooltip support to the UserPageMetricCard component for better user experience. --- .../projects/[projectId]/(overview)/globe.tsx | 8 +++---- .../[projectId]/(overview)/metrics-page.tsx | 24 +++++++++---------- .../users/[userId]/user-page-metric-card.tsx | 16 ++++++++++--- 3 files changed, 29 insertions(+), 19 deletions(-) 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 bce5037311..1a04190ad1 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 @@ -689,7 +689,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate const controls = globeRef.current.controls(); if (interactive) { controls.enableZoom = true; - controls.minDistance = 150; + controls.minDistance = cameraDistance; controls.maxDistance = 600; } else { controls.maxDistance = cameraDistance; @@ -1065,7 +1065,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) */} @@ -1168,7 +1168,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate controls.autoRotate = false; controls.autoRotateSpeed = 0.5; if (interactive) { - controls.minDistance = 150; + controls.minDistance = cameraDistance; controls.maxDistance = 600; } else { controls.maxDistance = cameraDistance; 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 5d42a45a35..c8a3696735 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 @@ -591,7 +591,7 @@ function AnalyticsInChartPill({ }) { const tooltipByLabel = new Map([ ["Daily Active Users", "Shows active users by day so you can see current product usage."], - ["Unique Visitors", "Shows unique visitors from analytics events for the selected period."], + ["Unique Visitors", "Counts distinct visitors from analytics events in the selected period."], ["Revenue", "Shows new revenue from payments for the selected period."], ]); @@ -1701,7 +1701,7 @@ function MetricsContent({
- + Total Users @@ -1821,7 +1821,7 @@ function MetricsContent({ timeRange={timeRange} customDateRange={customDateRange} chartDataIsPreFiltered={timeRange === "1d"} - headerTooltip="Shows new sign-ups over time, with a recent user list for quick follow-up." + headerTooltip="New sign-ups over time, with recent users for quick follow-up." />
@@ -1848,7 +1848,7 @@ function MetricsContent({ /> onToggleAnalyticsFilter("referrer", referrer)} @@ -1859,13 +1859,13 @@ function MetricsContent({
onToggleAnalyticsFilter("country_code", code)} selectedCountry={selectedAnalyticsFilters.country_code} /> = 2; + const labelNode = ( + + {label} + + ); return (
- - {label} - + {tooltip == null ? labelNode : ( + + {labelNode} + + )}
{value} From 174f3a4c28e21e55e471649d1999678703a0d208 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 27 May 2026 15:05:50 -0700 Subject: [PATCH 4/5] Update dependencies and enhance chart animations - Added `framer-motion` version 12.39.0 to `pnpm-lock.yaml` and `package.json` for improved animation capabilities. - Integrated motion properties into various chart components in `line-chart.tsx` and `metrics-page.tsx` to enhance user experience with smoother transitions. - Refactored sparkline component in `user-page-metric-card.tsx` to support motion effects, improving visual feedback during data updates. - Introduced utility functions for handling reduced motion preferences to ensure accessibility compliance. --- apps/dashboard/package.json | 1 + .../[projectId]/(overview)/line-chart.tsx | 167 +++++------ .../[projectId]/(overview)/metrics-page.tsx | 265 +++++++++++++----- .../users/[userId]/user-page-metric-card.tsx | 177 ++++++++++-- pnpm-lock.yaml | 62 ++++ 5 files changed, 484 insertions(+), 188 deletions(-) diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 39bdbb84ff..071b8ea485 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -86,6 +86,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)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index a90744f692..8f59e05309 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 @@ -22,7 +22,7 @@ import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from " import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; import { UserAvatar } from '@stackframe/stack'; import { fromNow, isWeekend } from '@stackframe/stack-shared/dist/utils/dates'; -import { useEffect, useId, useMemo, useRef, 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 = { @@ -253,6 +253,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)); @@ -344,6 +345,7 @@ export function MiniActivityBarChart({ }) { const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); + const chartMotion = useChartMotionProps(); return ( {datapoints.map((entry, index) => { const isActiveBar = hoveredIndex === index; @@ -450,6 +452,7 @@ export function MiniNamedBarChart({ }) { const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); + const chartMotion = useChartMotionProps(); return ( {datapoints.map((entry, index) => { const isActiveBar = hoveredIndex === index; @@ -622,6 +625,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); @@ -675,7 +679,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; @@ -689,7 +693,7 @@ export function StackedBarChartDisplay({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -703,7 +707,7 @@ export function StackedBarChartDisplay({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -726,7 +730,7 @@ export function StackedBarChartDisplay({ strokeDasharray="2.5 3.5" dot={false} activeDot={false} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -739,7 +743,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" /> @@ -792,69 +796,50 @@ export type ComposedDataPoint = { _showRevenue?: boolean, }; -const OVERVIEW_CHART_ANIMATION_MS = 260; +const OVERVIEW_CHART_ANIMATION_MS = 520; -function interpolateNumber(from: number | undefined, to: number, progress: number): number { - return (from ?? 0) + (to - (from ?? 0)) * progress; -} +type ChartMotionProps = { + isAnimationActive: boolean, + animationBegin: number, + animationDuration: number, + animationEasing: "ease-out", +}; -function easeOutCubic(progress: number): number { - return 1 - Math.pow(1 - progress, 3); -} +const enabledChartMotion: ChartMotionProps = { + isAnimationActive: true, + animationBegin: 0, + animationDuration: OVERVIEW_CHART_ANIMATION_MS, + animationEasing: "ease-out", +}; -function prefersReducedMotion(): boolean { - return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; -} +const disabledChartMotion: ChartMotionProps = { + isAnimationActive: false, + animationBegin: 0, + animationDuration: 0, + animationEasing: "ease-out", +}; -function useAnimatedComposedDatapoints(datapoints: ComposedDataPoint[]): ComposedDataPoint[] { - const [animatedDatapoints, setAnimatedDatapoints] = useState(datapoints); - const previousDatapointsRef = useRef(datapoints); +function usePrefersReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); useEffect(() => { - if (prefersReducedMotion()) { - previousDatapointsRef.current = datapoints; - setAnimatedDatapoints(datapoints); + if (typeof window.matchMedia !== "function") { return; } - const previousByDate = new Map(previousDatapointsRef.current.map((point) => [point.date, point])); - const previousByIndex: Array = previousDatapointsRef.current; - const startedAt = performance.now(); - let frameId: number | null = null; - - const renderFrame = (now: number) => { - const linearProgress = Math.min(1, (now - startedAt) / OVERVIEW_CHART_ANIMATION_MS); - const progress = easeOutCubic(linearProgress); - setAnimatedDatapoints(datapoints.map((point, index) => { - const previous = previousByDate.get(point.date) ?? previousByIndex[index]; - return { - ...point, - page_views: interpolateNumber(previous?.page_views, point.page_views, progress), - visitors: interpolateNumber(previous?.visitors, point.visitors, progress), - dau: interpolateNumber(previous?.dau, point.dau, progress), - new_cents: interpolateNumber(previous?.new_cents, point.new_cents, progress), - refund_cents: interpolateNumber(previous?.refund_cents, point.refund_cents, progress), - }; - })); - - if (linearProgress < 1) { - frameId = requestAnimationFrame(renderFrame); - return; - } + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches); - previousDatapointsRef.current = datapoints; - setAnimatedDatapoints(datapoints); - }; + updatePrefersReducedMotion(); + mediaQuery.addEventListener("change", updatePrefersReducedMotion); + return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion); + }, []); - frameId = requestAnimationFrame(renderFrame); - return () => { - if (frameId != null) { - cancelAnimationFrame(frameId); - } - }; - }, [datapoints]); + return prefersReducedMotion; +} - return animatedDatapoints; +function useChartMotionProps(): ChartMotionProps { + return usePrefersReducedMotion() ? disabledChartMotion : enabledChartMotion; } export type VisitorsHoverDataPoint = { @@ -900,12 +885,6 @@ const composedChartConfig: ChartConfig = { }, }; -const overviewChartAnimation = { - isAnimationActive: true, - animationDuration: OVERVIEW_CHART_ANIMATION_MS, - animationEasing: "ease-out" as const, -}; - function ComposedTooltip({ active, payload }: TooltipProps) { if (!active || !payload?.length) return null; @@ -1016,13 +995,13 @@ export function ComposedAnalyticsChart({ const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); const [hoveredX, setHoveredX] = useState(null); - const animatedDatapoints = useAnimatedComposedDatapoints(datapoints); + const chartMotion = useChartMotionProps(); const taggedDatapoints = useMemo( - () => animatedDatapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })), - [animatedDatapoints, showPageViews, showVisitors, showRevenue], + () => datapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })), + [datapoints, showPageViews, showVisitors, showRevenue], ); - const maxVisitors = Math.max(...animatedDatapoints.map(d => Math.max(showPageViews ? d.page_views : 0, showVisitors ? d.visitors : 0, d.dau)), 1); - const maxRevenueCents = Math.max(...animatedDatapoints.map(d => showRevenue ? d.new_cents : 0), 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); const visitorsMax = visitorTicks[visitorTicks.length - 1] ?? maxVisitors; @@ -1039,7 +1018,7 @@ export function ComposedAnalyticsChart({ data={taggedDatapoints} margin={{ top: 10, right: 4, left: 4, bottom: 0 }} onMouseMove={(state) => { - updateHoveredIndexFromChartState(state, animatedDatapoints.length, setHoveredIndex); + updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex); setHoveredX(getActiveCoordinateX(state)); }} onMouseLeave={() => { @@ -1090,7 +1069,7 @@ export function ComposedAnalyticsChart({ fill="var(--color-page_views)" fillOpacity={showPageViews ? (hoveredIndex == null ? 0.18 : 0.08) : 0} radius={[4, 4, 0, 0]} - isAnimationActive={false} + {...chartMotion} /> {showPageViews && hoveredIndex != null && hoveredX != null && ( : false} - {...overviewChartAnimation} + {...chartMotion} /> {showVisitors && hoveredIndex != null && hoveredX != null && ( } - {...overviewChartAnimation} + isAnimationActive={false} strokeLinecap="round" strokeLinejoin="round" style={{ clipPath: `url(#visitors-highlight-clip-${id})` }} @@ -1143,7 +1122,7 @@ export function ComposedAnalyticsChart({ strokeOpacity={hoveredIndex == null ? 0.95 : 0.24} dot={false} activeDot={} - {...overviewChartAnimation} + {...chartMotion} /> {hoveredIndex != null && hoveredX != null && ( } - {...overviewChartAnimation} + isAnimationActive={false} strokeLinecap="round" strokeLinejoin="round" style={{ clipPath: `url(#dau-highlight-clip-${id})` }} @@ -1172,7 +1151,7 @@ export function ComposedAnalyticsChart({ strokeDasharray="4 4" dot={false} activeDot={showRevenue ? : false} - {...overviewChartAnimation} + {...chartMotion} /> {showRevenue && hoveredIndex != null && hoveredX != null && ( } - {...overviewChartAnimation} + isAnimationActive={false} strokeLinecap="round" strokeLinejoin="round" style={{ clipPath: `url(#revenue-highlight-clip-${id})` }} @@ -1213,7 +1192,7 @@ export function ComposedAnalyticsChart({ tickMargin={compact ? 4 : 6} axisLine={false} padding={{ left: 8, right: 8 }} - interval={animatedDatapoints.length <= 7 ? 0 : "equidistantPreserveStart"} + interval={datapoints.length <= 7 ? 0 : "equidistantPreserveStart"} tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }} tickFormatter={(value) => formatChartXAxisTick(value)} /> @@ -2099,6 +2078,7 @@ export function CorrelationCard({ const chartConfig: ChartConfig = Object.fromEntries( series.map(s => [s.key, { label: s.label, color: s.color }]) ); + const chartMotion = useChartMotionProps(); return ( @@ -2164,7 +2144,7 @@ export function CorrelationCard({ stroke={s.color} strokeWidth={1.5} dot={false} - isAnimationActive={false} + {...chartMotion} /> ))} @@ -2398,6 +2378,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); @@ -2460,7 +2441,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; @@ -2488,7 +2469,7 @@ export function EmailStackedBarChartDisplay({ strokeDasharray="2.5 3.5" dot={false} activeDot={false} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -2501,7 +2482,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" /> @@ -2624,6 +2605,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); @@ -2669,7 +2651,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; @@ -2692,7 +2674,7 @@ export function VisitorsHoverChart({ strokeDasharray="2.5 3.5" dot={false} activeDot={false} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -2705,7 +2687,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" /> @@ -2834,6 +2816,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); @@ -2884,7 +2867,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; @@ -2898,7 +2881,7 @@ export function RevenueHoverChart({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -2912,7 +2895,7 @@ export function RevenueHoverChart({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -2935,7 +2918,7 @@ export function RevenueHoverChart({ strokeDasharray="2.5 3.5" dot={false} activeDot={false} - isAnimationActive={false} + {...chartMotion} connectNulls={false} legendType="none" /> @@ -2948,7 +2931,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 c8a3696735..29336243c7 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 @@ -42,6 +42,7 @@ import useResizeObserver from '@react-hook/resize-observer'; import { useUser } from "@stackframe/stack"; import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; import { stringCompare } from "@stackframe/stack-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, type ReactNode, Suspense, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { AnalyticsEventLimitBanner } from "../analytics/shared"; @@ -114,11 +115,98 @@ function formatPagesPerVisitor(value: number): string { } const OVERVIEW_WIDGET_ANIMATION_MS = 260; +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 easeOutCubic(progress: number): number { return 1 - Math.pow(1 - progress, 3); } +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() { + const sentinelRef = useRef(null); + const [compacted, setCompacted] = useState(false); + + useEffect(() => { + 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(); + }; + }, []); + + 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; +} + function prefersReducedMotion(): boolean { return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } @@ -554,6 +642,86 @@ function ViewToggle({ view, onChange }: { view: "overview" | "globe", onChange: ); } +function OverviewStickyHeader({ title, actions }: { title: string, actions: ReactNode }) { + const { compacted, sentinelRef } = useOverviewHeaderCompacted(); + 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 = shouldReduceMotion ? compacted : delayedCompacted; + const layoutTransition = shouldReduceMotion ? reducedOverviewHeaderLayoutTransition : overviewHeaderLayoutTransition; + + return ( + <> +
+
+ + + +
+
+ {renderTitle && ( +
+ + {title} + +
+ )} + + {actions} + +
+ + +
+ + ); +} + function GlobeView({ includeAnonymous }: { includeAnonymous: boolean }) { return (
@@ -703,13 +871,6 @@ function AnalyticsChartWidget({ }) { const [selectedMode, setSelectedMode] = useState('default'); const [previewMode, setPreviewMode] = useState(null); - const [displayMode, setDisplayMode] = useState('default'); - const [fadingOut, setFadingOut] = useState(false); - const [fadingIn, setFadingIn] = useState(false); - const fadeTimerRef = useRef | null>(null); - const fadeInRaf1Ref = useRef(null); - const fadeInRaf2Ref = useRef(null); - const FADE_OUT_MS = 140; const tablistInstanceId = useId(); const tabpanelId = `${tablistInstanceId}-panel`; @@ -718,46 +879,7 @@ function AnalyticsChartWidget({ const revenueTabId = `${tablistInstanceId}-tab-revenue`; const activeMode: AnalyticsChartMode = previewMode ?? selectedMode; - - const switchToMode = (mode: AnalyticsChartMode) => { - if (mode === displayMode) return; - if (fadeTimerRef.current != null) { - clearTimeout(fadeTimerRef.current); - } - setFadingOut(true); - fadeTimerRef.current = setTimeout(() => { - setDisplayMode(mode); - setFadingOut(false); - setFadingIn(true); - fadeInRaf1Ref.current = requestAnimationFrame(() => { - fadeInRaf2Ref.current = requestAnimationFrame(() => { - setFadingIn(false); - fadeInRaf2Ref.current = null; - }); - fadeInRaf1Ref.current = null; - }); - fadeTimerRef.current = null; - }, FADE_OUT_MS); - }; - - useEffect(() => { - switchToMode(activeMode); - // eslint-disable-next-line react-hooks/exhaustive-deps -- switchToMode closes over displayMode/fade state - }, [activeMode]); - - useEffect(() => { - return () => { - if (fadeTimerRef.current != null) { - clearTimeout(fadeTimerRef.current); - } - if (fadeInRaf1Ref.current != null) { - cancelAnimationFrame(fadeInRaf1Ref.current); - } - if (fadeInRaf2Ref.current != null) { - cancelAnimationFrame(fadeInRaf2Ref.current); - } - }; - }, []); + const displayMode: AnalyticsChartMode = activeMode; const handleHoverPreview = (mode: AnalyticsChartMetricMode) => { setPreviewMode(mode); @@ -889,16 +1011,7 @@ function AnalyticsChartWidget({ className="flex-1 min-h-0 relative" style={{ minHeight: chartViewportHeight }} > -
+
{displayMode === 'default' && ( composedData.length === 0 ? (
@@ -1284,31 +1397,31 @@ export default function MetricsPage(props: { toSetup: () => void }) { const markAnalyticsFiltersLoaded = useCallback(() => { setLoadedAnalyticsFilters(analyticsFilters); }, [analyticsFilters]); + const headerTitle = `Welcome back, ${truncatedName}!`; + const headerActions = ( +
+ {view === "overview" && ( + <> + + + + + )} + +
+ ); return ( - {view === "overview" && ( - <> - - - - - )} - -
- } fillWidth fullBleed - wrapHeaderInCard > + {view === "overview" && } {view === "overview" && isUpdatingAnalyticsFilters && ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card.tsx index cc8ea06676..c2624c7228 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useId } from "react"; +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { DesignAnalyticsCard, type AnalyticsCardGradient } from "@/components/design-components"; import { SimpleTooltip } from "@/components/ui"; @@ -50,27 +50,162 @@ const GRADIENT_STROKE: Record = { slate: "rgb(100 116 139)", }; -function Sparkline({ values, color }: { values: number[], color: string }) { - const gradId = `metric-spark-${useId()}`; - if (values.length < 2) return null; - const w = 100; - const h = 32; +const SPARKLINE_WIDTH = 100; +const SPARKLINE_HEIGHT = 32; +const SPARKLINE_PLOT_HEIGHT = SPARKLINE_HEIGHT - 2; +const SPARKLINE_BASELINE = SPARKLINE_HEIGHT - 1; +const SPARKLINE_ANIMATION_MS = 520; +const sparklineRestState = { + transform: "translate(0px, 0px) scale(1, 1)", + opacity: 1, + transitionEnabled: true, +}; + +type SparklineGeometry = { + valuesKey: string, + linePath: string, + areaPath: string, + min: number, + range: number, + pointCount: number, +}; + +type SparklineMotionState = { + transform: string, + opacity: number, + transitionEnabled: boolean, +}; + +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 clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function parseSparklineValues(valuesKey: string): number[] { + if (valuesKey.length === 0) { + return []; + } + return valuesKey.split(",").map((value) => Number(value)); +} + +function getSparklineGeometry(valuesKey: string): SparklineGeometry | null { + const values = parseSparklineValues(valuesKey); + if (values.length < 2) { + return null; + } + const max = Math.max(...values); const min = Math.min(...values); const flat = max === min; const range = flat ? 1 : max - min; - const step = w / (values.length - 1); + const step = SPARKLINE_WIDTH / (values.length - 1); const coords = values.map((v, i) => { const x = i * step; // Reserve 1px top/bottom so the stroke isn't clipped. - const y = flat ? h / 2 : h - 1 - ((v - min) / range) * (h - 2); + const y = flat ? SPARKLINE_HEIGHT / 2 : SPARKLINE_BASELINE - ((v - min) / range) * SPARKLINE_PLOT_HEIGHT; return `${x.toFixed(2)},${y.toFixed(2)}`; }); const linePath = `M${coords.join(" L")}`; - const areaPath = `${linePath} L${w},${h} L0,${h} Z`; + const areaPath = `${linePath} L${SPARKLINE_WIDTH},${SPARKLINE_HEIGHT} L0,${SPARKLINE_HEIGHT} Z`; + + return { + valuesKey, + linePath, + areaPath, + min, + range, + pointCount: values.length, + }; +} + +function getInitialSparklineMotion(previous: SparklineGeometry, current: SparklineGeometry): SparklineMotionState { + if (previous.pointCount !== current.pointCount) { + return { + transform: "translate(0px, 3px) scale(0.98, 0.94)", + opacity: 0.72, + transitionEnabled: false, + }; + } + + const scaleY = clampNumber(current.range / previous.range, 0.35, 2.4); + const rawTranslateY = SPARKLINE_BASELINE + - scaleY * SPARKLINE_BASELINE + - ((current.min - previous.min) / previous.range) * SPARKLINE_PLOT_HEIGHT; + const translateY = clampNumber(rawTranslateY, -SPARKLINE_HEIGHT, SPARKLINE_HEIGHT); + + return { + transform: `translate(0px, ${translateY.toFixed(2)}px) scale(1, ${scaleY.toFixed(4)})`, + opacity: 0.88, + transitionEnabled: false, + }; +} + +function useSparklineMotion(geometry: SparklineGeometry | null): SparklineMotionState { + const prefersReducedMotion = usePrefersReducedMotion(); + const previousGeometryRef = useRef(null); + const [motionState, setMotionState] = useState(sparklineRestState); + + useLayoutEffect(() => { + if (geometry == null) { + previousGeometryRef.current = null; + setMotionState(sparklineRestState); + return; + } + + const previousGeometry = previousGeometryRef.current; + previousGeometryRef.current = geometry; + + if (previousGeometry == null || previousGeometry.valuesKey === geometry.valuesKey || prefersReducedMotion) { + setMotionState(sparklineRestState); + return; + } + + setMotionState(getInitialSparklineMotion(previousGeometry, geometry)); + const frameId = requestAnimationFrame(() => setMotionState(sparklineRestState)); + return () => cancelAnimationFrame(frameId); + }, [geometry, prefersReducedMotion]); + + return motionState; +} + +function Sparkline({ values, color }: { values: number[], color: string }) { + const gradId = `metric-spark-${useId()}`; + const valuesKey = values.join(","); + const geometry = useMemo(() => getSparklineGeometry(valuesKey), [valuesKey]); + const motionState = useSparklineMotion(geometry); + const motionStyle: CSSProperties = { + transform: motionState.transform, + transformBox: "view-box", + transformOrigin: "left top", + transition: motionState.transitionEnabled + ? `transform ${SPARKLINE_ANIMATION_MS}ms ease-out, opacity 180ms ease-out` + : "none", + opacity: motionState.opacity, + }; + + if (geometry == null) return null; + return ( - - + + + + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 970a64a2f3..5d55fa8d01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -559,6 +559,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) @@ -13787,6 +13790,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. @@ -15880,6 +15897,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'} @@ -35361,6 +35398,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: {} @@ -38089,6 +38136,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: {} From 322c785c67851f7ad9fbd8c506ce0fef95a65f03 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 27 May 2026 15:45:36 -0700 Subject: [PATCH 5/5] Refactor user-agent classification and enhance analytics overview filters - Removed the `classifyUserAgent` function from `route.tsx` to streamline user-agent handling. - Introduced `buildAnalyticsOverviewUserAgentFilterFragmentsForTest` to create user-agent filter fragments for analytics without relying on a raw user-agent allowlist. - Updated tests in `route.test.ts` to validate the new user-agent filter fragment generation. - Cleaned up unused code related to user-agent classification in the metrics module. --- .../api/latest/internal/metrics/route.test.ts | 47 +-- .../app/api/latest/internal/metrics/route.tsx | 350 ++++++++---------- .../[projectId]/(overview)/line-chart.tsx | 192 ---------- 3 files changed, 166 insertions(+), 423 deletions(-) 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 92a0529352..a1996046d0 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,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - classifyUserAgent, + buildAnalyticsOverviewUserAgentFilterFragmentsForTest, getMetricsWindowBounds, isMetricsRevenueInvoiceStatus, normalizeAnalyticsOverviewFilters, @@ -41,37 +41,22 @@ describe("internal metrics helpers", () => { `); }); - it("classifies user agents for analytics overview breakdowns", () => { - expect(classifyUserAgent( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - 1440, - )).toMatchInlineSnapshot(` - { - "browser": "Chrome", - "device": "Desktop", - "os": "macOS", - } - `); - - expect(classifyUserAgent( - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", - 390, - )).toMatchInlineSnapshot(` - { - "browser": "Safari", - "device": "Mobile", - "os": "iOS", - } - `); - - expect(classifyUserAgent( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0", - 1366, - )).toMatchInlineSnapshot(` + it("builds deterministic user-agent filter fragments without a raw user-agent allowlist", () => { + expect(buildAnalyticsOverviewUserAgentFilterFragmentsForTest({ + browser: "Chrome", + os: "macOS", + device: "Desktop", + })).toMatchInlineSnapshot(` { - "browser": "Edge", - "device": "Desktop", - "os": "Windows", + "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 be635086dd..e48ac5a55f 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -1153,51 +1153,6 @@ async function loadSessionReplayAggregates(tenancy: Tenancy, since: Date): Promi const DIRECT_REFERRER_LABEL = "(direct)"; -// Lightweight User-Agent classifier. Returns short, human-friendly labels for -// the three analytics-overview breakdowns. Intentionally keyword-only — we -// don't pull `ua-parser-js` into the backend just for top-of-list grouping. -// -// Order matters: tests run top-to-bottom and return on first match (Edge before -// Chrome, mobile-flavored before desktop). -export function classifyUserAgent(ua: string, viewportWidth: number | null): { - browser: string, - os: string, - device: "Desktop" | "Mobile" | "Tablet", -} { - const lower = ua.toLowerCase(); - - // Browser - let browser = "Other"; - if (lower.includes("edg/") || lower.includes("edge/") || lower.includes("edga/") || lower.includes("edgios/")) browser = "Edge"; - else if (lower.includes("opr/") || lower.includes("opera")) browser = "Opera"; - else if (lower.includes("samsungbrowser")) browser = "Samsung Internet"; - else if (lower.includes("firefox") || lower.includes("fxios")) browser = "Firefox"; - else if (lower.includes("crios") || lower.includes("chrome")) browser = "Chrome"; - else if (lower.includes("safari")) browser = "Safari"; - - // OS - let os = "Other"; - if (lower.includes("windows")) os = "Windows"; - else if (lower.includes("android")) os = "Android"; - else if (lower.includes("iphone") || lower.includes("ipad") || lower.includes("ipod")) os = "iOS"; - else if (lower.includes("mac os") || lower.includes("macintosh")) os = "macOS"; - else if (lower.includes("cros")) os = "ChromeOS"; - else if (lower.includes("linux")) os = "Linux"; - - // Device — UA tokens first, viewport width as fallback for ambiguous cases. - let device: "Desktop" | "Mobile" | "Tablet" = "Desktop"; - if (lower.includes("ipad") || lower.includes("tablet") || (lower.includes("android") && !lower.includes("mobile"))) { - device = "Tablet"; - } else if (lower.includes("mobile") || lower.includes("iphone") || lower.includes("ipod") || lower.includes("android")) { - device = "Mobile"; - } else if (viewportWidth != null && Number.isFinite(viewportWidth) && viewportWidth > 0) { - if (viewportWidth < 600) device = "Mobile"; - else if (viewportWidth < 1024) device = "Tablet"; - } - - return { browser, os, device }; -} - export type AnalyticsOverviewFilters = { country_code?: string, referrer?: string, @@ -1221,53 +1176,75 @@ export function normalizeAnalyticsOverviewFilters(filters: AnalyticsOverviewFilt }; } -async function resolveUserAgentFilterSet( - tenancy: Tenancy, - since: Date, - untilExclusive: Date, - filters: AnalyticsOverviewFilters, -): Promise { - const wantBrowser = filters.browser?.trim(); - const wantOs = filters.os?.trim(); - const wantDevice = filters.device?.trim(); - if (!wantBrowser && !wantOs && !wantDevice) return null; - - const clickhouseClient = getClickhouseAdminClientForMetrics(); - const result = await clickhouseClient.query({ - query: ` - SELECT - toString(e.data.user_agent) AS ua, - toInt32(toInt64OrZero(toString(e.data.viewport_width))) AS vw - FROM analytics_internal.events AS e - 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 toString(e.data.user_agent) != '' - GROUP BY ua, vw - LIMIT 5000 - `, - query_params: { - since: formatClickhouseDateTimeParam(since), - untilExclusive: formatClickhouseDateTimeParam(untilExclusive), - projectId: tenancy.project.id, - branchId: tenancy.branchId, +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 } : {}), }, - format: "JSONEachRow", - }); - const rows: { ua: string, vw: number | string }[] = await result.json(); - const matching = new Set(); - for (const row of rows) { - if (!row.ua) continue; - const vw = Number(row.vw); - const { browser, os, device } = classifyUserAgent(row.ua, Number.isFinite(vw) && vw > 0 ? vw : null); - if (wantBrowser && browser !== wantBrowser) continue; - if (wantOs && os !== wantOs) continue; - if (wantDevice && device !== wantDevice) continue; - matching.add(row.ua); - } - return Array.from(matching); + }; +} + +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( @@ -1318,22 +1295,18 @@ async function loadAnalyticsOverview( } | null = null; try { - // Pre-resolve UA filter set in TS (browser/os/device classification lives in - // TS via classifyUserAgent — pushing it into SQL would duplicate the rules). - const matchingUserAgents = await resolveUserAgentFilterSet(tenancy, since, untilExclusive, filters); - // 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, - argMax(CAST(data.ip_info.country_code, 'Nullable(String)'), event_at) AS latest_country + 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} @@ -1345,7 +1318,10 @@ async function loadAnalyticsOverview( ) 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 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 @@ -1358,26 +1334,25 @@ async function loadAnalyticsOverview( const countryFragment = filters.country_code ? `AND upper(coalesce(token_refresh_users.latest_country, '')) = {countryFilter:String}` : ''; - const uaFragment = matchingUserAgents != null - ? (matchingUserAgents.length === 0 - ? `AND 1 = 0` - : `AND CAST(e.data.user_agent, 'String') IN {matchingUAs:Array(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 } : {}), - ...(matchingUserAgents != null && matchingUserAgents.length > 0 - ? { matchingUAs: matchingUserAgents } - : {}), + ...userAgentFilterFragments.params, }; const onlineFilteredUserFragment = sharedExtraFilters ? ` AND user_id IN ( SELECT assumeNotNull(e.user_id) FROM analytics_internal.events AS e - ${analyticsUserJoin} + ${filters.country_code != null ? analyticsUserJoinWithCountry : ""} WHERE e.event_type = '$page-view' AND e.project_id = {projectId:String} AND e.branch_id = {branchId:String} @@ -1396,29 +1371,20 @@ async function loadAnalyticsOverview( 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 @@ -1437,31 +1403,23 @@ async function loadAnalyticsOverview( query: ` SELECT toStartOfHour(e.event_at) AS hour, - countIf( - e.event_type = '$page-view' - AND e.user_id IS NOT NULL - AND ${nonAnonymousAnalyticsUserFilter} - ) AS pv, + countIf(e.event_type = '$page-view') AS pv, uniqExactIf( assumeNotNull(e.user_id), e.event_type IN ('$page-view', '$click') - AND e.user_id IS NOT NULL - AND ${nonAnonymousAnalyticsUserFilter} ) AS active_users, 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.user_id IS NOT NULL AND e.event_at >= {hourlySince:DateTime} AND e.event_at < {untilExclusive:DateTime} + AND ${analyticsContributingUserFilter} ${sharedExtraFilters} GROUP BY hour ORDER BY hour ASC @@ -1480,19 +1438,15 @@ async function loadAnalyticsOverview( clickhouseClient.query({ query: ` SELECT - 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.user_id IS NOT NULL AND e.event_at >= {since:DateTime} AND e.event_at < {untilExclusive:DateTime} + AND ${analyticsContributingUserFilter} ${sharedExtraFilters} `, query_params: { @@ -1509,18 +1463,15 @@ async function loadAnalyticsOverview( 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 ${analyticsContributingUserFilter} ${countryFragment} ${uaFragment} GROUP BY referrer @@ -1545,19 +1496,15 @@ async function loadAnalyticsOverview( query: ` SELECT upper(coalesce(token_refresh_users.latest_country, '')) AS country_code, - 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} + ${analyticsUserJoinWithCountry} 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.user_id IS NOT NULL + AND ${analyticsContributingUserFilter} AND coalesce(token_refresh_users.latest_country, '') != '' ${referrerFragment} ${uaFragment} @@ -1610,15 +1557,14 @@ async function loadAnalyticsOverview( SELECT e.session_replay_segment_id AS sid FROM analytics_internal.events AS e - ${analyticsUserJoin} + ${analyticsUserJoinForFilteredEvents} WHERE e.session_replay_segment_id IS NOT NULL 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 e.event_type = '$page-view' - AND ${nonAnonymousAnalyticsUserFilter} + AND ${analyticsContributingUserFilter} ${sharedExtraFilters} GROUP BY sid ), @@ -1656,36 +1602,46 @@ async function loadAnalyticsOverview( }, format: "JSONEachRow", }), - // Raw User-Agent buckets, classified in TS afterward. 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) with a server-side header fallback, - // so older rows that pre-date the capture simply return empty here. + // 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) with a + // server-side header fallback, so older rows that pre-date capture simply + // return empty here. clickhouseClient.query({ query: ` SELECT - toString(e.data.user_agent) AS ua, - toInt32(toInt64OrZero(toString(e.data.viewport_width))) AS vw, - uniqExactIf( - assumeNotNull(e.user_id), - e.user_id IS NOT NULL - AND ${nonAnonymousAnalyticsUserFilter} - ) AS visitors - FROM analytics_internal.events AS e - ${analyticsUserJoin} - 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.user_id IS NOT NULL - AND toString(e.data.user_agent) != '' - ${referrerFragment} - ${countryFragment} - GROUP BY ua, vw + 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 >= {since:DateTime} + AND e.event_at < {untilExclusive: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 visitors DESC - LIMIT 500 + ORDER BY dimension ASC, visitors DESC `, query_params: { since: formatClickhouseDateTimeParam(since), @@ -1693,8 +1649,13 @@ async function loadAnalyticsOverview( projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, - ...(filters.referrer && filters.referrer !== DIRECT_REFERRER_LABEL ? { referrerFilter: filters.referrer } : {}), - ...(filters.country_code ? { countryFilter: filters.country_code } : {}), + ...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", }), @@ -1780,30 +1741,19 @@ async function loadAnalyticsOverview( const topRegionRows: { country_code: string, visitors: number }[] = await topRegionResult.json(); const onlineRows: { online: number }[] = await onlineResult.json(); - // Bucket raw UA rows into browser / OS / device labels. We sum visitor - // counts because the ClickHouse query already deduped by `(ua, vw)` — two - // rows that classify to the same label are distinct visitor cohorts. - const userAgentRows: { ua: string, vw: number | string, visitors: number | string }[] = await userAgentResult.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(); - const wantBrowser = filters.browser?.trim(); - const wantOs = filters.os?.trim(); - const wantDevice = filters.device?.trim(); for (const row of userAgentRows) { - if (!row.ua) continue; - const vw = Number(row.vw); const visitors = Number(row.visitors); if (!Number.isFinite(visitors) || visitors <= 0) continue; - const { browser, os, device } = classifyUserAgent(row.ua, Number.isFinite(vw) && vw > 0 ? vw : null); - if ((!wantOs || os === wantOs) && (!wantDevice || device === wantDevice)) { - browserCounts.set(browser, (browserCounts.get(browser) ?? 0) + visitors); - } - if ((!wantBrowser || browser === wantBrowser) && (!wantDevice || device === wantDevice)) { - osCounts.set(os, (osCounts.get(os) ?? 0) + visitors); - } - if ((!wantBrowser || browser === wantBrowser) && (!wantOs || os === wantOs)) { - deviceCounts.set(device, (deviceCounts.get(device) ?? 0) + visitors); + 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) => 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 8f59e05309..797f28f8e5 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 @@ -334,198 +334,6 @@ export function ActivityBarChart({ ); } -export function MiniActivityBarChart({ - datapoints, - config, - height = 84, -}: { - datapoints: DataPoint[], - config: LineChartDisplayConfig, - height?: number, -}) { - const id = useId(); - const [hoveredIndex, setHoveredIndex] = useState(null); - const chartMotion = useChartMotionProps(); - - return ( - - updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex)} - onMouseLeave={() => setHoveredIndex(null)} - > - - } - cursor={{ - fill: "var(--color-activity)", - opacity: 0.18, - radius: 4, - }} - offset={16} - allowEscapeViewBox={{ x: true, y: true }} - wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} - /> - - {datapoints.map((entry, index) => { - const isActiveBar = hoveredIndex === index; - return ( - - ); - })} - - - formatChartXAxisTick(value)} - /> - - - ); -} - -export type MiniNamedBarChartPoint = { - name: string, - activity: number, - display: string, -}; - -function MiniNamedBarTooltip({ active, payload }: TooltipProps) { - if (!active || !payload?.length) return null; - - const row = payload[0]?.payload as MiniNamedBarChartPoint | undefined; - if (row == null) return null; - - return ( -
-
- {row.name} - {row.display} -
-
- ); -} - -export function MiniNamedBarChart({ - datapoints, - config, - height = 84, -}: { - datapoints: MiniNamedBarChartPoint[], - config: LineChartDisplayConfig, - height?: number, -}) { - const id = useId(); - const [hoveredIndex, setHoveredIndex] = useState(null); - const chartMotion = useChartMotionProps(); - - return ( - - updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex)} - onMouseLeave={() => setHoveredIndex(null)} - > - - } - cursor={{ - fill: "var(--color-activity)", - opacity: 0.18, - radius: 4, - }} - offset={16} - allowEscapeViewBox={{ x: true, y: true }} - wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} - /> - - {datapoints.map((entry, index) => { - const isActiveBar = hoveredIndex === index; - return ( - - ); - })} - - - - - - ); -} - // ── Stacked bar chart (for DAU/DAT new · reactivated · retained splits) ────── export type StackedDataPoint = {