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.
This commit is contained in:
mantrakp04 2026-06-10 17:20:27 -07:00
parent ff2197c445
commit 7fcd3558a5
10 changed files with 308 additions and 162 deletions

View File

@ -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,
}
`);
});

View File

@ -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 [

View File

@ -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;

View File

@ -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<HTMLDivElement>(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<T extends { value: number }>(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<string, string>([
["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<AnalyticsOverviewFilters, "since" | "until"> {
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 (
<motion.div
layout={animateLayout}
transition={layoutTransition}
className={cn(
"pointer-events-auto relative w-full max-w-full",
layoutCompacted && "ml-auto w-fit",
)}
>
<motion.div
layout={animateLayout}
transition={layoutTransition}
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 z-0 rounded-2xl border border-black/[0.06] bg-white/90 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl will-change-transform transition-[background-color,border-color,box-shadow,opacity] duration-[520ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none dark:border-0 dark:bg-transparent dark:shadow-none dark:backdrop-blur-none",
layoutCompacted && "rounded-xl border-black/[0.08] bg-white/[0.78] shadow-[0_14px_34px_rgba(15,23,42,0.14)] ring-1 ring-white/[0.55] dark:border-white/[0.08] dark:bg-background/[0.72] dark:shadow-[0_14px_34px_rgba(0,0,0,0.26)] dark:ring-white/[0.08] dark:backdrop-blur-xl",
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-5 top-0 z-10 h-px bg-gradient-to-r from-transparent via-white/70 to-transparent opacity-0 transition-opacity duration-[520ms] motion-reduce:transition-none dark:via-white/20",
layoutCompacted && "opacity-100",
)}
/>
<div
className={cn(
"relative z-10 flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-4 dark:px-0 dark:py-0 dark:sm:px-0 dark:sm:py-0",
layoutCompacted && "gap-0 sm:gap-0",
layoutCompacted && "px-3 py-2 sm:px-4 sm:py-2.5 dark:px-4 dark:py-2.5 dark:sm:px-4 dark:sm:py-2.5",
)}
>
{renderTitle && (
<div
className={cn(
"min-w-0 transition-[opacity,transform,filter] duration-[150ms] ease-out motion-reduce:transition-none sm:flex-1",
compacted && "pointer-events-none opacity-0 blur-[1px]",
)}
>
<Typography
type="h2"
className="truncate text-xl font-semibold tracking-tight sm:text-2xl"
>
{title}
</Typography>
</div>
)}
<motion.div
layout={animateLayout}
transition={layoutTransition}
className={cn(
"relative z-10 min-w-0 max-w-full flex-shrink-0 overflow-x-auto will-change-transform [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"transition-opacity duration-[520ms] motion-reduce:transition-none",
layoutCompacted && "opacity-95",
)}
>
{actions}
</motion.div>
</div>
</motion.div>
);
}
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 (
<>
<div ref={sentinelRef} aria-hidden className="-mb-[17px] h-px w-px" />
{sticky && (
<div key="sentinel" ref={sentinelRef} aria-hidden className="-mb-[17px] h-px w-px" />
)}
<div
className="sticky top-[4.25rem] z-30 mb-2 w-full pointer-events-none dark:top-[5.75rem]"
key="header"
className={cn(
"relative z-30 w-full pointer-events-none",
sticky && "sticky top-[4.25rem] mb-2 dark:top-[5.75rem]",
)}
>
<LayoutGroup id="overview-sticky-header">
<motion.div
layout
transition={layoutTransition}
className={cn(
"pointer-events-auto relative w-full max-w-full",
layoutCompacted && "ml-auto w-fit",
)}
>
<motion.div
layout
transition={layoutTransition}
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 z-0 rounded-2xl border border-black/[0.06] bg-white/90 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl will-change-transform transition-[background-color,border-color,box-shadow,opacity] duration-[520ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none dark:border-0 dark:bg-transparent dark:shadow-none dark:backdrop-blur-none",
layoutCompacted && "rounded-xl border-black/[0.08] bg-white/[0.78] shadow-[0_14px_34px_rgba(15,23,42,0.14)] ring-1 ring-white/[0.55] dark:border-white/[0.08] dark:bg-background/[0.72] dark:shadow-[0_14px_34px_rgba(0,0,0,0.26)] dark:ring-white/[0.08] dark:backdrop-blur-xl",
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-5 top-0 z-10 h-px bg-gradient-to-r from-transparent via-white/70 to-transparent opacity-0 transition-opacity duration-[520ms] motion-reduce:transition-none dark:via-white/20",
layoutCompacted && "opacity-100",
)}
/>
<div
className={cn(
"relative z-10 flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-4 dark:px-0 dark:py-0 dark:sm:px-0 dark:sm:py-0",
layoutCompacted && "gap-0 sm:gap-0",
layoutCompacted && "px-3 py-2 sm:px-4 sm:py-2.5 dark:px-4 dark:py-2.5 dark:sm:px-4 dark:sm:py-2.5",
)}
>
{renderTitle && (
<div
className={cn(
"min-w-0 transition-[opacity,transform,filter] duration-[150ms] ease-out motion-reduce:transition-none sm:flex-1",
compacted && "pointer-events-none opacity-0 blur-[1px]",
)}
>
<Typography
type="h2"
className="truncate text-xl font-semibold tracking-tight sm:text-2xl"
>
{title}
</Typography>
</div>
)}
<motion.div
layout
transition={layoutTransition}
className={cn(
"relative z-10 min-w-0 max-w-full flex-shrink-0 overflow-x-auto will-change-transform [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"transition-opacity duration-[520ms] motion-reduce:transition-none",
layoutCompacted && "opacity-95",
)}
>
{actions}
</motion.div>
</div>
</motion.div>
<OverviewHeaderChrome
title={title}
actions={actions}
compacted={sticky ? compacted : false}
layoutCompacted={layoutCompacted}
renderTitle={sticky ? renderTitle : true}
layoutTransition={layoutTransition}
animateLayout
/>
</LayoutGroup>
</div>
</>
@ -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 (
<div className="relative h-[calc(100vh-12rem)] min-h-[480px] w-full overflow-hidden rounded-2xl bg-white/90 shadow-sm ring-1 ring-black/[0.06] backdrop-blur-xl dark:rounded-none dark:bg-transparent dark:shadow-none dark:ring-0 dark:backdrop-blur-none">
<div className="relative min-h-0 w-full flex-1 overflow-hidden rounded-2xl bg-white/90 shadow-sm ring-1 ring-black/[0.06] backdrop-blur-xl dark:rounded-none dark:bg-transparent dark:shadow-none dark:ring-0 dark:backdrop-blur-none">
<GlobeSectionWithData includeAnonymous={includeAnonymous} interactive />
</div>
);
@ -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<TimeRange>("30d");
const [customDateRange, setCustomDateRange] = useState<CustomDateRange | null>(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 = (
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
@ -1405,20 +1452,25 @@ export default function MetricsPage(props: { toSetup: () => void }) {
<PageLayout
fillWidth
fullBleed
containedHeight={view === "globe"}
>
<OverviewStickyHeader title={headerTitle} actions={headerActions} />
{/* 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. */}
<OverviewHeader title={headerTitle} actions={headerActions} sticky={view === "overview"} />
{view === "overview" && <AnalyticsEventLimitBanner />}
{view === "overview" && isUpdatingAnalyticsFilters && (
<Suspense fallback={null}>
<MetricsFilterPreloader
includeAnonymous={includeAnonymous}
filters={analyticsFilters}
filterKey={selectedFilterKey}
onReady={markAnalyticsFiltersLoaded}
/>
</Suspense>
)}
<ErrorBoundary errorComponent={MetricsErrorComponent}>
{/* 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 && (
<Suspense fallback={null}>
<MetricsFilterPreloader
includeAnonymous={includeAnonymous}
filters={requestedAnalyticsFilters}
filterKey={selectedFilterKey}
onReady={markAnalyticsFiltersLoaded}
/>
</Suspense>
)}
<Suspense fallback={<MetricsLoadingFallback />}>
{view === "globe" ? (
<GlobeView includeAnonymous={includeAnonymous} />
@ -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({
/>
<UserPageMetricCard
label="Avg. Session Time"
tooltip="Average session duration from page views and clicks in the analytics window."
value={analyticsEnabled ? formatSeconds(analytics.avg_session_seconds) : "—"}
tooltip="Average session duration from page views and clicks for the selected period."
value={analyticsEnabled ? formatSeconds(analyticsPeriodTotals.avgSession) : "—"}
description="in period"
gradient="purple"
delta={analyticsPeriodTotals.hasPreviousWindow ? {
delta={analyticsEnabled && analyticsPeriodTotals.hasPreviousWindow ? {
current: analyticsPeriodTotals.avgSession,
previous: analyticsPeriodTotals.previousAvgSession,
comparisonLabel: "vs prev. period",

View File

@ -15,7 +15,7 @@ export default function PageClient() {
return <SetupPage toMetrics={() => setPage('metrics')} />;
}
case 'metrics': {
return <MetricsPage toSetup={() => setPage('setup')} />;
return <MetricsPage />;
}
}
}

View File

@ -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(

View File

@ -192,7 +192,7 @@ export function DesignCategoryTabs({
<div
ref={tabListRef}
className={cn(
"relative flex min-h-0 min-w-0 flex-1 items-center gap-1 overflow-x-auto flex-nowrap [&::-webkit-scrollbar]:hidden",
"relative flex min-h-0 min-w-0 items-center gap-1 overflow-x-auto flex-nowrap [&::-webkit-scrollbar]:hidden",
)}
>
{glassmorphic && sliderMetrics != null && (

View File

@ -369,6 +369,8 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
browser?: string,
os?: string,
device?: string,
since?: string,
until?: string,
},
): Promise<MetricsResponse> {
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<MetricsResponse> = body;
const rawAnalytics: Partial<MetricsResponse["analytics_overview"]> = 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<UserActivityResponse> {

View File

@ -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([]),

View File

@ -583,12 +583,12 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
// IF_PLATFORM react-like
useMetrics: (
includeAnonymous: boolean = false,
filters?: { country_code?: string, referrer?: string, browser?: string, os?: string, device?: string },
filters?: { country_code?: string, referrer?: string, browser?: string, os?: string, device?: string, since?: string, until?: string },
): MetricsResponse => {
const filtersKey = (() => {
if (filters == null) return "";
const params = new URLSearchParams();
for (const key of ["browser", "country_code", "device", "os", "referrer"] 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);
}