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); }