diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 39bdbb84f..071b8ea48 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -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", 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 a90744f69..8f59e0530 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 @@ -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(null); + const chartMotion = useChartMotionProps(); return ( {datapoints.map((entry, index) => { const isWeekendDay = isWeekend(parseChartDate(entry.date)); @@ -344,6 +345,7 @@ export function MiniActivityBarChart({ }) { const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); + const chartMotion = useChartMotionProps(); return ( {datapoints.map((entry, index) => { const isActiveBar = hoveredIndex === index; @@ -450,6 +452,7 @@ export function MiniNamedBarChart({ }) { const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); + const chartMotion = useChartMotionProps(); return ( {datapoints.map((entry, index) => { const isActiveBar = hoveredIndex === index; @@ -622,6 +625,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); @@ -675,7 +679,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; @@ -689,7 +693,7 @@ export function StackedBarChartDisplay({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -703,7 +707,7 @@ export function StackedBarChartDisplay({ ); })} - + {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 = 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) { if (!active || !payload?.length) return null; @@ -1016,13 +995,13 @@ export function ComposedAnalyticsChart({ const id = useId(); const [hoveredIndex, setHoveredIndex] = useState(null); const [hoveredX, setHoveredX] = useState(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 && ( : false} - {...overviewChartAnimation} + {...chartMotion} /> {showVisitors && hoveredIndex != null && hoveredX != null && ( } - {...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={} - {...overviewChartAnimation} + {...chartMotion} /> {hoveredIndex != null && hoveredX != null && ( } - {...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 ? : false} - {...overviewChartAnimation} + {...chartMotion} /> {showRevenue && hoveredIndex != null && hoveredX != null && ( } - {...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 ( @@ -2164,7 +2144,7 @@ export function CorrelationCard({ stroke={s.color} strokeWidth={1.5} dot={false} - isAnimationActive={false} + {...chartMotion} /> ))} @@ -2398,6 +2378,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); @@ -2460,7 +2441,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; @@ -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(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' }} /> - + {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(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' }} /> - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -2898,7 +2881,7 @@ export function RevenueHoverChart({ ); })} - + {datapoints.map((entry, index) => { const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; @@ -2912,7 +2895,7 @@ export function RevenueHoverChart({ ); })} - + {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" /> 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 c8a369673..29336243c 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 @@ -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(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 ( + <> +
+
+ + + +
+
+ {renderTitle && ( +
+ + {title} + +
+ )} + + {actions} + +
+ + +
+ + ); +} + function GlobeView({ includeAnonymous }: { includeAnonymous: boolean }) { return (
@@ -703,13 +871,6 @@ function AnalyticsChartWidget({ }) { const [selectedMode, setSelectedMode] = useState('default'); const [previewMode, setPreviewMode] = useState(null); - const [displayMode, setDisplayMode] = useState('default'); - const [fadingOut, setFadingOut] = useState(false); - const [fadingIn, setFadingIn] = useState(false); - const fadeTimerRef = useRef | null>(null); - const fadeInRaf1Ref = useRef(null); - const fadeInRaf2Ref = useRef(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 }} > -
+
{displayMode === 'default' && ( composedData.length === 0 ? (
@@ -1284,31 +1397,31 @@ export default function MetricsPage(props: { toSetup: () => void }) { const markAnalyticsFiltersLoaded = useCallback(() => { setLoadedAnalyticsFilters(analyticsFilters); }, [analyticsFilters]); + const headerTitle = `Welcome back, ${truncatedName}!`; + const headerActions = ( +
+ {view === "overview" && ( + <> + + + + + )} + +
+ ); return ( - {view === "overview" && ( - <> - - - - - )} - -
- } fillWidth fullBleed - wrapHeaderInCard > + {view === "overview" && } {view === "overview" && isUpdatingAnalyticsFilters && ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card.tsx index cc8ea0667..c2624c722 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card.tsx @@ -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 = { 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(null); + const [motionState, setMotionState] = useState(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 ( - - + + + + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 970a64a2f..5d55fa8d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}