diff --git a/apps/dashboard/src/@/components/analytics/date-range-selector.tsx b/apps/dashboard/src/@/components/analytics/date-range-selector.tsx index 0628a2e885a..429579af51f 100644 --- a/apps/dashboard/src/@/components/analytics/date-range-selector.tsx +++ b/apps/dashboard/src/@/components/analytics/date-range-selector.tsx @@ -18,6 +18,7 @@ export function DateRangeSelector(props: { }) { const { range, setRange } = props; const daysDiff = differenceInCalendarDays(range.to, range.from); + const matchingRange = normalizeTime(range.to).getTime() === normalizeTime(new Date()).getTime() ? durationPresets.find((preset) => preset.days === daysDiff) @@ -85,7 +86,7 @@ export function getLastNDaysRange(id: DurationId) { throw new Error(`Invalid duration id: ${id}`); } - const todayDate = new Date(Date.now() + 1000 * 60 * 60 * 24); // add 1 day to avoid timezone issues + const todayDate = new Date(Date.now()); const value: Range = { from: subDays(todayDate, durationInfo.days), diff --git a/apps/dashboard/src/@/components/analytics/range-selector.tsx b/apps/dashboard/src/@/components/analytics/range-selector.tsx deleted file mode 100644 index e092420ad80..00000000000 --- a/apps/dashboard/src/@/components/analytics/range-selector.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; -import { useQuery } from "@tanstack/react-query"; -import { usePathname, useSearchParams } from "next/navigation"; -import { useState } from "react"; -import type { - DurationId, - Range, -} from "@/components/analytics/date-range-selector"; -import { - DateRangeSelector, - getLastNDaysRange, -} from "@/components/analytics/date-range-selector"; -import { IntervalSelector } from "@/components/analytics/interval-selector"; -import { useDashboardRouter } from "@/lib/DashboardRouter"; - -export function RangeSelector({ - range, - interval, -}: { - range?: Range; - interval: "day" | "week"; -}) { - const pathname = usePathname(); - const searchParams = useSearchParams(); - const router = useDashboardRouter(); - const [localRange, setRange] = useState( - range || getLastNDaysRange("last-120"), - ); - const [localInterval, setInterval] = useState<"day" | "week">(interval); - - useQuery({ - queryFn: async () => { - if (range) { - setRange(range); - return range; - } - if (searchParams) { - const fromStringified = searchParams.get("from"); - const from = fromStringified - ? new Date(fromStringified) - : getLastNDaysRange("last-120").from; - const toStringified = searchParams.get("to"); - const to = toStringified ? new Date(toStringified) : new Date(); - const type = (searchParams.get("type") as DurationId) || "last-120"; - setRange({ from, to, type }); - setInterval(interval); - return { from, to, type } satisfies Range; - } - return getLastNDaysRange("last-120"); - }, - queryKey: ["analytics-range", searchParams?.toString(), range], - }); - - // prefetch for each interval and default range - useQuery({ - queryFn: async () => { - const newSearchParams = new URLSearchParams(searchParams || {}); - for (const interval of ["day", "week"] as const) { - newSearchParams.set("interval", interval); - for (const type of [ - "last-120", - "last-60", - "last-30", - "last-7", - ] as const) { - const newRange = getLastNDaysRange(type); - newSearchParams.set("from", newRange.from.toISOString()); - newSearchParams.set("to", newRange.to.toISOString()); - newSearchParams.set("type", newRange.type); - router.prefetch(`${pathname}?${newSearchParams.toString()}`); - } - } - return true; - }, - queryKey: ["analytics-range", searchParams?.toString()], - }); - - return ( -
- { - setRange(newRange); - const newSearchParams = new URLSearchParams(searchParams || {}); - newSearchParams.set("from", newRange.from.toISOString()); - newSearchParams.set("to", newRange.to.toISOString()); - newSearchParams.set("type", newRange.type); - router.push(`${pathname}?${newSearchParams.toString()}`); - }} - /> - { - setInterval(newInterval); - const newSearchParams = new URLSearchParams(searchParams || {}); - newSearchParams.set("interval", newInterval); - router.push(`${pathname}?${newSearchParams.toString()}`); - }} - /> -
- ); -} diff --git a/apps/dashboard/src/@/components/analytics/responsive-time-filters.tsx b/apps/dashboard/src/@/components/analytics/responsive-time-filters.tsx new file mode 100644 index 00000000000..1395932e048 --- /dev/null +++ b/apps/dashboard/src/@/components/analytics/responsive-time-filters.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; +import { + DateRangeSelector, + type DurationId, +} from "@/components/analytics/date-range-selector"; +import { IntervalSelector } from "@/components/analytics/interval-selector"; +import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time"; + +export function ResponsiveTimeFilters(props: { defaultRange: DurationId }) { + const responsiveSearchParams = useResponsiveSearchParams(); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: props.defaultRange, + from: responsiveSearchParams.from, + interval: responsiveSearchParams.interval, + to: responsiveSearchParams.to, + }); + + return ( +
+ { + setResponsiveSearchParams((v) => { + const newParams = { + ...v, + from: normalizeTimeISOString(newRange.from), + to: normalizeTimeISOString(newRange.to), + }; + return newParams; + }); + }} + /> + { + setResponsiveSearchParams((v) => { + const newParams = { + ...v, + interval: newInterval, + }; + return newParams; + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/TotalSponsoredCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/TotalSponsoredCard.tsx index e994c0a5465..63ac0835bb1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/TotalSponsoredCard.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/TotalSponsoredCard.tsx @@ -1,35 +1,53 @@ -import { defineChain } from "thirdweb"; -import { type ChainMetadata, getChainMetadata } from "thirdweb/chains"; +"use client"; +import { useSetResponsiveSearchParams } from "responsive-rsc"; +import type { ChainMetadata } from "thirdweb/chains"; import { cn } from "@/lib/utils"; import type { UserOpStats } from "@/types/analytics"; import { BarChart } from "../../../components/Analytics/BarChart"; import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard"; import { EmptyAccountAbstractionChartContent } from "../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/SponsoredTransactionsChartCard"; -export async function TotalSponsoredChartCardUI({ - data, - aggregatedData, - searchParams, +const chartConfig = { + mainnet: { + color: "hsl(var(--chart-1))", + label: "Mainnet Chains", + }, + testnet: { + color: "hsl(var(--chart-2))", + label: "Testnet Chains", + }, + total: { + color: "hsl(var(--chart-3))", + label: "All Chains", + }, +}; + +export function TotalSponsoredChartCardUI({ + processedAggregatedData, + selectedChart, className, onlyMainnet, title, + chains, + data, description, + selectedChartQueryParam, }: { data: UserOpStats[]; - aggregatedData: UserOpStats[]; - searchParams?: { [key: string]: string | string[] | undefined }; + selectedChart: string | undefined; className?: string; onlyMainnet?: boolean; title?: string; + selectedChartQueryParam: string; description?: string; + chains: ChainMetadata[]; + processedAggregatedData: { + mainnet: number; + testnet: number; + total: number; + }; }) { - const chains = await Promise.all( - data.map( - (item) => - // eslint-disable-next-line no-restricted-syntax - item.chainId && getChainMetadata(defineChain(Number(item.chainId))), - ), - ).then((chains) => chains.filter((c) => c) as ChainMetadata[]); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); // Process data to combine by date and chain type const dateMap = new Map(); @@ -55,35 +73,6 @@ export async function TotalSponsoredChartCardUI({ })) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); - const processedAggregatedData = { - mainnet: aggregatedData - .filter( - (d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet, - ) - .reduce((acc, curr) => acc + curr.sponsoredUsd, 0), - testnet: aggregatedData - .filter( - (d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet, - ) - .reduce((acc, curr) => acc + curr.sponsoredUsd, 0), - total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0), - }; - - const chartConfig = { - mainnet: { - color: "hsl(var(--chart-1))", - label: "Mainnet Chains", - }, - testnet: { - color: "hsl(var(--chart-2))", - label: "Testnet Chains", - }, - total: { - color: "hsl(var(--chart-3))", - label: "All Chains", - }, - }; - if (onlyMainnet) { const filteredData = timeSeriesData.filter((d) => d.mainnet > 0); return ( @@ -106,15 +95,23 @@ export async function TotalSponsoredChartCardUI({ return ( { + setResponsiveSearchParams((v) => { + return { + ...v, + [selectedChartQueryParam]: key, + }; + }); + }} aggregateFn={(_data, key) => processedAggregatedData[key]} chartConfig={chartConfig} className={className} data={timeSeriesData} - existingQueryParams={searchParams} isCurrency - queryKey="totalSponsored" title={title || "Gas Sponsored"} // Get the trend from the last two COMPLETE periods trendFn={(data, key) => diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/TransactionsCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/TransactionsCard.tsx index 24698ade816..0d071ab8db0 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/TransactionsCard.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/TransactionsCard.tsx @@ -1,37 +1,53 @@ -import { defineChain } from "thirdweb"; -import { type ChainMetadata, getChainMetadata } from "thirdweb/chains"; +"use client"; +import { useSetResponsiveSearchParams } from "responsive-rsc"; +import type { ChainMetadata } from "thirdweb/chains"; import { cn } from "@/lib/utils"; import type { TransactionStats } from "@/types/analytics"; import { BarChart } from "../../../components/Analytics/BarChart"; import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard"; import { EmptyAccountAbstractionChartContent } from "../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/SponsoredTransactionsChartCard"; -export async function TransactionsChartCardUI({ +const chartConfig = { + mainnet: { + color: "hsl(var(--chart-1))", + label: "Mainnet Chains", + }, + testnet: { + color: "hsl(var(--chart-2))", + label: "Testnet Chains", + }, + total: { + color: "hsl(var(--chart-3))", + label: "All Chains", + }, +}; + +export function TransactionsChartCardUI({ data, - aggregatedData, - searchParams, className, onlyMainnet, title, description, + selectedChart, + selectedChartQueryParam, + chains, + processedAggregatedData, }: { data: TransactionStats[]; - aggregatedData: TransactionStats[]; - searchParams?: { [key: string]: string | string[] | undefined }; className?: string; onlyMainnet?: boolean; title?: string; + selectedChartQueryParam: string; + selectedChart: string | undefined; description?: string; + chains: ChainMetadata[]; + processedAggregatedData: { + mainnet: number; + testnet: number; + total: number; + }; }) { - const uniqueChainIds = [ - ...new Set(data.map((item) => item.chainId).filter(Boolean)), - ]; - const chains = await Promise.all( - uniqueChainIds.map((chainId) => - // eslint-disable-next-line no-restricted-syntax - getChainMetadata(defineChain(Number(chainId))).catch(() => undefined), - ), - ).then((chains) => chains.filter((c) => c) as ChainMetadata[]); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); // Process data to combine by date and chain type const dateMap = new Map(); @@ -57,35 +73,6 @@ export async function TransactionsChartCardUI({ })) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); - const processedAggregatedData = { - mainnet: aggregatedData - .filter( - (d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet, - ) - .reduce((acc, curr) => acc + curr.count, 0), - testnet: aggregatedData - .filter( - (d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet, - ) - .reduce((acc, curr) => acc + curr.count, 0), - total: aggregatedData.reduce((acc, curr) => acc + curr.count, 0), - }; - - const chartConfig = { - mainnet: { - color: "hsl(var(--chart-1))", - label: "Mainnet Chains", - }, - testnet: { - color: "hsl(var(--chart-2))", - label: "Testnet Chains", - }, - total: { - color: "hsl(var(--chart-3))", - label: "All Chains", - }, - }; - if (onlyMainnet) { const filteredData = timeSeriesData.filter((d) => d.mainnet > 0); return ( @@ -107,15 +94,22 @@ export async function TransactionsChartCardUI({ return ( processedAggregatedData[key]} chartConfig={chartConfig} className={className} + onSelect={(key) => { + setResponsiveSearchParams((v) => { + return { + ...v, + [selectedChartQueryParam]: key, + }; + }); + }} data={timeSeriesData} - existingQueryParams={searchParams} - queryKey="client_transactions" title={title || "Transactions"} // Get the trend from the last two COMPLETE periods trendFn={(data, key) => diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/transaction-card-with-chain-mapping.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/transaction-card-with-chain-mapping.tsx new file mode 100644 index 00000000000..21489da3d99 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/_components/transaction-card-with-chain-mapping.tsx @@ -0,0 +1,51 @@ +import { defineChain } from "thirdweb"; +import { type ChainMetadata, getChainMetadata } from "thirdweb/chains"; +import type { TransactionStats } from "@/types/analytics"; +import { TransactionsChartCardUI } from "./TransactionsCard"; + +export async function TransactionsChartCardWithChainMapping(props: { + data: TransactionStats[]; + className?: string; + onlyMainnet?: boolean; + title?: string; + selectedChartQueryParam: string; + selectedChart: string | undefined; + description?: string; + aggregatedData: TransactionStats[]; +}) { + const uniqueChainIds = [ + ...new Set(props.data.map((item) => item.chainId).filter(Boolean)), + ]; + const chains = await Promise.all( + uniqueChainIds.map((chainId) => + // eslint-disable-next-line no-restricted-syntax + getChainMetadata(defineChain(Number(chainId))).catch(() => undefined), + ), + ).then((chains) => chains.filter((c) => c) as ChainMetadata[]); + + const processedAggregatedData = { + mainnet: props.aggregatedData + .filter( + (d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet, + ) + .reduce((acc, curr) => acc + curr.count, 0), + testnet: props.aggregatedData + .filter( + (d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet, + ) + .reduce((acc, curr) => acc + curr.count, 0), + total: props.aggregatedData.reduce((acc, curr) => acc + curr.count, 0), + }; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/highlights-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/highlights-card.tsx new file mode 100644 index 00000000000..ec5d3af610b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/highlights-card.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useMemo } from "react"; +import { useSetResponsiveSearchParams } from "responsive-rsc"; +import type { UniversalBridgeStats, WalletUserStats } from "@/types/analytics"; +import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard"; +import { EmptyStateContent } from "../../../../components/Analytics/EmptyStateCard"; + +type AggregatedMetrics = { + activeUsers: number; + newUsers: number; + totalVolume: number; + feesCollected: number; +}; + +export function TeamHighlightsCard({ + userStats, + volumeStats, + selectedChart, + selectedChartQueryParam, +}: { + userStats: WalletUserStats[]; + volumeStats: UniversalBridgeStats[]; + selectedChart: string | undefined; + selectedChartQueryParam: string; +}) { + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + const timeSeriesData = useMemo( + () => processTimeSeriesData(userStats, volumeStats), + [userStats, volumeStats], + ); + + const chartConfig = { + activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" }, + feesCollected: { + color: "hsl(var(--chart-4))", + emptyContent: ( + + ), + isCurrency: true, + label: "Fee Revenue", + }, + newUsers: { color: "hsl(var(--chart-3))", label: "New Users" }, + totalVolume: { + color: "hsl(var(--chart-2))", + emptyContent: ( + + ), + isCurrency: true, + label: "Total Volume", + }, + } as const; + + return ( + { + setResponsiveSearchParams((v) => { + return { + ...v, + [selectedChartQueryParam]: key, + }; + }); + }} + activeChart={ + selectedChart && selectedChart in chartConfig + ? (selectedChart as keyof AggregatedMetrics) + : "totalVolume" + } + aggregateFn={(_data, key) => { + if (key === "activeUsers") { + return Math.max(...timeSeriesData.map((d) => d[key])); + } + return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0); + }} + chartConfig={chartConfig} + data={timeSeriesData} + trendFn={(data, key) => + data.filter((d) => (d[key] as number) > 0).length >= 2 + ? ((data[data.length - 2]?.[key] as number) ?? 0) / + ((data[0]?.[key] as number) ?? 0) - + 1 + : undefined + } + /> + ); +} + +type TimeSeriesMetrics = AggregatedMetrics & { + date: string; +}; + +function processTimeSeriesData( + userStats: WalletUserStats[], + volumeStats: UniversalBridgeStats[], +): TimeSeriesMetrics[] { + const metrics: TimeSeriesMetrics[] = []; + + for (const stat of userStats) { + const volume = volumeStats + .filter( + (v) => + new Date(v.date).toISOString() === + new Date(stat.date).toISOString() && v.status === "completed", + ) + .reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0); + + const fees = volumeStats + .filter( + (v) => + new Date(v.date).toISOString() === + new Date(stat.date).toISOString() && v.status === "completed", + ) + .reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0); + + metrics.push({ + activeUsers: stat.totalUsers ?? 0, + date: stat.date, + feesCollected: fees, + newUsers: stat.newUsers ?? 0, + totalVolume: volume, + }); + } + + return metrics; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/page.tsx index 0052b955bb7..74d64b7116a 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/page.tsx @@ -1,9 +1,11 @@ -import { - EmptyStateCard, - EmptyStateContent, -} from "app/(app)/team/components/Analytics/EmptyStateCard"; +import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard"; import { redirect } from "next/navigation"; -import { Suspense } from "react"; +import { + ResponsiveSearchParamsProvider, + ResponsiveSuspense, +} from "responsive-rsc"; +import { defineChain } from "thirdweb"; +import { type ChainMetadata, getChainMetadata } from "thirdweb/chains"; import { getWalletInfo, type WalletId } from "thirdweb/wallets"; import { getClientTransactions, @@ -14,30 +16,32 @@ import { getWalletUsers, } from "@/api/analytics"; import { getTeamBySlug } from "@/api/team/get-team"; -import { - type DurationId, - getLastNDaysRange, - type Range, +import type { + DurationId, + Range, } from "@/components/analytics/date-range-selector"; import { LoadingChartState } from "@/components/analytics/empty-chart-state"; +import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters"; +import { getFiltersFromSearchParams } from "@/lib/time"; import type { InAppWalletStats, - UniversalBridgeStats, + UserOpStats, WalletStats, - WalletUserStats, } from "@/types/analytics"; -import { AnalyticsHeader } from "../../../../components/Analytics/AnalyticsHeader"; -import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard"; import { PieChartCard } from "../../../../components/Analytics/PieChartCard"; import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard"; -import { TransactionsChartCardUI } from "../../_components/TransactionsCard"; +import { TransactionsChartCardWithChainMapping } from "../../_components/transaction-card-with-chain-mapping"; +import { TeamHighlightsCard } from "./highlights-card"; type SearchParams = { - usersChart?: string; - from?: string; - to?: string; - type?: string; - interval?: string; + usersChart?: string | string[]; + from?: string | string[]; + to?: string | string[]; + type?: string | string[]; + interval?: string | string[]; + client_transactions?: string | string[]; + appHighlights?: string | string[]; + userOpUsage?: string | string[]; }; export default async function TeamOverviewPage(props: { @@ -55,84 +59,120 @@ export default async function TeamOverviewPage(props: { redirect("/team"); } - const interval = (searchParams.interval as "day" | "week") ?? "week"; - const rangeType = (searchParams.type as DurationId) || "last-120"; - const range: Range = { - from: new Date(searchParams.from ?? getLastNDaysRange("last-120").from), - to: new Date(searchParams.to ?? getLastNDaysRange("last-120").to), - type: rangeType, - }; + const defaultRange: DurationId = "last-30"; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange, + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); return ( -
-
- -
- -
-
- } - > - - + +
+ {/* header */} +
+
+
+

+ Analytics +

+
+
+ +
+
+
-
- } +
+
+ } > - - - - } + + + +
+ } + searchParamsUsed={["from", "to"]} + > + + + + } + searchParamsUsed={["from", "to"]} + > + + +
+ + } + searchParamsUsed={[ + "from", + "to", + "interval", + "client_transactions", + ]} > - - + + + + } + searchParamsUsed={["from", "to", "interval", "userOpUsage"]} + > + +
- - } - > - - - - } - > - -
-
+
); } -async function AsyncAppHighlightsCard(props: { +async function AsyncTeamHighlightsCard(props: { teamId: string; range: Range; interval: "day" | "week"; - searchParams: SearchParams; + selectedChart: string | undefined; + selectedChartQueryParam: string; }) { const [walletUserStatsTimeSeries, universalBridgeUsage] = await Promise.allSettled([ @@ -156,13 +196,12 @@ async function AsyncAppHighlightsCard(props: { walletUserStatsTimeSeries.value.some((w) => w.totalUsers !== 0) ) { return ( -
- -
+ ); } @@ -220,7 +259,8 @@ async function AsyncTransactionsChartCard(props: { teamId: string; range: Range; interval: "day" | "week"; - searchParams: SearchParams; + selectedChart: string | undefined; + selectedChartQueryParam: string; }) { const [clientTransactionsTimeSeries, clientTransactions] = await Promise.allSettled([ @@ -241,10 +281,11 @@ async function AsyncTransactionsChartCard(props: { return clientTransactionsTimeSeries.status === "fulfilled" && clientTransactions.status === "fulfilled" && clientTransactions.value.length > 0 ? ( - ) : ( 0 ? ( - ) : ( - new Date(v.date).toISOString() === - new Date(stat.date).toISOString() && v.status === "completed", - ) - .reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0); - - const fees = volumeStats - .filter( - (v) => - new Date(v.date).toISOString() === - new Date(stat.date).toISOString() && v.status === "completed", - ) - .reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0); - - metrics.push({ - activeUsers: stat.totalUsers ?? 0, - date: stat.date, - feesCollected: fees, - newUsers: stat.newUsers ?? 0, - totalVolume: volume, - }); - } - - return metrics; -} - -function AppHighlightsCard({ - userStats, - volumeStats, - searchParams, -}: { - userStats: WalletUserStats[]; - volumeStats: UniversalBridgeStats[]; - searchParams?: { [key: string]: string | string[] | undefined }; -}) { - const timeSeriesData = processTimeSeriesData(userStats, volumeStats); - - const chartConfig = { - activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" }, - feesCollected: { - color: "hsl(var(--chart-4))", - emptyContent: ( - - ), - isCurrency: true, - label: "Fee Revenue", - }, - newUsers: { color: "hsl(var(--chart-3))", label: "New Users" }, - totalVolume: { - color: "hsl(var(--chart-2))", - emptyContent: ( - - ), - isCurrency: true, - label: "Total Volume", - }, - } as const; - - return ( - { - if (key === "activeUsers") { - return Math.max(...timeSeriesData.map((d) => d[key])); - } - return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0); - }} - chartConfig={chartConfig} - data={timeSeriesData} - existingQueryParams={searchParams} - queryKey="appHighlights" - title="App Highlights" - trendFn={(data, key) => - data.filter((d) => (d[key] as number) > 0).length >= 2 - ? ((data[data.length - 2]?.[key] as number) ?? 0) / - ((data[0]?.[key] as number) ?? 0) - - 1 - : undefined - } - /> - ); -} - async function WalletDistributionCard({ data }: { data: WalletStats[] }) { const formattedData = await Promise.all( data @@ -446,3 +375,49 @@ function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) { /> ); } + +async function AsyncTotalSponsoredChartCard(props: { + data: UserOpStats[]; + aggregatedData: UserOpStats[]; + selectedChart: string | undefined; + className?: string; + onlyMainnet?: boolean; + title?: string; + selectedChartQueryParam: string; + description?: string; +}) { + const chains = await Promise.all( + props.data.map( + (item) => + // eslint-disable-next-line no-restricted-syntax + item.chainId && getChainMetadata(defineChain(Number(item.chainId))), + ), + ).then((chains) => chains.filter((c) => c) as ChainMetadata[]); + + const processedAggregatedData = { + mainnet: props.aggregatedData + .filter( + (d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet, + ) + .reduce((acc, curr) => acc + curr.sponsoredUsd, 0), + testnet: props.aggregatedData + .filter( + (d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet, + ) + .reduce((acc, curr) => acc + curr.sponsoredUsd, 0), + total: props.aggregatedData.reduce( + (acc, curr) => acc + curr.sponsoredUsd, + 0, + ), + }; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx index d6075c1a9d1..51ae46eeaed 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx @@ -1,10 +1,13 @@ +import { Suspense } from "react"; +import { ResponsiveSuspense } from "responsive-rsc"; import { getEcosystemWalletUsage } from "@/api/analytics"; import type { Partner } from "@/api/team/ecosystems"; -import { - getLastNDaysRange, - type Range, +import type { + DurationId, + Range, } from "@/components/analytics/date-range-selector"; -import { RangeSelector } from "@/components/analytics/range-selector"; +import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters"; +import type { EcosystemWalletStats } from "@/types/analytics"; import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard"; import { EcosystemWalletsSummary } from "./Summary"; @@ -14,16 +17,66 @@ export async function EcosystemAnalyticsPage({ interval, range, partners, + defaultRange, }: { ecosystemSlug: string; teamId: string; interval: "day" | "week"; - range?: Range; + range: Range; + defaultRange: DurationId; partners: Partner[]; }) { - if (!range) { - range = getLastNDaysRange("last-120"); - } + return ( +
+ + } + > + + + +
+ + + +
+ + + } + > + + +
+ ); +} + +async function AsyncEcosystemWalletsSummary(props: { + ecosystemSlug: string; + teamId: string; +}) { + const { ecosystemSlug, teamId } = props; const allTimeStatsPromise = getEcosystemWalletUsage({ ecosystemSlug, @@ -41,7 +94,30 @@ export async function EcosystemAnalyticsPage({ to: new Date(), }); - const statsPromise = getEcosystemWalletUsage({ + const [allTimeStats, monthlyStats] = await Promise.all([ + allTimeStatsPromise, + monthlyStatsPromise, + ]); + + return ( + + ); +} + +async function AsyncEcosystemWalletUsersAnalytics(props: { + ecosystemSlug: string; + teamId: string; + interval: "day" | "week"; + range: Range; + partners: Partner[]; +}) { + const { ecosystemSlug, teamId, interval, range, partners } = props; + + const stats = await getEcosystemWalletUsage({ ecosystemSlug, from: range.from, period: interval, @@ -49,39 +125,36 @@ export async function EcosystemAnalyticsPage({ to: range.to, }).catch(() => null); - const [allTimeStats, monthlyStats, stats] = await Promise.all([ - allTimeStatsPromise, - monthlyStatsPromise, - statsPromise, - ]); + return ( + + ); +} +function EcosystemWalletUsersAnalyticsUI(props: { + ecosystemWalletStats: EcosystemWalletStats[]; + isPending: boolean; + groupBy: "authenticationMethod" | "ecosystemPartnerId"; + partners: Partner[]; +}) { return ( -
- + + - -
- - - -
- -
- - -
); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/Summary.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/Summary.tsx index d88ba2f2bc8..ed48867664a 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/Summary.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/Summary.tsx @@ -5,6 +5,7 @@ import type { EcosystemWalletStats } from "@/types/analytics"; export function EcosystemWalletsSummary(props: { allTimeStats: EcosystemWalletStats[] | undefined; monthlyStats: EcosystemWalletStats[] | undefined; + isPending: boolean; }) { const allTimeStats = props.allTimeStats?.reduce( (acc, curr) => { @@ -30,13 +31,13 @@ export function EcosystemWalletsSummary(props: {
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx index d91a1040bf7..11501f0d099 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx @@ -1,7 +1,10 @@ import { redirect } from "next/navigation"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; import { getAuthToken } from "@/api/auth-token"; import { fetchEcosystem } from "@/api/team/ecosystems"; import { getTeamBySlug } from "@/api/team/get-team"; +import type { DurationId } from "@/components/analytics/date-range-selector"; +import { getFiltersFromSearchParams } from "@/lib/time"; import { fetchPartners } from "../configuration/hooks/fetchPartners"; import { EcosystemAnalyticsPage } from "./components/EcosystemAnalyticsPage"; @@ -47,21 +50,24 @@ export default async function Page(props: { teamId: team.id, }); + const defaultRange: DurationId = "last-30"; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange, + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); + return ( - + + + ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/EngineCloudChartCard/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/EngineCloudChartCard/index.tsx index bc97c9ae5a6..6168b06a93d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/EngineCloudChartCard/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/EngineCloudChartCard/index.tsx @@ -1,19 +1,8 @@ -import { Suspense } from "react"; import { getEngineCloudMethodUsage } from "@/api/analytics"; -import { LoadingChartState } from "@/components/analytics/empty-chart-state"; import type { AnalyticsQueryParams } from "@/types/analytics"; import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI"; -export function EngineCloudChartCard(props: AnalyticsQueryParams) { - return ( - }> - - - ); -} - -async function EngineCloudChartCardAsync(props: AnalyticsQueryParams) { +export async function EngineCloudChartCardAsync(props: AnalyticsQueryParams) { const rawData = await getEngineCloudMethodUsage(props); - return ; } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/index.tsx index 53adf3159ca..6f047c90f70 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/RpcMethodBarChartCard/index.tsx @@ -1,19 +1,8 @@ -import { Suspense } from "react"; import { getRpcMethodUsage } from "@/api/analytics"; -import { LoadingChartState } from "@/components/analytics/empty-chart-state"; import type { AnalyticsQueryParams } from "@/types/analytics"; import { RpcMethodBarChartCardUI } from "./RpcMethodBarChartCardUI"; -export function RpcMethodBarChartCard(props: AnalyticsQueryParams) { - return ( - // TODO: Add better LoadingChartState - }> - - - ); -} - -async function RpcMethodBarChartCardAsync(props: AnalyticsQueryParams) { +export async function RpcMethodBarChartCardAsync(props: AnalyticsQueryParams) { const rawData = await getRpcMethodUsage(props); return ; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/Transactions/TransactionCharts.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/Transactions/TransactionCharts.tsx index 52fbf03aaa7..743eae563bf 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/Transactions/TransactionCharts.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/Transactions/TransactionCharts.tsx @@ -3,29 +3,29 @@ import { getChainMetadata } from "thirdweb/chains"; import { fetchDashboardContractMetadata } from "@/hooks/useDashboardContractMetadata"; import type { TransactionStats } from "@/types/analytics"; import { PieChartCard } from "../../../../../components/Analytics/PieChartCard"; -import { TransactionsChartCardUI } from "../../../../(team)/_components/TransactionsCard"; +import { TransactionsChartCardWithChainMapping } from "../../../../(team)/_components/transaction-card-with-chain-mapping"; -export function TransactionsChartsUI({ - data, - aggregatedData, - searchParams, - client, -}: { +export function TransactionsChartsUI(props: { data: TransactionStats[]; aggregatedData: TransactionStats[]; - searchParams: { [key: string]: string | string[] | undefined }; + selectedChartQueryParam: string; client: ThirdwebClient; + selectedChart: string | undefined; }) { return ( <> -
- - + +
); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/Transactions/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/Transactions/index.tsx index 56c7bcbc410..3b403004d33 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/Transactions/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/Transactions/index.tsx @@ -1,27 +1,12 @@ -import { Suspense } from "react"; import type { ThirdwebClient } from "thirdweb"; import { getClientTransactions } from "@/api/analytics"; -import { LoadingChartState } from "@/components/analytics/empty-chart-state"; import type { AnalyticsQueryParams } from "@/types/analytics"; import { TransactionsChartsUI } from "./TransactionCharts"; -export function TransactionsCharts( +export async function TransactionsChartCardAsync( props: AnalyticsQueryParams & { - searchParams: { [key: string]: string | string[] | undefined }; - client: ThirdwebClient; - }, -) { - return ( - // TODO: Add better LoadingChartState - }> - - - ); -} - -async function TransactionsChartCardAsync( - props: AnalyticsQueryParams & { - searchParams: { [key: string]: string | string[] | undefined }; + selectedChartQueryParam: string; + selectedChart: string | undefined; client: ThirdwebClient; }, ) { @@ -42,7 +27,8 @@ async function TransactionsChartCardAsync( aggregatedData={aggregatedData} client={props.client} data={data} - searchParams={props.searchParams} + selectedChart={props.selectedChart} + selectedChartQueryParam={props.selectedChartQueryParam} /> ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/overview/highlights-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/overview/highlights-card.tsx new file mode 100644 index 00000000000..62b376c61b8 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/overview/highlights-card.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { EmptyStateContent } from "app/(app)/team/components/Analytics/EmptyStateCard"; +import { useSetResponsiveSearchParams } from "responsive-rsc"; +import type { UniversalBridgeStats, WalletUserStats } from "@/types/analytics"; +import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard"; + +type AggregatedMetrics = { + activeUsers: number; + newUsers: number; + totalVolume: number; + feesCollected: number; +}; + +export function ProjectHighlightsCard(props: { + selectedChart: string | undefined; + userStats: WalletUserStats[]; + volumeStats: UniversalBridgeStats[]; + teamSlug: string; + projectSlug: string; + selectedChartQueryParam: string; +}) { + const { + selectedChart, + userStats, + volumeStats, + teamSlug, + projectSlug, + selectedChartQueryParam, + } = props; + + const timeSeriesData = processTimeSeriesData(userStats, volumeStats); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + const chartConfig = { + activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" }, + feesCollected: { + color: "hsl(var(--chart-4))", + emptyContent: ( + + ), + isCurrency: true, + label: "Fee Revenue", + }, + newUsers: { color: "hsl(var(--chart-3))", label: "New Users" }, + totalVolume: { + color: "hsl(var(--chart-2))", + emptyContent: ( + + ), + isCurrency: true, + label: "Total Volume", + }, + } as const; + + return ( + { + if (key === "activeUsers") { + return Math.max(...timeSeriesData.map((d) => d[key])); + } + return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0); + }} + chartConfig={chartConfig} + data={timeSeriesData} + onSelect={(key) => { + setResponsiveSearchParams((v) => { + return { + ...v, + [selectedChartQueryParam]: key, + }; + }); + }} + trendFn={(data, key) => + data.filter((d) => (d[key] as number) > 0).length >= 2 + ? ((data[data.length - 2]?.[key] as number) ?? 0) / + ((data[0]?.[key] as number) ?? 0) - + 1 + : undefined + } + /> + ); +} + +type TimeSeriesMetrics = AggregatedMetrics & { + date: string; +}; + +/** + * Processes time series data to combine wallet and user statistics + */ +function processTimeSeriesData( + userStats: WalletUserStats[], + volumeStats: UniversalBridgeStats[], +): TimeSeriesMetrics[] { + const metrics: TimeSeriesMetrics[] = []; + + for (const stat of userStats) { + const volume = volumeStats + .filter( + (v) => + new Date(v.date).toISOString() === + new Date(stat.date).toISOString() && v.status === "completed", + ) + .reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0); + + const fees = volumeStats + .filter( + (v) => + new Date(v.date).toISOString() === + new Date(stat.date).toISOString() && v.status === "completed", + ) + .reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0); + + metrics.push({ + activeUsers: stat.totalUsers ?? 0, + date: stat.date, + feesCollected: fees, + newUsers: stat.newUsers ?? 0, + totalVolume: volume, + }); + } + + return metrics; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/overview/total-sponsored.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/overview/total-sponsored.tsx new file mode 100644 index 00000000000..7e26389dbfd --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/overview/total-sponsored.tsx @@ -0,0 +1,71 @@ +"use client"; +import { useSetResponsiveSearchParams } from "responsive-rsc"; +import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard"; + +type TimeSeriesMetrics = { + date: string; + mainnet: number; + testnet: number; + total: number; +}; + +export function TotalSponsoredCardUI(props: { + selectedChart: string | undefined; + selectedChartQueryParam: string; + timeSeriesData: TimeSeriesMetrics[]; + processedAggregatedData: { + mainnet: number; + testnet: number; + total: number; + }; +}) { + const { + selectedChart, + selectedChartQueryParam, + processedAggregatedData, + timeSeriesData, + } = props; + + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + const chartConfig = { + mainnet: { + color: "hsl(var(--chart-1))", + label: "Mainnet Chains", + }, + testnet: { + color: "hsl(var(--chart-2))", + label: "Testnet Chains", + }, + total: { + color: "hsl(var(--chart-3))", + label: "All Chains", + }, + }; + + return ( + processedAggregatedData[key]} + chartConfig={chartConfig} + data={timeSeriesData} + isCurrency + onSelect={(key) => { + setResponsiveSearchParams((v) => { + return { + ...v, + [selectedChartQueryParam]: key, + }; + }); + }} + title="Gas Sponsored" + // Get the trend from the last two COMPLETE periods + trendFn={(data, key) => + data.filter((d) => (d[key] as number) > 0).length >= 3 + ? ((data[data.length - 2]?.[key] as number) ?? 0) / + ((data[data.length - 3]?.[key] as number) ?? 0) - + 1 + : undefined + } + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx index 46cc4b71d74..4b43e76b050 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx @@ -1,9 +1,9 @@ -import { - EmptyStateCard, - EmptyStateContent, -} from "app/(app)/team/components/Analytics/EmptyStateCard"; +import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard"; import { redirect } from "next/navigation"; -import { Suspense } from "react"; +import { + ResponsiveSearchParamsProvider, + ResponsiveSuspense, +} from "responsive-rsc"; import type { ThirdwebClient } from "thirdweb"; import { type ChainMetadata, @@ -21,43 +21,47 @@ import { } from "@/api/analytics"; import { getAuthToken } from "@/api/auth-token"; import { getProject, type Project } from "@/api/project/projects"; -import { - type DurationId, - getLastNDaysRange, - type Range, +import type { + DurationId, + Range, } from "@/components/analytics/date-range-selector"; import { LoadingChartState } from "@/components/analytics/empty-chart-state"; -import { RangeSelector } from "@/components/analytics/range-selector"; -import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage"; +import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters"; import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { getFiltersFromSearchParams } from "@/lib/time"; import type { InAppWalletStats, - UniversalBridgeStats, UserOpStats, WalletStats, - WalletUserStats, } from "@/types/analytics"; import { loginRedirect } from "@/utils/redirects"; -import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard"; import { PieChartCard } from "../../../components/Analytics/PieChartCard"; -import { EngineCloudChartCard } from "./components/EngineCloudChartCard"; +import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard"; import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX"; -import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard"; -import { TransactionsCharts } from "./components/Transactions"; +import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard"; +import { TransactionsChartCardAsync } from "./components/Transactions"; +import { ProjectHighlightsCard } from "./overview/highlights-card"; +import { TotalSponsoredCardUI } from "./overview/total-sponsored"; -interface PageParams { +type PageParams = { team_slug: string; project_slug: string; -} +}; -interface PageSearchParams { - [key: string]: string | undefined; -} +type PageSearchParams = { + from: string | undefined | string[]; + to: string | undefined | string[]; + type: string | undefined | string[]; + interval: string | undefined | string[]; + appHighlights: string | undefined | string[]; + client_transactions: string | undefined | string[]; + totalSponsored: string | undefined | string[]; +}; -interface PageProps { +type PageProps = { params: Promise; searchParams: Promise; -} +}; export default async function ProjectOverviewPage(props: PageProps) { const [params, searchParams] = await Promise.all([ @@ -78,13 +82,13 @@ export default async function ProjectOverviewPage(props: PageProps) { redirect(`/team/${params.team_slug}`); } - const interval = (searchParams.interval as "day" | "week") ?? "week"; - const rangeType = (searchParams.type as DurationId) || "last-120"; - const range: Range = { - from: new Date(searchParams.from ?? getLastNDaysRange("last-120").from), - to: new Date(searchParams.to ?? getLastNDaysRange("last-120").to), - type: rangeType, - }; + const defaultRange: DurationId = "last-30"; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange: "last-30", + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); const activeStatus = await isProjectActive({ projectId: project.id, @@ -99,27 +103,26 @@ export default async function ProjectOverviewPage(props: PageProps) { }); return ( -
-
-
-
+ +
+
+
+
+
-
-
+
- {!isActive ? ( -
- -
- ) : ( -
- }> + {!isActive ? ( +
+ +
+ ) : ( +
- -
- )} +
+ )} -
-
+
+
+ ); } @@ -149,117 +152,112 @@ async function ProjectAnalytics(props: { return (
- }> + } + searchParamsUsed={["from", "to", "interval", "appHighlights"]} + > - +
- }> + } + searchParamsUsed={["from", "to", "interval"]} + > - + - }> + } + searchParamsUsed={["from", "to", "interval"]} + > - +
- + } + searchParamsUsed={["from", "to", "interval", "client_transactions"]} + > + + - }> + } + searchParamsUsed={["from", "to", "interval", "totalSponsored"]} + > - - - - + + + } + searchParamsUsed={["from", "to", "interval"]} + > + + + + } + searchParamsUsed={["from", "to", "interval"]} + > + +
); } -type AggregatedMetrics = { - activeUsers: number; - newUsers: number; - totalVolume: number; - feesCollected: number; -}; - -type TimeSeriesMetrics = AggregatedMetrics & { - date: string; -}; - -/** - * Processes time series data to combine wallet and user statistics - */ -function processTimeSeriesData( - userStats: WalletUserStats[], - volumeStats: UniversalBridgeStats[], -): TimeSeriesMetrics[] { - const metrics: TimeSeriesMetrics[] = []; - - for (const stat of userStats) { - const volume = volumeStats - .filter( - (v) => - new Date(v.date).toISOString() === - new Date(stat.date).toISOString() && v.status === "completed", - ) - .reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0); - - const fees = volumeStats - .filter( - (v) => - new Date(v.date).toISOString() === - new Date(stat.date).toISOString() && v.status === "completed", - ) - .reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0); - - metrics.push({ - activeUsers: stat.totalUsers ?? 0, - date: stat.date, - feesCollected: fees, - newUsers: stat.newUsers ?? 0, - totalVolume: volume, - }); - } - - return metrics; -} - export async function AsyncTotalSponsoredCard(props: { project: Project; range: Range; interval: "day" | "week"; - searchParams: PageSearchParams; + selectedChart: string | undefined; + selectedChartQueryParam: string; }) { const [userOpUsageTimeSeries, userOpUsage] = await Promise.allSettled([ getUserOpUsage({ @@ -285,7 +283,8 @@ export async function AsyncTotalSponsoredCard(props: {
) : ( @@ -322,7 +321,8 @@ async function AsyncAppHighlightsCard(props: { project: Project; range: Range; interval: "day" | "week"; - searchParams: PageSearchParams; + selectedChartQueryParam: string; + selectedChart: string | undefined; client: ThirdwebClient; params: PageParams; }) { @@ -350,13 +350,11 @@ async function AsyncAppHighlightsCard(props: { ) return (
- - ), - isCurrency: true, - label: "Fee Revenue", - }, - newUsers: { color: "hsl(var(--chart-3))", label: "New Users" }, - totalVolume: { - color: "hsl(var(--chart-2))", - emptyContent: ( - - ), - isCurrency: true, - label: "Total Volume", - }, - } as const; - - return ( - { - if (key === "activeUsers") { - return Math.max(...timeSeriesData.map((d) => d[key])); - } - return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0); - }} - chartConfig={chartConfig} - data={timeSeriesData} - existingQueryParams={searchParams} - queryKey="appHighlights" - title="App Highlights" - trendFn={(data, key) => - data.filter((d) => (d[key] as number) > 0).length >= 2 - ? ((data[data.length - 2]?.[key] as number) ?? 0) / - ((data[0]?.[key] as number) ?? 0) - - 1 - : undefined - } - /> - ); -} - async function WalletDistributionCard({ data }: { data: WalletStats[] }) { const formattedData = await Promise.all( data @@ -515,11 +444,13 @@ function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) { async function TotalSponsoredCard({ data, aggregatedData, - searchParams, + selectedChart, + selectedChartQueryParam, }: { data: UserOpStats[]; aggregatedData: UserOpStats[]; - searchParams: { [key: string]: string | string[] | undefined }; + selectedChart: string | undefined; + selectedChartQueryParam: string; }) { const chains = await Promise.all( data.map( @@ -567,52 +498,22 @@ async function TotalSponsoredCard({ total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0), }; - const chartConfig = { - mainnet: { - color: "hsl(var(--chart-1))", - label: "Mainnet Chains", - }, - testnet: { - color: "hsl(var(--chart-2))", - label: "Testnet Chains", - }, - total: { - color: "hsl(var(--chart-3))", - label: "All Chains", - }, - }; - return ( - processedAggregatedData[key]} - chartConfig={chartConfig} - data={timeSeriesData} - existingQueryParams={searchParams} - isCurrency - queryKey="totalSponsored" - title="Gas Sponsored" - // Get the trend from the last two COMPLETE periods - trendFn={(data, key) => - data.filter((d) => (d[key] as number) > 0).length >= 3 - ? ((data[data.length - 2]?.[key] as number) ?? 0) / - ((data[data.length - 3]?.[key] as number) ?? 0) - - 1 - : undefined - } + ); } export function Header(props: { title: string; - interval: "day" | "week"; - range: Range; showRangeSelector: boolean; + defaultRange: DurationId; }) { - const { title, interval, range, showRangeSelector } = props; + const { title, showRangeSelector, defaultRange } = props; return (
@@ -623,7 +524,7 @@ export function Header(props: {
{showRangeSelector && (
- +
)}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/index.tsx index bcdc370394d..4c829b899c1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/index.tsx @@ -1,10 +1,9 @@ -import { Suspense } from "react"; +import { ResponsiveSuspense } from "responsive-rsc"; import { getInAppWalletUsage } from "@/api/analytics"; import { getLastNDaysRange, type Range, } from "@/components/analytics/date-range-selector"; -import { RangeSelector } from "@/components/analytics/range-selector"; import type { InAppWalletStats } from "@/types/analytics"; import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard"; @@ -15,27 +14,20 @@ type InAppWalletAnalyticsProps = { isPending: boolean; }; -function InAppWalletAnalyticsInner({ - interval, - range, +function InAppWalletAnalyticsUI({ stats, isPending, }: InAppWalletAnalyticsProps) { return ( -
- -
-
- -
-
+ ); } + type AsyncInAppWalletAnalyticsProps = Omit< InAppWalletAnalyticsProps, "stats" | "isPending" @@ -61,7 +53,7 @@ async function AsyncInAppWalletAnalytics( }); return ( - + } > - + ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx index 52a0d2eec68..c9e504eb94f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx @@ -1,6 +1,9 @@ import { redirect } from "next/navigation"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; import { getProject } from "@/api/project/projects"; -import type { Range } from "@/components/analytics/date-range-selector"; +import type { DurationId } from "@/components/analytics/date-range-selector"; +import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters"; +import { getFiltersFromSearchParams } from "@/lib/time"; import { InAppWalletAnalytics } from "./analytics/chart"; import { InAppWalletsSummary } from "./analytics/chart/Summary"; @@ -18,20 +21,13 @@ export default async function Page(props: { props.params, ]); - const range = - searchParams.from && searchParams.to - ? { - from: new Date(searchParams.from), - to: new Date(searchParams.to), - type: searchParams.type ?? "last-120", - } - : undefined; - - const interval: "day" | "week" = ["day", "week"].includes( - searchParams.interval ?? "", - ) - ? (searchParams.interval as "day" | "week") - : "week"; + const defaultRange: DurationId = "last-30"; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange, + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); const project = await getProject(params.team_slug, params.project_slug); @@ -40,15 +36,19 @@ export default async function Page(props: { } return ( -
- -
- -
+ +
+ +
+ +
+ +
+ ); } diff --git a/apps/dashboard/src/app/(app)/team/components/Analytics/AnalyticsHeader.tsx b/apps/dashboard/src/app/(app)/team/components/Analytics/AnalyticsHeader.tsx deleted file mode 100644 index 317ec0601c8..00000000000 --- a/apps/dashboard/src/app/(app)/team/components/Analytics/AnalyticsHeader.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { Range } from "@/components/analytics/date-range-selector"; -import { RangeSelector } from "@/components/analytics/range-selector"; - -export function AnalyticsHeader(props: { - title: string; - interval: "day" | "week"; - range: Range; - showRangeSelector: boolean; -}) { - const { title, interval, range, showRangeSelector } = props; - - return ( -
-
-

- {title} -

-
- {showRangeSelector && ( -
- -
- )} -
- ); -} diff --git a/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.stories.tsx b/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.stories.tsx index ed32c72ee76..c1cde05ce88 100644 --- a/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; import { CombinedBarChartCard } from "./CombinedBarChartCard"; const meta = { - component: CombinedBarChartCard, + component: Variant, decorators: [ (Story) => (
@@ -11,7 +12,7 @@ const meta = { ), ], title: "Analytics/CombinedBarChartCard", -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -64,32 +65,20 @@ function generateTimeSeriesData(days: number) { return data; } -export const UserActivity: Story = { - args: { - activeChart: "dailyUsers", - chartConfig, - data: generateTimeSeriesData(30), - queryKey: "dailyUsers", - title: "User Activity", - }, -}; +export const Default: Story = {}; -export const MonthlyUsers: Story = { - args: { - activeChart: "monthlyUsers", - chartConfig, - data: generateTimeSeriesData(30), - queryKey: "monthlyUsers", - title: "Monthly Users", - }, -}; +function Variant() { + const [activeChart, setActiveChart] = useState< + "dailyUsers" | "annualUsers" | "monthlyUsers" + >("dailyUsers"); -export const AnnualUsers: Story = { - args: { - activeChart: "annualUsers", - chartConfig, - data: generateTimeSeriesData(30), - queryKey: "annualUsers", - title: "Annual Users", - }, -}; + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx b/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx index 35e8e97a223..8bd6d5c6efb 100644 --- a/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx +++ b/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx @@ -1,4 +1,4 @@ -import Link from "next/link"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { toUSD } from "@/utils/number"; import { BarChart } from "./BarChart"; @@ -13,8 +13,6 @@ type CombinedBarChartConfig = { }; }; -// TODO - don't reload page on tab change -> make this a client component, load all the charts at once on server and switch between them on client - export function CombinedBarChartCard< T extends string, K extends Exclude, @@ -23,7 +21,6 @@ export function CombinedBarChartCard< chartConfig, data, activeChart, - queryKey, isCurrency = false, aggregateFn = (data, key) => data[data.length - 1]?.[key] as number | undefined, @@ -33,18 +30,17 @@ export function CombinedBarChartCard< ((data[data.length - 2]?.[key] as number) ?? 0) - 1 : undefined, - existingQueryParams, className, + onSelect, }: { title?: string; chartConfig: CombinedBarChartConfig; data: { [key in T]: number | string }[]; activeChart: K; - queryKey: string; isCurrency?: boolean; aggregateFn?: (d: typeof data, key: K) => number | undefined; trendFn?: (d: typeof data, key: K) => number | undefined; - existingQueryParams?: { [key: string]: string | string[] | undefined }; + onSelect: (key: K) => void; className?: string; }) { return ( @@ -61,18 +57,12 @@ export function CombinedBarChartCard< {Object.keys(chartConfig).map((chart: string) => { const key = chart as K; return ( - onSelect(key)} key={chart} - prefetch - scroll={false} > - + ); })}