{/* Border container - same approach as globe */}
{/* Inner square div - contain behavior (square, fills either width or height) */}
@@ -1160,10 +1170,15 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
const controls = current.controls();
controls.autoRotate = false;
controls.autoRotateSpeed = 0.5;
- controls.maxDistance = cameraDistance;
- controls.minDistance = cameraDistance;
+ if (interactive) {
+ controls.minDistance = cameraDistance;
+ controls.maxDistance = 600;
+ } else {
+ controls.maxDistance = cameraDistance;
+ controls.minDistance = cameraDistance;
+ }
controls.dampingFactor = 0.15;
- controls.enableZoom = false;
+ controls.enableZoom = interactive;
controls.enableRotate = true;
current.camera().position.z = cameraDistance;
// Little Saint James Island, U.S. Virgin Islands
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx
index 45c4253ea..3e408c802 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx
@@ -14,6 +14,7 @@ import {
import { useRouter } from "@/components/router";
import {
cn,
+ SimpleTooltip,
Typography
} from "@/components/ui";
import { Calendar } from "@/components/ui/calendar";
@@ -21,7 +22,7 @@ import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { UserAvatar } from '@hexclave/next';
import { fromNow, isWeekend } from '@hexclave/shared/dist/utils/dates';
-import { useId, useMemo, useState } from "react";
+import { useEffect, useId, useMemo, useState } from "react";
import { Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts";
export type CustomDateRange = {
@@ -29,7 +30,7 @@ export type CustomDateRange = {
to: Date,
};
-export type TimeRange = '7d' | '30d' | 'all' | 'custom';
+export type TimeRange = '1d' | '7d' | '30d' | 'all' | 'custom';
export type LineChartDisplayConfig = {
name: string,
@@ -114,6 +115,19 @@ function parseChartDate(dateValue: string): Date {
return parsed;
}
+function formatChartXAxisTick(value: string): string {
+ let date: Date;
+ try {
+ date = parseChartDate(value);
+ } catch {
+ return value;
+ }
+ if (value.includes("T")) {
+ return date.toLocaleTimeString("en-US", { hour: "numeric" });
+ }
+ return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`;
+}
+
function formatDateRangeLabel(range: CustomDateRange | null): string {
if (range == null) {
return "Pick date range";
@@ -133,6 +147,9 @@ function filterPointsByTimeRange(
if (timeRange === '7d') {
return datapoints.slice(-7);
}
+ if (timeRange === '1d') {
+ return datapoints.slice(-1);
+ }
if (timeRange === '30d') {
return datapoints.slice(-30);
}
@@ -238,6 +255,7 @@ export function ActivityBarChart({
}) {
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState(null);
+ const chartMotion = useChartMotionProps();
return (
{datapoints.map((entry, index) => {
const isWeekendDay = isWeekend(parseChartDate(entry.date));
@@ -311,17 +329,7 @@ export function ActivityBarChart({
fill: "hsl(var(--muted-foreground))",
fontSize: compact ? 8 : 10,
}}
- tickFormatter={(value) => {
- const date = parseChartDate(value);
- if (!isNaN(date.getTime())) {
- const month = date.toLocaleDateString("en-US", {
- month: "short",
- });
- const day = date.getDate();
- return `${month} ${day}`;
- }
- return value;
- }}
+ tickFormatter={(value) => formatChartXAxisTick(value)}
/>
@@ -427,6 +435,7 @@ export function StackedBarChartDisplay({
}) {
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState(null);
+ const chartMotion = useChartMotionProps();
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
const totals = datapoints.map(p => p.new + p.retained + p.reactivated);
@@ -480,7 +489,7 @@ export function StackedBarChartDisplay({
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
/>
-
+
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@@ -494,7 +503,7 @@ export function StackedBarChartDisplay({
);
})}
-
+
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@@ -508,7 +517,7 @@ export function StackedBarChartDisplay({
);
})}
-
+
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@@ -531,7 +540,7 @@ export function StackedBarChartDisplay({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={false}
- isAnimationActive={false}
+ {...chartMotion}
connectNulls={false}
legendType="none"
/>
@@ -544,7 +553,7 @@ export function StackedBarChartDisplay({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
- isAnimationActive={false}
+ {...chartMotion}
connectNulls={false}
legendType="none"
/>
@@ -576,13 +585,7 @@ export function StackedBarChartDisplay({
axisLine={false}
interval={datapoints.length <= 7 ? 0 : "equidistantPreserveStart"}
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }}
- tickFormatter={(value) => {
- const date = parseChartDate(value);
- if (!isNaN(date.getTime())) {
- return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`;
- }
- return value;
- }}
+ tickFormatter={(value) => formatChartXAxisTick(value)}
/>
@@ -595,12 +598,60 @@ export type ComposedDataPoint = {
date: string,
new_cents: number,
refund_cents: number,
+ page_views: number,
visitors: number,
dau: number,
+ _showPageViews?: boolean,
_showVisitors?: boolean,
_showRevenue?: boolean,
};
+const OVERVIEW_CHART_ANIMATION_MS = 520;
+
+type ChartMotionProps = {
+ isAnimationActive: boolean,
+ animationBegin: number,
+ animationDuration: number,
+ animationEasing: "ease-out",
+};
+
+const enabledChartMotion: ChartMotionProps = {
+ isAnimationActive: true,
+ animationBegin: 0,
+ animationDuration: OVERVIEW_CHART_ANIMATION_MS,
+ animationEasing: "ease-out",
+};
+
+const disabledChartMotion: ChartMotionProps = {
+ isAnimationActive: false,
+ animationBegin: 0,
+ animationDuration: 0,
+ animationEasing: "ease-out",
+};
+
+function usePrefersReducedMotion(): boolean {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+
+ useEffect(() => {
+ if (typeof window.matchMedia !== "function") {
+ return;
+ }
+
+ const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
+
+ updatePrefersReducedMotion();
+ mediaQuery.addEventListener("change", updatePrefersReducedMotion);
+ return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
+ }, []);
+
+ return prefersReducedMotion;
+}
+
+function useChartMotionProps(): ChartMotionProps {
+ return usePrefersReducedMotion() ? disabledChartMotion : enabledChartMotion;
+}
+
export type VisitorsHoverDataPoint = {
date: string,
page_views: number,
@@ -634,6 +685,10 @@ const composedChartConfig: ChartConfig = {
label: "Unique Visitors",
theme: { light: "hsl(210, 84%, 64%)", dark: "hsl(210, 84%, 72%)" },
},
+ page_views: {
+ label: "Page Views",
+ theme: { light: "hsl(189, 84%, 54%)", dark: "hsl(189, 84%, 68%)" },
+ },
revenue: {
label: "Revenue",
theme: { light: "hsl(268, 82%, 66%)", dark: "hsl(268, 82%, 74%)" },
@@ -652,6 +707,7 @@ function ComposedTooltip({ active, payload }: TooltipProps) {
: row.date;
const visitorsEnabled = row._showVisitors !== false;
+ const pageViewsEnabled = row._showPageViews !== false;
const revenueEnabled = row._showRevenue !== false;
const revenueDollars = (row.new_cents / 100);
const revenuePerVisitor = visitorsEnabled && revenueEnabled && row.visitors > 0 ? (revenueDollars / row.visitors) : null;
@@ -683,6 +739,16 @@ function ComposedTooltip({ active, payload }: TooltipProps) {
+
+
+
+ Page views
+
+
+ {pageViewsEnabled ? row.page_views.toLocaleString() : "—"}
+
+
+
@@ -724,12 +790,14 @@ function HighlightedLineDot({ cx, cy, fill }: HighlightDotProps) {
export function ComposedAnalyticsChart({
datapoints,
showVisitors = true,
+ showPageViews = true,
showRevenue = true,
height,
compact = false,
}: {
datapoints: ComposedDataPoint[],
showVisitors?: boolean,
+ showPageViews?: boolean,
showRevenue?: boolean,
height?: number,
compact?: boolean,
@@ -737,11 +805,12 @@ export function ComposedAnalyticsChart({
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState
(null);
const [hoveredX, setHoveredX] = useState(null);
+ const chartMotion = useChartMotionProps();
const taggedDatapoints = useMemo(
- () => datapoints.map(d => ({ ...d, _showVisitors: showVisitors, _showRevenue: showRevenue })),
- [datapoints, showVisitors, showRevenue],
+ () => datapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })),
+ [datapoints, showPageViews, showVisitors, showRevenue],
);
- const maxVisitors = Math.max(...datapoints.map(d => Math.max(showVisitors ? d.visitors : 0, d.dau)), 1);
+ const maxVisitors = Math.max(...datapoints.map(d => Math.max(showPageViews ? d.page_views : 0, showVisitors ? d.visitors : 0, d.dau)), 1);
const maxRevenueCents = Math.max(...datapoints.map(d => showRevenue ? d.new_cents : 0), 1);
const visitorTicks = niceAxisTicks(Math.ceil(maxVisitors * 1.1), 5);
const revenueTicks = niceAxisTicks(Math.ceil(maxRevenueCents * 1.15), 5);
@@ -778,6 +847,9 @@ export function ComposedAnalyticsChart({
+
+
+
@@ -801,6 +873,26 @@ export function ComposedAnalyticsChart({
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
/>
+
+ {showPageViews && hoveredIndex != null && hoveredX != null && (
+
+ )}
: false}
- isAnimationActive={false}
+ {...chartMotion}
/>
{showVisitors && hoveredIndex != null && hoveredX != null && (
}
- isAnimationActive={false}
+ {...chartMotion}
/>
{hoveredIndex != null && hoveredX != null && (
: false}
- isAnimationActive={false}
+ {...chartMotion}
/>
{showRevenue && hoveredIndex != null && hoveredX != null && (
{
- const date = parseChartDate(value);
- if (!isNaN(date.getTime())) {
- return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`;
- }
- return value;
- }}
+ tickFormatter={(value) => formatChartXAxisTick(value)}
/>
@@ -987,6 +1073,7 @@ export function TimeRangeToggle({
const customDateRangeHandler = onCustomDateRangeChange;
const options: { id: TimeRange, label: string }[] = [
+ { id: '1d', label: '1d' },
{ id: '7d', label: '7d' },
{ id: '30d', label: '30d' },
{ id: 'all', label: 'All' },
@@ -1010,6 +1097,7 @@ export function TimeRangeToggle({
glassmorphic={false}
onSelect={(selectedId) => {
if (
+ selectedId === '1d' ||
selectedId === '7d' ||
selectedId === '30d' ||
selectedId === 'all' ||
@@ -1182,6 +1270,8 @@ export function TabbedMetricsCard({
totalAllTime,
showTotal = false,
stackedLegendItems,
+ chartDataIsPreFiltered = false,
+ headerTooltip,
}: {
config: LineChartDisplayConfig,
chartData: DataPoint[],
@@ -1198,11 +1288,15 @@ export function TabbedMetricsCard({
totalAllTime?: number,
showTotal?: boolean,
stackedLegendItems?: Array<{ key: string, label: string, color: string }>,
+ chartDataIsPreFiltered?: boolean,
+ headerTooltip?: string,
}) {
const [view, setView] = useState<'chart' | 'list'>('chart');
- const filteredDatapoints = filterDatapointsByTimeRange(chartData, timeRange, customDateRange);
- const filteredStackedDatapoints = stackedChartData ? filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange) : null;
+ const filteredDatapoints = chartDataIsPreFiltered ? chartData : filterDatapointsByTimeRange(chartData, timeRange, customDateRange);
+ const filteredStackedDatapoints = stackedChartData
+ ? (chartDataIsPreFiltered ? stackedChartData : filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange))
+ : null;
// Calculate total for the selected time range
const total = filteredDatapoints.reduce((sum, point) => sum + point.activity, 0);
@@ -1255,6 +1349,9 @@ export function TabbedMetricsCard({
gradient={tabsGradient}
className="flex-1 min-w-0 border-0 [&>button]:rounded-none [&>button]:px-3 [&>button]:py-3.5 [&>button]:text-xs"
/>
+ {headerTooltip && (
+
+ )}
{view === 'chart' && showTotal && (
@@ -1791,6 +1888,7 @@ export function CorrelationCard({
const chartConfig: ChartConfig = Object.fromEntries(
series.map(s => [s.key, { label: s.label, color: s.color }])
);
+ const chartMotion = useChartMotionProps();
return (
@@ -1833,11 +1931,7 @@ export function CorrelationCard({
tickMargin={6}
interval="equidistantPreserveStart"
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }}
- tickFormatter={(value) => {
- const date = parseChartDate(value);
- if (isNaN(date.getTime())) return value;
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
- }}
+ tickFormatter={(value) => formatChartXAxisTick(value)}
/>
))}
@@ -1982,9 +2076,11 @@ export function DonutChartDisplay({
-
- Auth Methods
-
+
+
+ Auth Methods
+
+
{!compact && (
Login distribution
@@ -2092,6 +2188,7 @@ export function EmailStackedBarChartDisplay({
}) {
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState
(null);
+ const chartMotion = useChartMotionProps();
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
const totals = datapoints.map(p => p.ok + p.error + p.in_progress);
@@ -2154,7 +2251,7 @@ export function EmailStackedBarChartDisplay({
};
const colorVar = dataKey === "ok" ? "ok" : dataKey === "in_progress" ? "in_progress" : "error";
return (
-
+
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@@ -2182,7 +2279,7 @@ export function EmailStackedBarChartDisplay({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={false}
- isAnimationActive={false}
+ {...chartMotion}
connectNulls={false}
legendType="none"
/>
@@ -2195,7 +2292,7 @@ export function EmailStackedBarChartDisplay({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
- isAnimationActive={false}
+ {...chartMotion}
connectNulls={false}
legendType="none"
/>
@@ -2318,6 +2415,7 @@ export function VisitorsHoverChart({
compact?: boolean,
}) {
const [hoveredIndex, setHoveredIndex] = useState(null);
+ const chartMotion = useChartMotionProps();
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
const totals = datapoints.map((p) => p.page_views);
const avgValues = rollingAvg(totals, windowSize);
@@ -2363,7 +2461,7 @@ export function VisitorsHoverChart({
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
/>
-
+
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@@ -2386,7 +2484,7 @@ export function VisitorsHoverChart({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={false}
- isAnimationActive={false}
+ {...chartMotion}
connectNulls={false}
legendType="none"
/>
@@ -2399,7 +2497,7 @@ export function VisitorsHoverChart({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
- isAnimationActive={false}
+ {...chartMotion}
connectNulls={false}
legendType="none"
/>
@@ -2528,6 +2626,7 @@ export function RevenueHoverChart({
compact?: boolean,
}) {
const [hoveredIndex, setHoveredIndex] = useState(null);
+ const chartMotion = useChartMotionProps();
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
const totals = datapoints.map((p) => p.new_cents + p.refund_cents);
const avgValues = rollingAvg(totals, windowSize);
@@ -2578,7 +2677,7 @@ export function RevenueHoverChart({
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
/>
-
+
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@@ -2592,7 +2691,7 @@ export function RevenueHoverChart({
);
})}
-
+
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@@ -2606,7 +2705,7 @@ export function RevenueHoverChart({
);
})}
-
+
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@@ -2629,7 +2728,7 @@ export function RevenueHoverChart({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={false}
- isAnimationActive={false}
+ {...chartMotion}
connectNulls={false}
legendType="none"
/>
@@ -2642,7 +2741,7 @@ export function RevenueHoverChart({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
- isAnimationActive={false}
+ {...chartMotion}
connectNulls={false}
legendType="none"
/>
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx
index ca0766417..7b2153abf 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx
@@ -1,27 +1,54 @@
'use client';
import { AppIcon } from "@/components/app-square";
-import { DesignAnalyticsCard, DesignCategoryTabs, DesignChartLegend, useInfiniteListWindow } from "@/components/design-components";
+import { DesignAnalyticsCard, DesignCategoryTabs, DesignChartLegend, DesignPillToggle, useInfiniteListWindow } from "@/components/design-components";
import { Link } from "@/components/link";
import { useRouter } from "@/components/router";
-import { cn, Typography } from "@/components/ui";
+import { cn, SimpleTooltip, Typography } from "@/components/ui";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { ALL_APPS_FRONTEND, type AppId, getAppPath } from "@/lib/apps-frontend";
import { getEnabledAppIds } from "@/lib/apps-utils";
import {
+ type AnalyticsOverviewFilters,
type MetricsEmailOverview,
type MetricsRecentEmail,
- type MetricsTopReferrer,
useMetricsOrThrow,
} from "@/lib/hexclave-app-internals";
-import { CompassIcon, EnvelopeIcon, EnvelopeOpenIcon, GlobeIcon, SquaresFourIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
+import {
+ ChartLineIcon,
+ CompassIcon,
+ DesktopIcon,
+ DeviceMobileIcon,
+ DeviceTabletIcon,
+ EnvelopeIcon,
+ EnvelopeOpenIcon,
+ FunnelIcon,
+ GearIcon,
+ GlobeIcon,
+ MonitorIcon,
+ SquaresFourIcon,
+ WarningCircleIcon,
+ XCircleIcon,
+ XIcon,
+} from "@phosphor-icons/react";
import useResizeObserver from '@react-hook/resize-observer';
import { useUser } from "@hexclave/next";
import { ALL_APPS } from "@hexclave/shared/dist/apps/apps-config";
import { stringCompare } from "@hexclave/shared/dist/utils/strings";
+import { LayoutGroup, motion, useReducedMotion, type Transition } from "motion/react";
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
-import { type ElementType, Suspense, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { type ElementType, type ReactNode, Suspense, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { AnalyticsEventLimitBanner } from "../analytics/shared";
import { PageLayout } from "../page-layout";
import { useAdminApp, useProjectId } from "../use-admin-app";
+import { UserPageMetricCard } from "../users/[userId]/user-page-metric-card";
import { GlobeSectionWithData } from "./globe-section-with-data";
import {
ComposedAnalyticsChart,
@@ -43,6 +70,13 @@ import {
VisitorsHoverDataPoint
} from "./line-chart";
import { MetricsErrorFallback, MetricsLoadingFallback } from "./metrics-loading";
+import { ReferrersWithAnalyticsCard, TopNamedListCard, TopRegionsCard } from "./top-lists";
+import {
+ ANALYTICS_CHART_METRIC_MODE_ORDER,
+ toggleAnalyticsChartMetricMode,
+ type AnalyticsChartMetricMode,
+ type AnalyticsChartMode,
+} from "./analytics-chart-mode";
const dailySignUpsConfig: LineChartDisplayConfig = {
name: 'Daily Sign-Ups',
@@ -71,6 +105,168 @@ function formatCompact(n: number): string {
return n.toLocaleString();
}
+function pagesPerVisitor(pageViews: number, visitors: number): number {
+ return visitors > 0 ? pageViews / visitors : 0;
+}
+
+function formatPagesPerVisitor(value: number): string {
+ if (!Number.isFinite(value)) return "0.0";
+ return value.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
+}
+
+const OVERVIEW_HEADER_COMPACT_SCROLL_TOP = 24;
+const OVERVIEW_HEADER_MORPH_MS = 520;
+const OVERVIEW_HEADER_TITLE_EXIT_MS = 150;
+const overviewHeaderLayoutTransition: Transition = {
+ duration: OVERVIEW_HEADER_MORPH_MS / 1000,
+ ease: [0.32, 0.72, 0, 1],
+};
+const reducedOverviewHeaderLayoutTransition: Transition = {
+ duration: 0,
+};
+
+const scrollableOverflowValues = new Set(["auto", "scroll", "overlay"]);
+
+function findScrollContainer(element: HTMLElement): HTMLElement | null {
+ let current = element.parentElement;
+ while (current != null) {
+ const overflowY = window.getComputedStyle(current).overflowY;
+ if (scrollableOverflowValues.has(overflowY) && current.scrollHeight > current.clientHeight) {
+ return current;
+ }
+ current = current.parentElement;
+ }
+
+ return null;
+}
+
+function useOverviewHeaderCompacted(enabled: boolean) {
+ const sentinelRef = useRef(null);
+ const [compacted, setCompacted] = useState(false);
+
+ useEffect(() => {
+ if (!enabled) {
+ setCompacted(false);
+ return;
+ }
+
+ const sentinel = sentinelRef.current;
+ if (sentinel == null) return;
+
+ const scrollContainer = findScrollContainer(sentinel);
+
+ const observer = new IntersectionObserver((entries) => {
+ const entry = entries[0];
+ const nextCompacted = !entry.isIntersecting;
+ setCompacted((current) => current === nextCompacted ? current : nextCompacted);
+ }, {
+ root: scrollContainer,
+ rootMargin: `-${OVERVIEW_HEADER_COMPACT_SCROLL_TOP}px 0px 0px 0px`,
+ threshold: 0,
+ });
+
+ observer.observe(sentinel);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [enabled]);
+
+ return { compacted, sentinelRef };
+}
+
+function useRenderWhileClosing(open: boolean, durationMs: number): boolean {
+ const [shouldRender, setShouldRender] = useState(open);
+
+ useEffect(() => {
+ if (open) {
+ setShouldRender(true);
+ return;
+ }
+
+ const timeout = setTimeout(() => setShouldRender(false), durationMs);
+ return () => clearTimeout(timeout);
+ }, [durationMs, open]);
+
+ return open || shouldRender;
+}
+
+function useDelayedTrue(value: boolean, delayMs: number): boolean {
+ const [delayedValue, setDelayedValue] = useState(value);
+
+ useEffect(() => {
+ if (!value) {
+ setDelayedValue(false);
+ return;
+ }
+
+ const timeout = setTimeout(() => setDelayedValue(true), delayMs);
+ return () => clearTimeout(timeout);
+ }, [delayMs, value]);
+
+ return delayedValue;
+}
+
+const BROWSER_SLUGS = new Map([
+ ["chrome", "googlechrome"],
+ ["google chrome", "googlechrome"],
+ ["firefox", "firefox"],
+ ["safari", "safari"],
+ ["edge", "microsoftedge"],
+ ["microsoft edge", "microsoftedge"],
+ ["opera", "opera"],
+ ["samsung internet", "samsung"],
+ ["brave", "brave"],
+ ["vivaldi", "vivaldi"],
+ ["duckduckgo", "duckduckgo"],
+]);
+
+const OS_SLUGS = new Map([
+ ["macos", "apple"],
+ ["ios", "apple"],
+ ["ipados", "apple"],
+ ["windows", "windows11"],
+ ["android", "android"],
+ ["linux", "linux"],
+ ["ubuntu", "ubuntu"],
+ ["chromeos", "googlechrome"],
+]);
+
+function BrandIcon({ slug }: { slug: string | undefined }) {
+ const [failed, setFailed] = useState(false);
+ if (!slug || failed) {
+ return ;
+ }
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
setFailed(true)}
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-90 [filter:invert(0)] dark:[filter:invert(1)_hue-rotate(180deg)]"
+ />
+ );
+}
+
+function browserIcon(name: string): ReactNode {
+ return ;
+}
+
+function osIcon(name: string): ReactNode {
+ return ;
+}
+
+function deviceIcon(name: string): ReactNode {
+ const key = name.toLowerCase().trim();
+ if (key === "mobile") return ;
+ if (key === "tablet") return ;
+ return ;
+}
+
function calculatePeriodDelta(currentValue: number, previousValue: number): number | undefined {
if (!Number.isFinite(currentValue) || !Number.isFinite(previousValue)) {
return undefined;
@@ -113,57 +309,465 @@ function SetupAppPrompt({
);
}
-type AnalyticsStatPill = {
- label: string,
- value: string,
- delta?: number,
-};
+const FILTER_DIMENSIONS: Array = ["country_code", "referrer", "browser", "os", "device"];
-function StatCard({
- stat,
- compact = false,
+const FILTER_DIMENSION_LABELS = new Map([
+ ["country_code", "Country"],
+ ["referrer", "Referrer"],
+ ["browser", "Browser"],
+ ["os", "OS"],
+ ["device", "Device"],
+]);
+
+function analyticsFiltersKey(filters: AnalyticsOverviewFilters): string {
+ const params = new URLSearchParams();
+ for (const dimension of FILTER_DIMENSIONS) {
+ const value = filters[dimension];
+ if (value != null) {
+ params.set(dimension, value);
+ }
+ }
+ if (filters.since != null) params.set("since", filters.since);
+ if (filters.until != null) params.set("until", filters.until);
+ return params.toString();
+}
+
+// Matches getDateKey in line-chart.tsx: custom-range picker dates are
+// local-midnight Dates, and the daily series keys are "YYYY-MM-DD".
+function localDateKey(date: Date): string {
+ const year = String(date.getFullYear());
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+// Server-side date bounds for the top-N breakdowns (referrers, regions,
+// browsers/OS/devices), derived from the chart time range. Quantized to the
+// current UTC hour (1d) / UTC day (7d) so the metrics cache key stays stable
+// across renders instead of changing every millisecond.
+function analyticsDateRangeForTimeRange(
+ timeRange: TimeRange,
+ customDateRange: CustomDateRange | null,
+): Pick {
+ switch (timeRange) {
+ case "1d": {
+ const latestHour = new Date();
+ latestHour.setUTCMinutes(0, 0, 0);
+ return { since: new Date(latestHour.getTime() - 23 * 60 * 60 * 1000).toISOString() };
+ }
+ case "7d": {
+ const todayUtc = new Date();
+ todayUtc.setUTCHours(0, 0, 0, 0);
+ return { since: new Date(todayUtc.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString() };
+ }
+ case "30d":
+ case "all": {
+ return {};
+ }
+ case "custom": {
+ if (customDateRange == null) {
+ return {};
+ }
+ const untilExclusive = new Date(new Date(`${localDateKey(customDateRange.to)}T00:00:00.000Z`).getTime() + 24 * 60 * 60 * 1000);
+ return {
+ since: `${localDateKey(customDateRange.from)}T00:00:00.000Z`,
+ until: untilExclusive.toISOString(),
+ };
+ }
+ }
+}
+
+function getFilterDimensionLabel(dimension: keyof AnalyticsOverviewFilters): string {
+ const label = FILTER_DIMENSION_LABELS.get(dimension);
+ if (label == null) {
+ throw new Error(`Missing analytics filter dimension label: ${dimension}`);
+ }
+ return label;
+}
+
+function hasAnalyticsFilters(filters: AnalyticsOverviewFilters): boolean {
+ return FILTER_DIMENSIONS.some((dimension) => filters[dimension] != null);
+}
+
+function FilterChipsBar({
+ filters,
+ onClear,
+ onClearAll,
}: {
- stat: AnalyticsStatPill,
- compact?: boolean,
+ filters: AnalyticsOverviewFilters,
+ onClear: (dimension: keyof AnalyticsOverviewFilters) => void,
+ onClearAll: () => void,
}) {
+ const entries = FILTER_DIMENSIONS.flatMap((dimension) => {
+ const value = filters[dimension];
+ return value != null ? [{ dimension, value }] : [];
+ });
+ if (entries.length === 0) return null;
+
return (
-
-
-
- {stat.label}
+
+ {entries.map(({ dimension, value }) => (
+
+ {getFilterDimensionLabel(dimension)}:
+ {value}
+
-
-
- {stat.value}
-
- {stat.delta != null && (
- 0 ? "text-emerald-600 dark:text-emerald-400" : stat.delta < 0 ? "text-red-500 dark:text-red-400" : "text-muted-foreground"
- )}>
- {stat.delta > 0 ? "+" : ""}{stat.delta}%
-
- )}
-
-
-
+ ))}
+ {entries.length > 1 && (
+
+ )}
+
);
}
-type AnalyticsChartMode = 'default' | 'dau' | 'visitors' | 'revenue';
+type FilterOption = {
+ value: string,
+ label: string,
+};
+
+type FilterDimensionConfig = {
+ key: keyof AnalyticsOverviewFilters,
+ label: string,
+ options: FilterOption[],
+};
+
+function FilterMenuButton({ active }: { active: boolean }) {
+ return (
+
+
+
+ );
+}
+
+function FilterMenu({
+ filters,
+ onToggle,
+}: {
+ filters: AnalyticsOverviewFilters,
+ onToggle: (dimension: keyof AnalyticsOverviewFilters, value: string) => void,
+}) {
+ const active = hasAnalyticsFilters(filters);
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+ {
+ onToggle(dimension, value);
+ setOpen(false);
+ }}
+ />
+
+
+ );
+}
+
+function FilterMenuContent({
+ filters,
+ onSelect,
+}: {
+ filters: AnalyticsOverviewFilters,
+ onSelect: (dimension: keyof AnalyticsOverviewFilters, value: string) => void,
+}) {
+ const adminApp = useAdminApp();
+ // Read unfiltered metrics here so the menu keeps offering the full value set.
+ // The visible overview preloads filtered data separately before swapping.
+ const data = useMetricsOrThrow(adminApp, false);
+ const analytics = data.analytics_overview;
+
+ const dimensions = useMemo(() => [
+ { key: "country_code", label: "Country", options: analytics.top_regions.slice(0, 15).map((r) => ({ value: r.country_code.toUpperCase(), label: r.country_code.toUpperCase() })) },
+ { key: "referrer", label: "Referrer", options: analytics.top_referrers.slice(0, 15).map((r) => ({ value: r.referrer, label: r.referrer || "(direct)" })) },
+ { key: "browser", label: "Browser", options: analytics.top_browsers.slice(0, 15).map((b) => ({ value: b.name, label: b.name })) },
+ { key: "os", label: "OS", options: analytics.top_operating_systems.slice(0, 15).map((o) => ({ value: o.name, label: o.name })) },
+ { key: "device", label: "Device", options: analytics.top_devices.slice(0, 15).map((d) => ({ value: d.name, label: d.name })) },
+ ], [analytics.top_browsers, analytics.top_devices, analytics.top_operating_systems, analytics.top_referrers, analytics.top_regions]);
+
+ const firstAvailableDimension = dimensions.find((dimension) => dimension.options.length > 0)?.key ?? "country_code";
+ const [selectedDimension, setSelectedDimension] = useState(firstAvailableDimension);
+ const selectedConfig = dimensions.find((dimension) => dimension.key === selectedDimension);
+ if (selectedConfig == null) {
+ throw new Error(`Missing analytics filter dimension: ${selectedDimension}`);
+ }
+ const selectedFilterValue = filters[selectedConfig.key];
+
+ return (
+
+
+
+ Filter analytics by
+
+
+
+
+ {dimensions.map((dimension) => {
+ const isSelected = dimension.key === selectedDimension;
+ const activeValue = filters[dimension.key];
+ return (
+
+ );
+ })}
+
+
+
+
+
{selectedConfig.label}
+ {selectedFilterValue != null && (
+
+ Current: {selectedFilterValue}
+
+ )}
+
+ {selectedFilterValue != null && (
+
+ )}
+
+
+ {selectedConfig.options.length === 0 ? (
+
+ No values
+
+ ) : (
+ selectedConfig.options.map((option) => {
+ const isActive = selectedFilterValue === option.value;
+ return (
+
+ );
+ })
+ )}
+
+
+
+
+
+ );
+}
+
+function ViewToggle({ view, onChange }: { view: "overview" | "globe", onChange: (view: "overview" | "globe") => void }) {
+ return (
+ {
+ if (id === "overview" || id === "globe") {
+ onChange(id);
+ return;
+ }
+ throw new Error(`Unsupported project overview view selected: ${id}`);
+ }}
+ />
+ );
+}
+
+function OverviewHeaderChrome({
+ title,
+ actions,
+ compacted,
+ layoutCompacted,
+ renderTitle,
+ layoutTransition,
+ animateLayout,
+}: {
+ title: string,
+ actions: ReactNode,
+ compacted: boolean,
+ layoutCompacted: boolean,
+ renderTitle: boolean,
+ layoutTransition: Transition,
+ animateLayout: boolean,
+}) {
+ return (
+
+
+
+
+ {renderTitle && (
+
+
+ {title}
+
+
+ )}
+
+ {actions}
+
+
+
+ );
+}
+
+function OverviewHeader({ title, actions, sticky }: { title: string, actions: ReactNode, sticky: boolean }) {
+ const { compacted, sentinelRef } = useOverviewHeaderCompacted(sticky);
+ const renderTitle = useRenderWhileClosing(!compacted, OVERVIEW_HEADER_TITLE_EXIT_MS);
+ const shouldReduceMotion = useReducedMotion();
+ const delayedCompacted = useDelayedTrue(compacted, shouldReduceMotion ? 0 : OVERVIEW_HEADER_TITLE_EXIT_MS);
+ const layoutCompacted = sticky && (shouldReduceMotion ? compacted : delayedCompacted);
+ const layoutTransition = shouldReduceMotion ? reducedOverviewHeaderLayoutTransition : overviewHeaderLayoutTransition;
+
+ return (
+ <>
+ {sticky && (
+
+ )}
+
+
+
+ >
+ );
+}
+
+function GlobeView({ includeAnonymous }: { includeAnonymous: boolean }) {
+ // Fills the height granted by PageLayout's containedHeight mode (the globe
+ // tab sets it) instead of guessing the chrome height with 100vh math, which
+ // left a slight page scroll whenever the guess was off.
+ return (
+
+
+
+ );
+}
function AnalyticsInChartPill({
label,
value,
delta,
color,
+ isHighlighted,
isSelected,
controlsId,
tabId,
- onActivate,
+ onToggle,
onHoverPreview,
onHoverEnd,
onArrowNavigate,
@@ -172,26 +776,30 @@ function AnalyticsInChartPill({
value: string,
delta?: number,
color: string,
+ isHighlighted: boolean,
isSelected: boolean,
controlsId: string,
tabId: string,
- onActivate: () => void,
+ onToggle: () => void,
onHoverPreview: () => void,
onHoverEnd: () => void,
onArrowNavigate: (direction: 'next' | 'prev' | 'first' | 'last') => void,
}) {
+ const tooltipByLabel = new Map([
+ ["Daily Active Users", "Shows active users by day so you can see current product usage."],
+ ["Visitors", "Sums each day's unique visitors across the selected period, so returning visitors count once per day."],
+ ["Revenue", "Shows new revenue from payments for the selected period."],
+ ]);
+
return (