From 7fcd3558a588a1091e45aa931f91a658a85c374d Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 10 Jun 2026 17:20:27 -0700 Subject: [PATCH] Enhance analytics filters with date range support - Added `since` and `until` properties to `AnalyticsOverviewFilters` for bounding analytics data. - Implemented date range parsing and validation in the analytics overview loading function. - Updated relevant components and tests to accommodate the new date range functionality. - Adjusted the metrics page to utilize the new date filters in analytics queries. This change improves the flexibility of analytics data retrieval, allowing users to specify custom date ranges for their metrics. --- .../api/latest/internal/metrics/route.test.ts | 4 + .../app/api/latest/internal/metrics/route.tsx | 85 ++++- .../projects/[projectId]/(overview)/globe.tsx | 4 +- .../[projectId]/(overview)/metrics-page.tsx | 325 ++++++++++-------- .../[projectId]/(overview)/page-client.tsx | 2 +- .../src/lib/hexclave-app-internals.ts | 5 + .../src/components/tabs.tsx | 2 +- .../shared/src/interface/admin-interface.ts | 35 +- .../shared/src/interface/admin-metrics.ts | 4 +- .../apps/implementations/admin-app-impl.ts | 4 +- 10 files changed, 308 insertions(+), 162 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 a1996046d..b3fe285dc 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.test.ts +++ b/apps/backend/src/app/api/latest/internal/metrics/route.test.ts @@ -30,6 +30,8 @@ describe("internal metrics helpers", () => { browser: "", os: " macOS ", device: " Desktop ", + since: " 2026-06-01T00:00:00.000Z ", + until: "", })).toMatchInlineSnapshot(` { "browser": undefined, @@ -37,6 +39,8 @@ describe("internal metrics helpers", () => { "device": "Desktop", "os": "macOS", "referrer": "https://example.com", + "since": "2026-06-01T00:00:00.000Z", + "until": undefined, } `); }); 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 62336ef02..9b6199f79 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -20,7 +20,7 @@ import { MetricsPaymentsOverviewSchema, MetricsRecentUserSchema, } from "@hexclave/shared/dist/interface/admin-metrics"; -import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; +import { captureError, HexclaveAssertionError, StatusError } from "@hexclave/shared/dist/utils/errors"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupRecord, yupString } from "@hexclave/shared/dist/schema-fields"; import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud"; @@ -1159,6 +1159,12 @@ export type AnalyticsOverviewFilters = { browser?: string, os?: string, device?: string, + // ISO 8601 datetimes bounding the top-N breakdowns (referrers, regions, + // browsers/OS/devices). Clamped to the analytics window server-side; the + // daily/hourly series intentionally stay full-window so the dashboard can + // compute previous-period deltas client-side. + since?: string, + until?: string, }; export function normalizeAnalyticsOverviewFilters(filters: AnalyticsOverviewFilters): AnalyticsOverviewFilters { @@ -1167,15 +1173,28 @@ export function normalizeAnalyticsOverviewFilters(filters: AnalyticsOverviewFilt const browser = filters.browser?.trim(); const os = filters.os?.trim(); const device = filters.device?.trim(); + const since = filters.since?.trim(); + const until = filters.until?.trim(); return { country_code: countryCode || undefined, referrer: referrer || undefined, browser: browser || undefined, os: os || undefined, device: device || undefined, + since: since || undefined, + until: until || undefined, }; } +function parseAnalyticsRangeBound(value: string | undefined, paramName: string): Date | undefined { + if (value == null) return undefined; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new StatusError(400, `Invalid ${paramName}: expected an ISO 8601 datetime, got ${JSON.stringify(value)}`); + } + return parsed; +} + const analyticsOverviewUserAgentSql = "toString(e.data.user_agent)"; const analyticsOverviewViewportWidthSql = "toInt32(toInt64OrZero(toString(e.data.viewport_width)))"; @@ -1258,6 +1277,17 @@ async function loadAnalyticsOverview( const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); + // Optional date range for the top-N breakdowns, clamped to the analytics + // window (ClickHouse only has detail for the last METRICS_WINDOW_DAYS). + // The daily/hourly series stay full-window — see AnalyticsOverviewFilters. + const requestedRangeSince = parseAnalyticsRangeBound(filters.since, "filter_since"); + const requestedRangeUntil = parseAnalyticsRangeBound(filters.until, "filter_until"); + const rangeSince = new Date(Math.max(since.getTime(), requestedRangeSince?.getTime() ?? since.getTime())); + const rangeUntilExclusive = new Date(Math.min(untilExclusive.getTime(), requestedRangeUntil?.getTime() ?? untilExclusive.getTime())); + if (rangeSince.getTime() >= rangeUntilExclusive.getTime()) { + throw new StatusError(400, "filter_since must be before filter_until after clamping to the analytics window"); + } + const clickhouseClient = getClickhouseAdminClientForMetrics(); // Session replay aggregates come from Postgres and have nothing to do with @@ -1294,7 +1324,14 @@ async function loadAnalyticsOverview( topDevices: { name: string, visitors: number }[], } | null = null; - try { + // Explicit installed-check instead of inferring "analytics not enabled" from + // a failed ClickHouse query: when the app isn't installed we skip ClickHouse + // entirely and return the token-refresh fallback payload; when it IS + // installed, every ClickHouse error propagates to the caller so the + // dashboard renders its error state instead of plausible-looking zeros. + const analyticsInstalled = tenancy.config.apps.installed["analytics"]?.enabled ?? false; + + if (analyticsInstalled) try { // The `event_at >= since` bound on the inner subquery is load-bearing: // without it the GROUP BY hash table holds one row per ever-seen user. // Edge case: anonymous page-views by users with no token-refresh in the @@ -1473,8 +1510,8 @@ async function loadAnalyticsOverview( WHERE e.event_type = '$page-view' AND e.project_id = {projectId:String} AND e.branch_id = {branchId:String} - AND e.event_at >= {since:DateTime} - AND e.event_at < {untilExclusive:DateTime} + AND e.event_at >= {rangeSince:DateTime} + AND e.event_at < {rangeUntilExclusive:DateTime} AND ${analyticsContributingUserFilter} ${countryFragment} ${uaFragment} @@ -1486,6 +1523,8 @@ async function loadAnalyticsOverview( query_params: { since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + rangeSince: formatClickhouseDateTimeParam(rangeSince), + rangeUntilExclusive: formatClickhouseDateTimeParam(rangeUntilExclusive), projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, @@ -1506,8 +1545,8 @@ async function loadAnalyticsOverview( WHERE e.event_type = '$page-view' AND e.project_id = {projectId:String} AND e.branch_id = {branchId:String} - AND e.event_at >= {since:DateTime} - AND e.event_at < {untilExclusive:DateTime} + AND e.event_at >= {rangeSince:DateTime} + AND e.event_at < {rangeUntilExclusive:DateTime} AND ${analyticsContributingUserFilter} AND coalesce(token_refresh_users.latest_country, '') != '' ${referrerFragment} @@ -1520,6 +1559,8 @@ async function loadAnalyticsOverview( query_params: { since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + rangeSince: formatClickhouseDateTimeParam(rangeSince), + rangeUntilExclusive: formatClickhouseDateTimeParam(rangeUntilExclusive), projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, @@ -1608,9 +1649,9 @@ async function loadAnalyticsOverview( }), // 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. + // `data.user_agent` is captured client-side (navigator.userAgent) only — + // there is no server-side fallback — so older rows that pre-date capture + // simply return empty here. clickhouseClient.query({ query: ` SELECT @@ -1628,8 +1669,8 @@ async function loadAnalyticsOverview( WHERE e.event_type = '$page-view' AND e.project_id = {projectId:String} AND e.branch_id = {branchId:String} - AND e.event_at >= {since:DateTime} - AND e.event_at < {untilExclusive:DateTime} + AND e.event_at >= {rangeSince:DateTime} + AND e.event_at < {rangeUntilExclusive:DateTime} AND ${analyticsContributingUserFilter} AND ${analyticsOverviewUserAgentSql} != '' ${referrerFragment} @@ -1650,6 +1691,8 @@ async function loadAnalyticsOverview( query_params: { since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + rangeSince: formatClickhouseDateTimeParam(rangeSince), + rangeUntilExclusive: formatClickhouseDateTimeParam(rangeUntilExclusive), projectId: tenancy.project.id, branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, @@ -1800,20 +1843,18 @@ async function loadAnalyticsOverview( topDevices, }; } catch (error) { - // Only swallow real ClickHouse errors — that's the "analytics not enabled - // for this project" path. Anything else is a real bug and should propagate. - if (!(error instanceof ClickHouseError)) { - throw error; - } - captureError("internal-metrics-analytics-overview-clickhouse-fallback", new HexclaveAssertionError( - "Falling back to empty analytics overview due to ClickHouse query failure.", + // The analytics app is installed, so a ClickHouse failure here is a real + // error — capture with context, then propagate so the dashboard shows its + // error state instead of silently rendering an empty overview. + captureError("internal-metrics-analytics-overview-clickhouse", new HexclaveAssertionError( + "Analytics overview ClickHouse queries failed for a project with the analytics app installed.", { cause: error, projectId: tenancy.project.id, branchId: tenancy.branchId, }, )); - // Leave clickhouseAggregates as null — handled in the response builder below. + throw error; } // Postgres-backed session replay query has its own error surface — let it @@ -1999,6 +2040,10 @@ export const GET = createSmartRouteHandler({ filter_browser: yupString().optional(), filter_os: yupString().optional(), filter_device: yupString().optional(), + // ISO 8601 datetimes bounding the analytics top-N breakdowns (referrers, + // regions, browsers/OS/devices); clamped to the analytics window. + filter_since: yupString().optional(), + filter_until: yupString().optional(), }), }), response: yupObject({ @@ -2035,6 +2080,8 @@ export const GET = createSmartRouteHandler({ browser: req.query.filter_browser || undefined, os: req.query.filter_os || undefined, device: req.query.filter_device || undefined, + since: req.query.filter_since || undefined, + until: req.query.filter_until || undefined, }); const [ 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 7ffc7c9b0..90ff20932 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx @@ -690,7 +690,9 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate if (interactive) { controls.enableZoom = true; controls.minDistance = cameraDistance; - controls.maxDistance = 600; + // Large containers can push cameraDistance past 600; keep min <= max so + // OrbitControls doesn't end up with an inverted zoom range. + controls.maxDistance = Math.max(600, cameraDistance); } else { controls.enableZoom = false; controls.maxDistance = cameraDistance; 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 72ceb9d21..7b2153abf 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx @@ -71,7 +71,6 @@ import { } from "./line-chart"; import { MetricsErrorFallback, MetricsLoadingFallback } from "./metrics-loading"; import { ReferrersWithAnalyticsCard, TopNamedListCard, TopRegionsCard } from "./top-lists"; -import { easeOutCubic, prefersReducedMotion } from "./animation-utils"; import { ANALYTICS_CHART_METRIC_MODE_ORDER, toggleAnalyticsChartMetricMode, @@ -115,7 +114,6 @@ function formatPagesPerVisitor(value: number): string { return value.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }); } -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; @@ -142,11 +140,16 @@ function findScrollContainer(element: HTMLElement): HTMLElement | null { return null; } -function useOverviewHeaderCompacted() { +function useOverviewHeaderCompacted(enabled: boolean) { const sentinelRef = useRef(null); const [compacted, setCompacted] = useState(false); useEffect(() => { + if (!enabled) { + setCompacted(false); + return; + } + const sentinel = sentinelRef.current; if (sentinel == null) return; @@ -167,7 +170,7 @@ function useOverviewHeaderCompacted() { return () => { observer.disconnect(); }; - }, []); + }, [enabled]); return { compacted, sentinelRef }; } @@ -204,52 +207,6 @@ function useDelayedTrue(value: boolean, delayMs: number): boolean { return delayedValue; } -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"], @@ -370,9 +327,56 @@ function analyticsFiltersKey(filters: AnalyticsOverviewFilters): string { params.set(dimension, value); } } + if (filters.since != null) params.set("since", filters.since); + if (filters.until != null) params.set("until", filters.until); return params.toString(); } +// Matches getDateKey in line-chart.tsx: custom-range picker dates are +// local-midnight Dates, and the daily series keys are "YYYY-MM-DD". +function localDateKey(date: Date): string { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +// Server-side date bounds for the top-N breakdowns (referrers, regions, +// browsers/OS/devices), derived from the chart time range. Quantized to the +// current UTC hour (1d) / UTC day (7d) so the metrics cache key stays stable +// across renders instead of changing every millisecond. +function analyticsDateRangeForTimeRange( + timeRange: TimeRange, + customDateRange: CustomDateRange | null, +): Pick { + switch (timeRange) { + case "1d": { + const latestHour = new Date(); + latestHour.setUTCMinutes(0, 0, 0); + return { since: new Date(latestHour.getTime() - 23 * 60 * 60 * 1000).toISOString() }; + } + case "7d": { + const todayUtc = new Date(); + todayUtc.setUTCHours(0, 0, 0, 0); + return { since: new Date(todayUtc.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString() }; + } + case "30d": + case "all": { + return {}; + } + case "custom": { + if (customDateRange == null) { + return {}; + } + const untilExclusive = new Date(new Date(`${localDateKey(customDateRange.to)}T00:00:00.000Z`).getTime() + 24 * 60 * 60 * 1000); + return { + since: `${localDateKey(customDateRange.from)}T00:00:00.000Z`, + until: untilExclusive.toISOString(), + }; + } + } +} + function getFilterDimensionLabel(dimension: keyof AnalyticsOverviewFilters): string { const label = FILTER_DIMENSION_LABELS.get(dimension); if (label == null) { @@ -627,80 +631,116 @@ function ViewToggle({ view, onChange }: { view: "overview" | "globe", onChange: ); } -function OverviewStickyHeader({ title, actions }: { title: string, actions: ReactNode }) { - const { compacted, sentinelRef } = useOverviewHeaderCompacted(); +function OverviewHeaderChrome({ + title, + actions, + compacted, + layoutCompacted, + renderTitle, + layoutTransition, + animateLayout, +}: { + title: string, + actions: ReactNode, + compacted: boolean, + layoutCompacted: boolean, + renderTitle: boolean, + layoutTransition: Transition, + animateLayout: boolean, +}) { + return ( + + +
+
+ {renderTitle && ( +
+ + {title} + +
+ )} + + {actions} + +
+ + ); +} + +function OverviewHeader({ title, actions, sticky }: { title: string, actions: ReactNode, sticky: boolean }) { + const { compacted, sentinelRef } = useOverviewHeaderCompacted(sticky); const renderTitle = useRenderWhileClosing(!compacted, OVERVIEW_HEADER_TITLE_EXIT_MS); const shouldReduceMotion = useReducedMotion(); const delayedCompacted = useDelayedTrue(compacted, shouldReduceMotion ? 0 : OVERVIEW_HEADER_TITLE_EXIT_MS); - const layoutCompacted = shouldReduceMotion ? compacted : delayedCompacted; + const layoutCompacted = sticky && (shouldReduceMotion ? compacted : delayedCompacted); const layoutTransition = shouldReduceMotion ? reducedOverviewHeaderLayoutTransition : overviewHeaderLayoutTransition; return ( <> -
+ {sticky && ( +
+ )}
- - -
-
- {renderTitle && ( -
- - {title} - -
- )} - - {actions} - -
- +
@@ -708,8 +748,11 @@ function OverviewStickyHeader({ title, actions }: { title: string, actions: Reac } function GlobeView({ includeAnonymous }: { includeAnonymous: boolean }) { + // Fills the height granted by PageLayout's containedHeight mode (the globe + // tab sets it) instead of guessing the chrome height with 100vh math, which + // left a slight page scroll whenever the guess was off. return ( -
+
); @@ -744,7 +787,7 @@ function AnalyticsInChartPill({ }) { const tooltipByLabel = new Map([ ["Daily Active Users", "Shows active users by day so you can see current product usage."], - ["Unique Visitors", "Counts distinct visitors from analytics events in the selected period."], + ["Visitors", "Sums each day's unique visitors across the selected period, so returning visitors count once per day."], ["Revenue", "Shows new revenue from payments for the selected period."], ]); @@ -1357,7 +1400,7 @@ function QuickAccessApps({ projectId, installedApps }: { projectId: string, inst ); } -export default function MetricsPage(props: { toSetup: () => void }) { +export default function MetricsPage() { const includeAnonymous = false; const [timeRange, setTimeRange] = useState("30d"); const [customDateRange, setCustomDateRange] = useState(null); @@ -1368,7 +1411,11 @@ export default function MetricsPage(props: { toSetup: () => void }) { const displayName = user?.displayName || user?.primaryEmail || null; const truncatedName = displayName && displayName.length > 30 ? `${displayName.slice(0, 30)}...` : displayName; - const selectedFilterKey = analyticsFiltersKey(analyticsFilters); + // The fetched filters combine the dimension chips with the date bounds from + // the time-range toggle, so range changes re-query the top-N breakdowns too. + const analyticsDateRange = useMemo(() => analyticsDateRangeForTimeRange(timeRange, customDateRange), [timeRange, customDateRange]); + const requestedAnalyticsFilters = useMemo(() => ({ ...analyticsFilters, ...analyticsDateRange }), [analyticsFilters, analyticsDateRange]); + const selectedFilterKey = analyticsFiltersKey(requestedAnalyticsFilters); const loadedFilterKey = analyticsFiltersKey(loadedAnalyticsFilters); const isUpdatingAnalyticsFilters = selectedFilterKey !== loadedFilterKey; @@ -1380,8 +1427,8 @@ export default function MetricsPage(props: { toSetup: () => void }) { setAnalyticsFilters((previous) => ({ ...previous, [dimension]: previous[dimension] === value ? undefined : value })); }, []); const markAnalyticsFiltersLoaded = useCallback(() => { - setLoadedAnalyticsFilters(analyticsFilters); - }, [analyticsFilters]); + setLoadedAnalyticsFilters(requestedAnalyticsFilters); + }, [requestedAnalyticsFilters]); const headerTitle = `Welcome back${truncatedName ? `, ${truncatedName}` : ""}!`; const headerActions = (
@@ -1405,20 +1452,25 @@ export default function MetricsPage(props: { toSetup: () => void }) { - + {/* The globe tab is a contained, no-scroll scene. A sticky top offset would + shift this bar over the globe card and clip the live-users badge. */} + {view === "overview" && } - {view === "overview" && isUpdatingAnalyticsFilters && ( - - - - )} + {/* Inside the error boundary so a failed filtered fetch surfaces the + page's own error fallback instead of escaping to the layout. */} + {view === "overview" && isUpdatingAnalyticsFilters && ( + + + + )} }> {view === "globe" ? ( @@ -1687,8 +1739,11 @@ function MetricsContent({ dauTotal: formatCompact(latestDau), dauLabel: "Daily Active Users", dauDelta: previousDau == null ? undefined : calculatePeriodDelta(latestDau, previousDau), + // Sum of per-bucket uniques — a visitor active on several days counts + // once per day, so this is NOT deduplicated across the whole period. + // Labeled "Visitors" (not "Unique Visitors") for that reason. visitorsTotal: formatCompact(visitorsTotalInRange), - visitorsLabel: "Unique Visitors", + visitorsLabel: "Visitors", visitorsDelta: hasFullPreviousComposedWindow ? calculatePeriodDelta(visitorsTotalInRange, previousVisitorsTotal) : undefined, revenueTotal: paymentsEnabled ? formatUsdFromCents(totalRevenueCentsInRange) @@ -1831,11 +1886,11 @@ function MetricsContent({ /> setPage('metrics')} />; } case 'metrics': { - return setPage('setup')} />; + return ; } } } diff --git a/apps/dashboard/src/lib/hexclave-app-internals.ts b/apps/dashboard/src/lib/hexclave-app-internals.ts index 537413d10..23879ed52 100644 --- a/apps/dashboard/src/lib/hexclave-app-internals.ts +++ b/apps/dashboard/src/lib/hexclave-app-internals.ts @@ -43,6 +43,11 @@ export type AnalyticsOverviewFilters = { browser?: string, os?: string, device?: string, + // ISO 8601 datetimes bounding the analytics top-N breakdowns server-side + // (top referrers / regions / browsers / OS / devices). The daily and hourly + // series stay full-window so previous-period deltas can be computed locally. + since?: string, + until?: string, }; export function useMetricsOrThrow( diff --git a/packages/dashboard-ui-components/src/components/tabs.tsx b/packages/dashboard-ui-components/src/components/tabs.tsx index c98a67794..b56c9b0a1 100644 --- a/packages/dashboard-ui-components/src/components/tabs.tsx +++ b/packages/dashboard-ui-components/src/components/tabs.tsx @@ -192,7 +192,7 @@ export function DesignCategoryTabs({
{glassmorphic && sliderMetrics != null && ( diff --git a/packages/shared/src/interface/admin-interface.ts b/packages/shared/src/interface/admin-interface.ts index 71df73333..f0c3dd4cc 100644 --- a/packages/shared/src/interface/admin-interface.ts +++ b/packages/shared/src/interface/admin-interface.ts @@ -369,6 +369,8 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { browser?: string, os?: string, device?: string, + since?: string, + until?: string, }, ): Promise { const params = new URLSearchParams(); @@ -380,6 +382,8 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { if (filters?.browser) params.append('filter_browser', filters.browser); if (filters?.os) params.append('filter_os', filters.os); if (filters?.device) params.append('filter_device', filters.device); + if (filters?.since) params.append('filter_since', filters.since); + if (filters?.until) params.append('filter_until', filters.until); const queryString = params.toString(); const response = await this.sendAdminRequest( `/internal/metrics${queryString ? `?${queryString}` : ''}`, @@ -388,7 +392,36 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { }, null, ); - return (await response.json()) as MetricsResponse; + const body = (await response.json()) as MetricsResponse; + // The yup schema's .optional().default(...) fallbacks only run during + // backend response validation, not on this client-side cast — apply them + // here too so the one-release-cycle tolerance for older servers that the + // schema comments promise actually holds for dashboard consumers. The + // Partial views widen the static type (which claims these are always + // defined) to match what an older server can actually send. + const rawBody: Partial = body; + const rawAnalytics: Partial = body.analytics_overview; + return { + ...body, + live_users: rawBody.live_users ?? 0, + hourly_users: rawBody.hourly_users ?? [], + hourly_active_users: rawBody.hourly_active_users ?? [], + analytics_overview: { + ...body.analytics_overview, + hourly_page_views: rawAnalytics.hourly_page_views ?? [], + hourly_active_users: rawAnalytics.hourly_active_users ?? [], + hourly_visitors: rawAnalytics.hourly_visitors ?? [], + daily_anonymous_visitors_fallback: rawAnalytics.daily_anonymous_visitors_fallback ?? [], + anonymous_visitors_fallback: rawAnalytics.anonymous_visitors_fallback ?? 0, + top_regions: rawAnalytics.top_regions ?? [], + bounce_rate: rawAnalytics.bounce_rate ?? 0, + daily_bounce_rate: rawAnalytics.daily_bounce_rate ?? [], + daily_avg_session_seconds: rawAnalytics.daily_avg_session_seconds ?? [], + top_browsers: rawAnalytics.top_browsers ?? [], + top_operating_systems: rawAnalytics.top_operating_systems ?? [], + top_devices: rawAnalytics.top_devices ?? [], + }, + }; } async getUserActivity(userId: string): Promise { diff --git a/packages/shared/src/interface/admin-metrics.ts b/packages/shared/src/interface/admin-metrics.ts index 2cf60234d..f5f8eca2f 100644 --- a/packages/shared/src/interface/admin-metrics.ts +++ b/packages/shared/src/interface/admin-metrics.ts @@ -140,8 +140,8 @@ export const MetricsAnalyticsOverviewSchema = yupObject({ 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 + // `data.user_agent` blob on `$page-view` events (captured client-side only, + // no server-side fallback). Optional + default-[] for one release cycle // so older clients / servers without UA capture don't fail validation. top_browsers: yupArray(MetricsNamedCountSchema).optional().default([]), top_operating_systems: yupArray(MetricsNamedCountSchema).optional().default([]), diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index 8c7e56e84..1541042e7 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -583,12 +583,12 @@ export class _HexclaveAdminAppImplIncomplete { const filtersKey = (() => { if (filters == null) return ""; const params = new URLSearchParams(); - for (const key of ["browser", "country_code", "device", "os", "referrer"] as const) { + for (const key of ["browser", "country_code", "device", "os", "referrer", "since", "until"] as const) { const v = filters[key]; if (v != null) params.set(key, v); }