Update dependencies and enhance chart animations

- Added `framer-motion` version 12.39.0 to `pnpm-lock.yaml` and `package.json` for improved animation capabilities.
- Integrated motion properties into various chart components in `line-chart.tsx` and `metrics-page.tsx` to enhance user experience with smoother transitions.
- Refactored sparkline component in `user-page-metric-card.tsx` to support motion effects, improving visual feedback during data updates.
- Introduced utility functions for handling reduced motion preferences to ensure accessibility compliance.
This commit is contained in:
mantrakp04 2026-05-27 15:05:50 -07:00
parent 3f03d947ce
commit 174f3a4c28
5 changed files with 484 additions and 188 deletions

View File

@ -86,6 +86,7 @@
"jose": "^6.1.3",
"libsodium-wrappers": "^0.8.2",
"lodash": "^4.17.21",
"motion": "^12.39.0",
"next": "16.1.7",
"next-themes": "^0.2.1",
"posthog-js": "^1.336.1",

View File

@ -22,7 +22,7 @@ import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { UserAvatar } from '@stackframe/stack';
import { fromNow, isWeekend } from '@stackframe/stack-shared/dist/utils/dates';
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts";
export type CustomDateRange = {
@ -253,6 +253,7 @@ export function ActivityBarChart({
}) {
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const chartMotion = useChartMotionProps();
return (
<ChartContainer
@ -290,7 +291,7 @@ export function ActivityBarChart({
dataKey="activity"
fill="var(--color-activity)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
{...chartMotion}
>
{datapoints.map((entry, index) => {
const isWeekendDay = isWeekend(parseChartDate(entry.date));
@ -344,6 +345,7 @@ export function MiniActivityBarChart({
}) {
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const chartMotion = useChartMotionProps();
return (
<ChartContainer
@ -381,7 +383,7 @@ export function MiniActivityBarChart({
dataKey="activity"
fill="var(--color-activity)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
{...chartMotion}
>
{datapoints.map((entry, index) => {
const isActiveBar = hoveredIndex === index;
@ -450,6 +452,7 @@ export function MiniNamedBarChart({
}) {
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const chartMotion = useChartMotionProps();
return (
<ChartContainer
@ -487,7 +490,7 @@ export function MiniNamedBarChart({
dataKey="activity"
fill="var(--color-activity)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
{...chartMotion}
>
{datapoints.map((entry, index) => {
const isActiveBar = hoveredIndex === index;
@ -622,6 +625,7 @@ export function StackedBarChartDisplay({
}) {
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const chartMotion = useChartMotionProps();
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
const totals = datapoints.map(p => p.new + p.retained + p.reactivated);
@ -675,7 +679,7 @@ export function StackedBarChartDisplay({
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
/>
<Bar dataKey="retained" stackId="split" fill="var(--color-retained)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
<Bar dataKey="retained" stackId="split" fill="var(--color-retained)" radius={[0, 0, 0, 0]} {...chartMotion}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@ -689,7 +693,7 @@ export function StackedBarChartDisplay({
);
})}
</Bar>
<Bar dataKey="reactivated" stackId="split" fill="var(--color-reactivated)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
<Bar dataKey="reactivated" stackId="split" fill="var(--color-reactivated)" radius={[0, 0, 0, 0]} {...chartMotion}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@ -703,7 +707,7 @@ export function StackedBarChartDisplay({
);
})}
</Bar>
<Bar dataKey="new" stackId="split" fill="var(--color-new)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
<Bar dataKey="new" stackId="split" fill="var(--color-new)" radius={[4, 4, 0, 0]} {...chartMotion}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@ -726,7 +730,7 @@ export function StackedBarChartDisplay({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={false}
isAnimationActive={false}
{...chartMotion}
connectNulls={false}
legendType="none"
/>
@ -739,7 +743,7 @@ export function StackedBarChartDisplay({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
isAnimationActive={false}
{...chartMotion}
connectNulls={false}
legendType="none"
/>
@ -792,69 +796,50 @@ export type ComposedDataPoint = {
_showRevenue?: boolean,
};
const OVERVIEW_CHART_ANIMATION_MS = 260;
const OVERVIEW_CHART_ANIMATION_MS = 520;
function interpolateNumber(from: number | undefined, to: number, progress: number): number {
return (from ?? 0) + (to - (from ?? 0)) * progress;
}
type ChartMotionProps = {
isAnimationActive: boolean,
animationBegin: number,
animationDuration: number,
animationEasing: "ease-out",
};
function easeOutCubic(progress: number): number {
return 1 - Math.pow(1 - progress, 3);
}
const enabledChartMotion: ChartMotionProps = {
isAnimationActive: true,
animationBegin: 0,
animationDuration: OVERVIEW_CHART_ANIMATION_MS,
animationEasing: "ease-out",
};
function prefersReducedMotion(): boolean {
return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
const disabledChartMotion: ChartMotionProps = {
isAnimationActive: false,
animationBegin: 0,
animationDuration: 0,
animationEasing: "ease-out",
};
function useAnimatedComposedDatapoints(datapoints: ComposedDataPoint[]): ComposedDataPoint[] {
const [animatedDatapoints, setAnimatedDatapoints] = useState(datapoints);
const previousDatapointsRef = useRef(datapoints);
function usePrefersReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
if (prefersReducedMotion()) {
previousDatapointsRef.current = datapoints;
setAnimatedDatapoints(datapoints);
if (typeof window.matchMedia !== "function") {
return;
}
const previousByDate = new Map(previousDatapointsRef.current.map((point) => [point.date, point]));
const previousByIndex: Array<ComposedDataPoint | undefined> = previousDatapointsRef.current;
const startedAt = performance.now();
let frameId: number | null = null;
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
const renderFrame = (now: number) => {
const linearProgress = Math.min(1, (now - startedAt) / OVERVIEW_CHART_ANIMATION_MS);
const progress = easeOutCubic(linearProgress);
setAnimatedDatapoints(datapoints.map((point, index) => {
const previous = previousByDate.get(point.date) ?? previousByIndex[index];
return {
...point,
page_views: interpolateNumber(previous?.page_views, point.page_views, progress),
visitors: interpolateNumber(previous?.visitors, point.visitors, progress),
dau: interpolateNumber(previous?.dau, point.dau, progress),
new_cents: interpolateNumber(previous?.new_cents, point.new_cents, progress),
refund_cents: interpolateNumber(previous?.refund_cents, point.refund_cents, progress),
};
}));
updatePrefersReducedMotion();
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
}, []);
if (linearProgress < 1) {
frameId = requestAnimationFrame(renderFrame);
return;
}
return prefersReducedMotion;
}
previousDatapointsRef.current = datapoints;
setAnimatedDatapoints(datapoints);
};
frameId = requestAnimationFrame(renderFrame);
return () => {
if (frameId != null) {
cancelAnimationFrame(frameId);
}
};
}, [datapoints]);
return animatedDatapoints;
function useChartMotionProps(): ChartMotionProps {
return usePrefersReducedMotion() ? disabledChartMotion : enabledChartMotion;
}
export type VisitorsHoverDataPoint = {
@ -900,12 +885,6 @@ const composedChartConfig: ChartConfig = {
},
};
const overviewChartAnimation = {
isAnimationActive: true,
animationDuration: OVERVIEW_CHART_ANIMATION_MS,
animationEasing: "ease-out" as const,
};
function ComposedTooltip({ active, payload }: TooltipProps<number, string>) {
if (!active || !payload?.length) return null;
@ -1016,13 +995,13 @@ export function ComposedAnalyticsChart({
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [hoveredX, setHoveredX] = useState<number | null>(null);
const animatedDatapoints = useAnimatedComposedDatapoints(datapoints);
const chartMotion = useChartMotionProps();
const taggedDatapoints = useMemo(
() => animatedDatapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })),
[animatedDatapoints, showPageViews, showVisitors, showRevenue],
() => datapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })),
[datapoints, showPageViews, showVisitors, showRevenue],
);
const maxVisitors = Math.max(...animatedDatapoints.map(d => Math.max(showPageViews ? d.page_views : 0, showVisitors ? d.visitors : 0, d.dau)), 1);
const maxRevenueCents = Math.max(...animatedDatapoints.map(d => showRevenue ? d.new_cents : 0), 1);
const maxVisitors = Math.max(...datapoints.map(d => Math.max(showPageViews ? d.page_views : 0, showVisitors ? d.visitors : 0, d.dau)), 1);
const maxRevenueCents = Math.max(...datapoints.map(d => showRevenue ? d.new_cents : 0), 1);
const visitorTicks = niceAxisTicks(Math.ceil(maxVisitors * 1.1), 5);
const revenueTicks = niceAxisTicks(Math.ceil(maxRevenueCents * 1.15), 5);
const visitorsMax = visitorTicks[visitorTicks.length - 1] ?? maxVisitors;
@ -1039,7 +1018,7 @@ export function ComposedAnalyticsChart({
data={taggedDatapoints}
margin={{ top: 10, right: 4, left: 4, bottom: 0 }}
onMouseMove={(state) => {
updateHoveredIndexFromChartState(state, animatedDatapoints.length, setHoveredIndex);
updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex);
setHoveredX(getActiveCoordinateX(state));
}}
onMouseLeave={() => {
@ -1090,7 +1069,7 @@ export function ComposedAnalyticsChart({
fill="var(--color-page_views)"
fillOpacity={showPageViews ? (hoveredIndex == null ? 0.18 : 0.08) : 0}
radius={[4, 4, 0, 0]}
isAnimationActive={false}
{...chartMotion}
/>
{showPageViews && hoveredIndex != null && hoveredX != null && (
<Bar
@ -1115,7 +1094,7 @@ export function ComposedAnalyticsChart({
strokeOpacity={showVisitors ? (hoveredIndex == null ? 1 : 0.22) : 0}
dot={false}
activeDot={showVisitors ? <HighlightedLineDot fill="var(--color-visitors)" /> : false}
{...overviewChartAnimation}
{...chartMotion}
/>
{showVisitors && hoveredIndex != null && hoveredX != null && (
<Line
@ -1127,7 +1106,7 @@ export function ComposedAnalyticsChart({
strokeOpacity={1}
dot={false}
activeDot={<HighlightedLineDot fill="var(--color-visitors)" />}
{...overviewChartAnimation}
isAnimationActive={false}
strokeLinecap="round"
strokeLinejoin="round"
style={{ clipPath: `url(#visitors-highlight-clip-${id})` }}
@ -1143,7 +1122,7 @@ export function ComposedAnalyticsChart({
strokeOpacity={hoveredIndex == null ? 0.95 : 0.24}
dot={false}
activeDot={<HighlightedLineDot fill="var(--color-dau)" />}
{...overviewChartAnimation}
{...chartMotion}
/>
{hoveredIndex != null && hoveredX != null && (
<Line
@ -1155,7 +1134,7 @@ export function ComposedAnalyticsChart({
strokeOpacity={1}
dot={false}
activeDot={<HighlightedLineDot fill="var(--color-dau)" />}
{...overviewChartAnimation}
isAnimationActive={false}
strokeLinecap="round"
strokeLinejoin="round"
style={{ clipPath: `url(#dau-highlight-clip-${id})` }}
@ -1172,7 +1151,7 @@ export function ComposedAnalyticsChart({
strokeDasharray="4 4"
dot={false}
activeDot={showRevenue ? <HighlightedLineDot fill="var(--color-revenue)" /> : false}
{...overviewChartAnimation}
{...chartMotion}
/>
{showRevenue && hoveredIndex != null && hoveredX != null && (
<Line
@ -1185,7 +1164,7 @@ export function ComposedAnalyticsChart({
strokeDasharray="4 4"
dot={false}
activeDot={<HighlightedLineDot fill="var(--color-revenue)" />}
{...overviewChartAnimation}
isAnimationActive={false}
strokeLinecap="round"
strokeLinejoin="round"
style={{ clipPath: `url(#revenue-highlight-clip-${id})` }}
@ -1213,7 +1192,7 @@ export function ComposedAnalyticsChart({
tickMargin={compact ? 4 : 6}
axisLine={false}
padding={{ left: 8, right: 8 }}
interval={animatedDatapoints.length <= 7 ? 0 : "equidistantPreserveStart"}
interval={datapoints.length <= 7 ? 0 : "equidistantPreserveStart"}
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }}
tickFormatter={(value) => formatChartXAxisTick(value)}
/>
@ -2099,6 +2078,7 @@ export function CorrelationCard({
const chartConfig: ChartConfig = Object.fromEntries(
series.map(s => [s.key, { label: s.label, color: s.color }])
);
const chartMotion = useChartMotionProps();
return (
<ChartCard gradientColor={gradientColor} className={cn("h-full", className)}>
@ -2164,7 +2144,7 @@ export function CorrelationCard({
stroke={s.color}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
{...chartMotion}
/>
))}
</LineChart>
@ -2398,6 +2378,7 @@ export function EmailStackedBarChartDisplay({
}) {
const id = useId();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const chartMotion = useChartMotionProps();
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
const totals = datapoints.map(p => p.ok + p.error + p.in_progress);
@ -2460,7 +2441,7 @@ export function EmailStackedBarChartDisplay({
};
const colorVar = dataKey === "ok" ? "ok" : dataKey === "in_progress" ? "in_progress" : "error";
return (
<Bar key={dataKey} dataKey={dataKey} stackId="split" fill={`var(--color-${colorVar})`} radius={[4, 4, 0, 0]} isAnimationActive={false}>
<Bar key={dataKey} dataKey={dataKey} stackId="split" fill={`var(--color-${colorVar})`} radius={[4, 4, 0, 0]} {...chartMotion}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@ -2488,7 +2469,7 @@ export function EmailStackedBarChartDisplay({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={false}
isAnimationActive={false}
{...chartMotion}
connectNulls={false}
legendType="none"
/>
@ -2501,7 +2482,7 @@ export function EmailStackedBarChartDisplay({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
isAnimationActive={false}
{...chartMotion}
connectNulls={false}
legendType="none"
/>
@ -2624,6 +2605,7 @@ export function VisitorsHoverChart({
compact?: boolean,
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const chartMotion = useChartMotionProps();
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
const totals = datapoints.map((p) => p.page_views);
const avgValues = rollingAvg(totals, windowSize);
@ -2669,7 +2651,7 @@ export function VisitorsHoverChart({
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
/>
<Bar dataKey="page_views" stackId="visitors" fill="var(--color-page_views)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
<Bar dataKey="page_views" stackId="visitors" fill="var(--color-page_views)" radius={[4, 4, 0, 0]} {...chartMotion}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@ -2692,7 +2674,7 @@ export function VisitorsHoverChart({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={false}
isAnimationActive={false}
{...chartMotion}
connectNulls={false}
legendType="none"
/>
@ -2705,7 +2687,7 @@ export function VisitorsHoverChart({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
isAnimationActive={false}
{...chartMotion}
connectNulls={false}
legendType="none"
/>
@ -2834,6 +2816,7 @@ export function RevenueHoverChart({
compact?: boolean,
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const chartMotion = useChartMotionProps();
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
const totals = datapoints.map((p) => p.new_cents + p.refund_cents);
const avgValues = rollingAvg(totals, windowSize);
@ -2884,7 +2867,7 @@ export function RevenueHoverChart({
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
/>
<Bar dataKey="new_cents_square" stackId="revenue" fill="var(--color-new_cents)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
<Bar dataKey="new_cents_square" stackId="revenue" fill="var(--color-new_cents)" radius={[0, 0, 0, 0]} {...chartMotion}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@ -2898,7 +2881,7 @@ export function RevenueHoverChart({
);
})}
</Bar>
<Bar dataKey="new_cents_rounded" stackId="revenue" fill="var(--color-new_cents)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
<Bar dataKey="new_cents_rounded" stackId="revenue" fill="var(--color-new_cents)" radius={[4, 4, 0, 0]} {...chartMotion}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@ -2912,7 +2895,7 @@ export function RevenueHoverChart({
);
})}
</Bar>
<Bar dataKey="refund_cents" stackId="revenue" fill="var(--color-refund_cents)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
<Bar dataKey="refund_cents" stackId="revenue" fill="var(--color-refund_cents)" radius={[4, 4, 0, 0]} {...chartMotion}>
{datapoints.map((entry, index) => {
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
const isActiveBar = hoveredIndex === index;
@ -2935,7 +2918,7 @@ export function RevenueHoverChart({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={false}
isAnimationActive={false}
{...chartMotion}
connectNulls={false}
legendType="none"
/>
@ -2948,7 +2931,7 @@ export function RevenueHoverChart({
strokeDasharray="2.5 3.5"
dot={false}
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
isAnimationActive={false}
{...chartMotion}
connectNulls={false}
legendType="none"
/>

View File

@ -42,6 +42,7 @@ import useResizeObserver from '@react-hook/resize-observer';
import { useUser } from "@stackframe/stack";
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
import { LayoutGroup, motion, useReducedMotion, type Transition } from "motion/react";
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
import { type ElementType, type ReactNode, Suspense, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AnalyticsEventLimitBanner } from "../analytics/shared";
@ -114,11 +115,98 @@ function formatPagesPerVisitor(value: number): string {
}
const OVERVIEW_WIDGET_ANIMATION_MS = 260;
const OVERVIEW_HEADER_COMPACT_SCROLL_TOP = 24;
const OVERVIEW_HEADER_MORPH_MS = 520;
const OVERVIEW_HEADER_TITLE_EXIT_MS = 150;
const overviewHeaderLayoutTransition: Transition = {
duration: OVERVIEW_HEADER_MORPH_MS / 1000,
ease: [0.32, 0.72, 0, 1],
};
const reducedOverviewHeaderLayoutTransition: Transition = {
duration: 0,
};
const scrollableOverflowValues = new Set(["auto", "scroll", "overlay"]);
function easeOutCubic(progress: number): number {
return 1 - Math.pow(1 - progress, 3);
}
function findScrollContainer(element: HTMLElement): HTMLElement | null {
let current = element.parentElement;
while (current != null) {
const overflowY = window.getComputedStyle(current).overflowY;
if (scrollableOverflowValues.has(overflowY) && current.scrollHeight > current.clientHeight) {
return current;
}
current = current.parentElement;
}
return null;
}
function useOverviewHeaderCompacted() {
const sentinelRef = useRef<HTMLDivElement>(null);
const [compacted, setCompacted] = useState(false);
useEffect(() => {
const sentinel = sentinelRef.current;
if (sentinel == null) return;
const scrollContainer = findScrollContainer(sentinel);
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
const nextCompacted = !entry.isIntersecting;
setCompacted((current) => current === nextCompacted ? current : nextCompacted);
}, {
root: scrollContainer,
rootMargin: `-${OVERVIEW_HEADER_COMPACT_SCROLL_TOP}px 0px 0px 0px`,
threshold: 0,
});
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, []);
return { compacted, sentinelRef };
}
function useRenderWhileClosing(open: boolean, durationMs: number): boolean {
const [shouldRender, setShouldRender] = useState(open);
useEffect(() => {
if (open) {
setShouldRender(true);
return;
}
const timeout = setTimeout(() => setShouldRender(false), durationMs);
return () => clearTimeout(timeout);
}, [durationMs, open]);
return open || shouldRender;
}
function useDelayedTrue(value: boolean, delayMs: number): boolean {
const [delayedValue, setDelayedValue] = useState(value);
useEffect(() => {
if (!value) {
setDelayedValue(false);
return;
}
const timeout = setTimeout(() => setDelayedValue(true), delayMs);
return () => clearTimeout(timeout);
}, [delayMs, value]);
return delayedValue;
}
function prefersReducedMotion(): boolean {
return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
@ -554,6 +642,86 @@ function ViewToggle({ view, onChange }: { view: "overview" | "globe", onChange:
);
}
function OverviewStickyHeader({ title, actions }: { title: string, actions: ReactNode }) {
const { compacted, sentinelRef } = useOverviewHeaderCompacted();
const renderTitle = useRenderWhileClosing(!compacted, OVERVIEW_HEADER_TITLE_EXIT_MS);
const shouldReduceMotion = useReducedMotion();
const delayedCompacted = useDelayedTrue(compacted, shouldReduceMotion ? 0 : OVERVIEW_HEADER_TITLE_EXIT_MS);
const layoutCompacted = shouldReduceMotion ? compacted : delayedCompacted;
const layoutTransition = shouldReduceMotion ? reducedOverviewHeaderLayoutTransition : overviewHeaderLayoutTransition;
return (
<>
<div 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]"
>
<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>
</LayoutGroup>
</div>
</>
);
}
function GlobeView({ includeAnonymous }: { includeAnonymous: boolean }) {
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">
@ -703,13 +871,6 @@ function AnalyticsChartWidget({
}) {
const [selectedMode, setSelectedMode] = useState<AnalyticsChartMode>('default');
const [previewMode, setPreviewMode] = useState<AnalyticsChartMode | null>(null);
const [displayMode, setDisplayMode] = useState<AnalyticsChartMode>('default');
const [fadingOut, setFadingOut] = useState(false);
const [fadingIn, setFadingIn] = useState(false);
const fadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fadeInRaf1Ref = useRef<number | null>(null);
const fadeInRaf2Ref = useRef<number | null>(null);
const FADE_OUT_MS = 140;
const tablistInstanceId = useId();
const tabpanelId = `${tablistInstanceId}-panel`;
@ -718,46 +879,7 @@ function AnalyticsChartWidget({
const revenueTabId = `${tablistInstanceId}-tab-revenue`;
const activeMode: AnalyticsChartMode = previewMode ?? selectedMode;
const switchToMode = (mode: AnalyticsChartMode) => {
if (mode === displayMode) return;
if (fadeTimerRef.current != null) {
clearTimeout(fadeTimerRef.current);
}
setFadingOut(true);
fadeTimerRef.current = setTimeout(() => {
setDisplayMode(mode);
setFadingOut(false);
setFadingIn(true);
fadeInRaf1Ref.current = requestAnimationFrame(() => {
fadeInRaf2Ref.current = requestAnimationFrame(() => {
setFadingIn(false);
fadeInRaf2Ref.current = null;
});
fadeInRaf1Ref.current = null;
});
fadeTimerRef.current = null;
}, FADE_OUT_MS);
};
useEffect(() => {
switchToMode(activeMode);
// eslint-disable-next-line react-hooks/exhaustive-deps -- switchToMode closes over displayMode/fade state
}, [activeMode]);
useEffect(() => {
return () => {
if (fadeTimerRef.current != null) {
clearTimeout(fadeTimerRef.current);
}
if (fadeInRaf1Ref.current != null) {
cancelAnimationFrame(fadeInRaf1Ref.current);
}
if (fadeInRaf2Ref.current != null) {
cancelAnimationFrame(fadeInRaf2Ref.current);
}
};
}, []);
const displayMode: AnalyticsChartMode = activeMode;
const handleHoverPreview = (mode: AnalyticsChartMetricMode) => {
setPreviewMode(mode);
@ -889,16 +1011,7 @@ function AnalyticsChartWidget({
className="flex-1 min-h-0 relative"
style={{ minHeight: chartViewportHeight }}
>
<div
className={cn(
"h-full flex flex-col",
fadingOut
? "opacity-0 -translate-y-0.5 transition-[opacity,transform] duration-[140ms] ease-in"
: fadingIn
? "opacity-0 translate-y-0.5"
: "opacity-100 translate-y-0 transition-[opacity,transform] duration-[260ms] ease-out",
)}
>
<div className="h-full flex flex-col">
{displayMode === 'default' && (
composedData.length === 0 ? (
<div className="flex items-center justify-center h-full">
@ -1284,31 +1397,31 @@ export default function MetricsPage(props: { toSetup: () => void }) {
const markAnalyticsFiltersLoaded = useCallback(() => {
setLoadedAnalyticsFilters(analyticsFilters);
}, [analyticsFilters]);
const headerTitle = `Welcome back, ${truncatedName}!`;
const headerActions = (
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{view === "overview" && (
<>
<FilterChipsBar filters={analyticsFilters} onClear={clearAnalyticsFilter} onClearAll={clearAllAnalyticsFilters} />
<FilterMenu filters={analyticsFilters} onToggle={toggleAnalyticsFilter} />
<TimeRangeToggle
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
customDateRange={customDateRange}
onCustomDateRangeChange={setCustomDateRange}
/>
</>
)}
<ViewToggle view={view} onChange={setView} />
</div>
);
return (
<PageLayout
title={`Welcome back, ${truncatedName}!`}
actions={
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{view === "overview" && (
<>
<FilterChipsBar filters={analyticsFilters} onClear={clearAnalyticsFilter} onClearAll={clearAllAnalyticsFilters} />
<FilterMenu filters={analyticsFilters} onToggle={toggleAnalyticsFilter} />
<TimeRangeToggle
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
customDateRange={customDateRange}
onCustomDateRangeChange={setCustomDateRange}
/>
</>
)}
<ViewToggle view={view} onChange={setView} />
</div>
}
fillWidth
fullBleed
wrapHeaderInCard
>
<OverviewStickyHeader title={headerTitle} actions={headerActions} />
{view === "overview" && <AnalyticsEventLimitBanner />}
{view === "overview" && isUpdatingAnalyticsFilters && (
<Suspense fallback={null}>

View File

@ -1,6 +1,6 @@
"use client";
import { useId } from "react";
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { DesignAnalyticsCard, type AnalyticsCardGradient } from "@/components/design-components";
import { SimpleTooltip } from "@/components/ui";
@ -50,27 +50,162 @@ const GRADIENT_STROKE: Record<AnalyticsCardGradient, string> = {
slate: "rgb(100 116 139)",
};
function Sparkline({ values, color }: { values: number[], color: string }) {
const gradId = `metric-spark-${useId()}`;
if (values.length < 2) return null;
const w = 100;
const h = 32;
const SPARKLINE_WIDTH = 100;
const SPARKLINE_HEIGHT = 32;
const SPARKLINE_PLOT_HEIGHT = SPARKLINE_HEIGHT - 2;
const SPARKLINE_BASELINE = SPARKLINE_HEIGHT - 1;
const SPARKLINE_ANIMATION_MS = 520;
const sparklineRestState = {
transform: "translate(0px, 0px) scale(1, 1)",
opacity: 1,
transitionEnabled: true,
};
type SparklineGeometry = {
valuesKey: string,
linePath: string,
areaPath: string,
min: number,
range: number,
pointCount: number,
};
type SparklineMotionState = {
transform: string,
opacity: number,
transitionEnabled: boolean,
};
function usePrefersReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return;
}
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
updatePrefersReducedMotion();
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
}, []);
return prefersReducedMotion;
}
function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function parseSparklineValues(valuesKey: string): number[] {
if (valuesKey.length === 0) {
return [];
}
return valuesKey.split(",").map((value) => Number(value));
}
function getSparklineGeometry(valuesKey: string): SparklineGeometry | null {
const values = parseSparklineValues(valuesKey);
if (values.length < 2) {
return null;
}
const max = Math.max(...values);
const min = Math.min(...values);
const flat = max === min;
const range = flat ? 1 : max - min;
const step = w / (values.length - 1);
const step = SPARKLINE_WIDTH / (values.length - 1);
const coords = values.map((v, i) => {
const x = i * step;
// Reserve 1px top/bottom so the stroke isn't clipped.
const y = flat ? h / 2 : h - 1 - ((v - min) / range) * (h - 2);
const y = flat ? SPARKLINE_HEIGHT / 2 : SPARKLINE_BASELINE - ((v - min) / range) * SPARKLINE_PLOT_HEIGHT;
return `${x.toFixed(2)},${y.toFixed(2)}`;
});
const linePath = `M${coords.join(" L")}`;
const areaPath = `${linePath} L${w},${h} L0,${h} Z`;
const areaPath = `${linePath} L${SPARKLINE_WIDTH},${SPARKLINE_HEIGHT} L0,${SPARKLINE_HEIGHT} Z`;
return {
valuesKey,
linePath,
areaPath,
min,
range,
pointCount: values.length,
};
}
function getInitialSparklineMotion(previous: SparklineGeometry, current: SparklineGeometry): SparklineMotionState {
if (previous.pointCount !== current.pointCount) {
return {
transform: "translate(0px, 3px) scale(0.98, 0.94)",
opacity: 0.72,
transitionEnabled: false,
};
}
const scaleY = clampNumber(current.range / previous.range, 0.35, 2.4);
const rawTranslateY = SPARKLINE_BASELINE
- scaleY * SPARKLINE_BASELINE
- ((current.min - previous.min) / previous.range) * SPARKLINE_PLOT_HEIGHT;
const translateY = clampNumber(rawTranslateY, -SPARKLINE_HEIGHT, SPARKLINE_HEIGHT);
return {
transform: `translate(0px, ${translateY.toFixed(2)}px) scale(1, ${scaleY.toFixed(4)})`,
opacity: 0.88,
transitionEnabled: false,
};
}
function useSparklineMotion(geometry: SparklineGeometry | null): SparklineMotionState {
const prefersReducedMotion = usePrefersReducedMotion();
const previousGeometryRef = useRef<SparklineGeometry | null>(null);
const [motionState, setMotionState] = useState<SparklineMotionState>(sparklineRestState);
useLayoutEffect(() => {
if (geometry == null) {
previousGeometryRef.current = null;
setMotionState(sparklineRestState);
return;
}
const previousGeometry = previousGeometryRef.current;
previousGeometryRef.current = geometry;
if (previousGeometry == null || previousGeometry.valuesKey === geometry.valuesKey || prefersReducedMotion) {
setMotionState(sparklineRestState);
return;
}
setMotionState(getInitialSparklineMotion(previousGeometry, geometry));
const frameId = requestAnimationFrame(() => setMotionState(sparklineRestState));
return () => cancelAnimationFrame(frameId);
}, [geometry, prefersReducedMotion]);
return motionState;
}
function Sparkline({ values, color }: { values: number[], color: string }) {
const gradId = `metric-spark-${useId()}`;
const valuesKey = values.join(",");
const geometry = useMemo(() => getSparklineGeometry(valuesKey), [valuesKey]);
const motionState = useSparklineMotion(geometry);
const motionStyle: CSSProperties = {
transform: motionState.transform,
transformBox: "view-box",
transformOrigin: "left top",
transition: motionState.transitionEnabled
? `transform ${SPARKLINE_ANIMATION_MS}ms ease-out, opacity 180ms ease-out`
: "none",
opacity: motionState.opacity,
};
if (geometry == null) return null;
return (
<svg
viewBox={`0 0 ${w} ${h}`}
viewBox={`0 0 ${SPARKLINE_WIDTH} ${SPARKLINE_HEIGHT}`}
preserveAspectRatio="none"
className="h-8 w-full"
aria-hidden
@ -81,16 +216,18 @@ function Sparkline({ values, color }: { values: number[], color: string }) {
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<path d={areaPath} fill={`url(#${gradId})`} />
<path
d={linePath}
fill="none"
stroke={color}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
<g style={motionStyle}>
<path d={geometry.areaPath} fill={`url(#${gradId})`} />
<path
d={geometry.linePath}
fill="none"
stroke={color}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</g>
</svg>
);
}

View File

@ -559,6 +559,9 @@ importers:
lodash:
specifier: ^4.17.21
version: 4.17.21
motion:
specifier: ^12.39.0
version: 12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next:
specifier: 16.1.7
version: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -13787,6 +13790,20 @@ packages:
frame-ticker@1.0.3:
resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==}
framer-motion@12.39.0:
resolution: {integrity: sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
freestyle-sandboxes@0.1.6:
resolution: {integrity: sha512-zfyJy+DgmheFjCAPYMklo7rpzvuxNP46rB0a9WfNBEmitYGE23nlbjyTy8qdrmVuCVCoMIDQQzzJRkyuh0Szqg==}
deprecated: This package has been deprecated. Please use freestyle instead.
@ -15880,6 +15897,26 @@ packages:
monaco-editor@0.52.2:
resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
motion-dom@12.39.0:
resolution: {integrity: sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
motion@12.39.0:
resolution: {integrity: sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@ -35361,6 +35398,16 @@ snapshots:
dependencies:
simplesignal: 2.1.7
framer-motion@12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
motion-dom: 12.39.0
motion-utils: 12.39.0
tslib: 2.8.1
optionalDependencies:
'@emotion/is-prop-valid': 1.3.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
freestyle-sandboxes@0.1.6: {}
fresh@0.5.2: {}
@ -38089,6 +38136,21 @@ snapshots:
monaco-editor@0.52.2: {}
motion-dom@12.39.0:
dependencies:
motion-utils: 12.39.0
motion-utils@12.39.0: {}
motion@12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
framer-motion: 12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tslib: 2.8.1
optionalDependencies:
'@emotion/is-prop-valid': 1.3.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
mri@1.2.0: {}
ms@2.0.0: {}