diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 4ea0ee32339..923263fcd3f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -49,6 +49,7 @@ "@sentry/nextjs": "8.45.1", "@shazow/whatsabi": "^0.18.0", "@tanstack/react-query": "5.62.16", + "@tanstack/react-query-persist-client": "^5.64.2", "@tanstack/react-table": "^8.20.6", "@thirdweb-dev/service-utils": "workspace:*", "@vercel/functions": "^1.5.2", @@ -64,6 +65,7 @@ "flat": "^6.0.1", "framer-motion": "11.15.0", "fuse.js": "7.0.0", + "idb-keyval": "^6.2.1", "input-otp": "^1.4.1", "ioredis": "^5.4.1", "ipaddr.js": "^2.2.0", @@ -89,6 +91,7 @@ "react-table": "^7.8.0", "recharts": "2.14.1", "remark-gfm": "^4.0.0", + "responsive-rsc": "^0.0.7", "server-only": "^0.0.1", "shiki": "1.27.0", "sonner": "^1.7.1", diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/layout.tsx index 67dcd524548..68e28f0badb 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/layout.tsx @@ -87,6 +87,10 @@ export default async function TeamLayout(props: { path: `/team/${params.team_slug}/~/settings`, name: "Settings", }, + { + path: `/team/${params.team_slug}/~/test`, + name: "Test", + }, ]} /> diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/ChartUI.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/ChartUI.tsx new file mode 100644 index 00000000000..9c374e107e6 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/ChartUI.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; + +export function ChartUI(props: { + data: Array<{ time: Date; count: number }>; + isPending: boolean; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/date.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/date.ts new file mode 100644 index 00000000000..fb6e216ee43 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/date.ts @@ -0,0 +1,5 @@ +export function ignoreTime(date: Date) { + const newDate = new Date(date); + newDate.setHours(1, 0, 0, 0); + return newDate; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/delays.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/delays.ts new file mode 100644 index 00000000000..1e197ebce2c --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/delays.ts @@ -0,0 +1,9 @@ +// This adds a key difference from the client side version - +// client side version can skip this entirely on subsequent date range changes +export async function simulatePageProcessingDelay() { + await new Promise((resolve) => setTimeout(resolve, 1000)); +} + +export async function simulateChartFetchingDelay() { + await new Promise((resolve) => setTimeout(resolve, 2000)); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/fetchTestData.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/fetchTestData.ts new file mode 100644 index 00000000000..6ab36325bd1 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/fetchTestData.ts @@ -0,0 +1,23 @@ +import { addDays, differenceInCalendarDays } from "date-fns"; +import { simulateChartFetchingDelay } from "./delays"; + +export type TestData = Array<{ time: Date; count: number }>; + +export async function fetchTestData(params: { + from: Date; + to: Date; +}) { + await simulateChartFetchingDelay(); + + const days = differenceInCalendarDays(params.to, params.from); + + const data: TestData = []; + for (let i = 0; i < days; i++) { + data.push({ + time: addDays(params.from, i), + count: ((i + 1) % 10) + i, + }); + } + + return data; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/getCachedFetchTestData.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/getCachedFetchTestData.ts new file mode 100644 index 00000000000..b2df93867f3 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/getCachedFetchTestData.ts @@ -0,0 +1,12 @@ +import "server-only"; + +import { unstable_cache } from "next/cache"; +import { fetchTestData } from "./fetchTestData"; + +export const getCachedFetchTestData = unstable_cache( + fetchTestData, + ["fetchTestData"], + { + revalidate: 3600, // 1 hour + }, +); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/getRange.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/getRange.ts new file mode 100644 index 00000000000..2d7cd4db49a --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/getRange.ts @@ -0,0 +1,27 @@ +import { getLastNDaysRange } from "../../../../../../../components/analytics/date-range-selector"; +import type { Range } from "../../../../../../../components/analytics/date-range-selector"; +import { ignoreTime } from "./date"; + +export function getRange(params: { + from: string | undefined; + to: string | undefined; +}) { + const fromStr = params.from; + const toStr = params.to; + + const defaultRange = getLastNDaysRange("last-30"); + const range: Range = + fromStr && toStr && typeof fromStr === "string" && typeof toStr === "string" + ? { + from: ignoreTime(new Date(fromStr)), + to: ignoreTime(new Date(toStr)), + type: "custom", + } + : { + from: ignoreTime(defaultRange.from), + to: ignoreTime(defaultRange.to), + type: defaultRange.type, + }; + + return range; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/pageProcessing.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/pageProcessing.ts new file mode 100644 index 00000000000..8d50fc94d25 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/_common/pageProcessing.ts @@ -0,0 +1,19 @@ +import { simulatePageProcessingDelay } from "./delays"; +import { getRange } from "./getRange"; + +export async function pageProcessing(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const searchParams = await props.searchParams; + const fromStr = searchParams.from; + const toStr = searchParams.to; + + await simulatePageProcessingDelay(); + + const range = getRange({ + from: typeof fromStr === "string" ? fromStr : undefined, + to: typeof toStr === "string" ? toStr : undefined, + }); + + return range; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/client.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/client.tsx new file mode 100644 index 00000000000..18d884c9359 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/client.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useRef } from "react"; +import { DateRangeSelector } from "../../../../../../../components/analytics/date-range-selector"; +import { ChartUI } from "../_common/ChartUI"; +import { type TestData, fetchTestData } from "../_common/fetchTestData"; +import { useRange, useSetRange } from "./contexts"; + +export function QueryChartUI(props: { + initialData: TestData; +}) { + const range = useRange(); + const initialRange = useRef(range); + const chartDataQuery = useQuery({ + queryKey: [ + "fetchTestDate", + { + from: range.from.toDateString(), + to: range.to.toDateString(), + }, + ], + queryFn: () => { + console.log("client side query", range); + return fetchTestData(range); + }, + initialData() { + const hasInitialData = + range.from.toString() === initialRange.current.from.toString() && + range.to.toString() === initialRange.current.to.toString(); + + if (hasInitialData) { + return props.initialData; + } + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + return ( + + ); +} + +export function RangeSelector() { + const range = useRange(); + const setRange = useSetRange(); + return ( + { + setRange(v); + // update search params without reloading the page + const searchParams = new URLSearchParams(window.location.search); + searchParams.set("from", v.from.toDateString()); + searchParams.set("to", v.to.toDateString()); + window.history.pushState({}, "", `?${searchParams.toString()}`); + }} + /> + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/contexts.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/contexts.tsx new file mode 100644 index 00000000000..56e901fe127 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/contexts.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { createContext, useContext, useState } from "react"; +import invariant from "tiny-invariant"; +import type { Range } from "../../../../../../../components/analytics/date-range-selector"; + +type SetRange = (range: Range) => void; + +// eslint-disable-next-line no-restricted-syntax +const RangeCtx = createContext(null); +// eslint-disable-next-line no-restricted-syntax +const SetRangeCtx = createContext(null); + +export function RangeProvider(props: { + value: Range; + children: React.ReactNode; +}) { + const [range, setRange] = useState(props.value); + + return ( + + + {props.children} + + + ); +} + +export function useRange() { + const range = useContext(RangeCtx); + invariant(range, "Not in RangeProvider"); + return range; +} + +export function useSetRange() { + const setRange = useContext(SetRangeCtx); + invariant(setRange, "Not in RangeProvider"); + return setRange; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/page.tsx new file mode 100644 index 00000000000..ebdb9590e14 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/page.tsx @@ -0,0 +1,25 @@ +import { pageProcessing } from "../_common/pageProcessing"; +import { RangeSelector } from "./client"; +import { RangeProvider } from "./contexts"; +import { RSCQueryChart } from "./server"; + +export default async function Page(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const range = await pageProcessing(props); + + return ( + +
+ +
+ +
+ + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/server.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/server.tsx new file mode 100644 index 00000000000..4907926d998 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/cache-client/server.tsx @@ -0,0 +1,36 @@ +import { unstable_cache } from "next/cache"; +import { Suspense } from "react"; +import { ChartUI } from "../_common/ChartUI"; +import { fetchTestData } from "../_common/fetchTestData"; +import { QueryChartUI } from "./client"; + +const cachedFetchTestData = unstable_cache(fetchTestData, ["fetchTestData"], { + // 1 hour + revalidate: 3600, +}); + +export function RSCQueryChart(props: { + range: { + from: Date; + to: Date; + }; +}) { + return ( + } + > + + + ); +} + +async function AsyncChartUI(props: { + range: { + from: Date; + to: Date; + }; +}) { + const data = await cachedFetchTestData(props.range); + return ; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/client-persist/idb-persister.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/client-persist/idb-persister.tsx new file mode 100644 index 00000000000..fcf913f34a2 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/client-persist/idb-persister.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { QueryClient } from "@tanstack/react-query"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; +import type { + PersistedClient, + Persister, +} from "@tanstack/react-query-persist-client"; +import { del, get, set } from "idb-keyval"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // 1 day + gcTime: 1000 * 60 * 60 * 24, + }, + }, +}); + +/** + * Creates an Indexed DB persister + * @see https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API + */ +function createIDBPersister(idbValidKey: IDBValidKey = "reactQuery") { + return { + persistClient: async (client: PersistedClient) => { + await set(idbValidKey, client); + }, + restoreClient: async () => { + return await get(idbValidKey); + }, + removeClient: async () => { + await del(idbValidKey); + }, + } satisfies Persister; +} + +const idbPersister = createIDBPersister(); + +export function IdbPersistProvider(props: { + children?: React.ReactNode; +}) { + return ( + + {props.children} + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/client-persist/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/client-persist/page.tsx new file mode 100644 index 00000000000..9e5d8679f0f --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/client-persist/page.tsx @@ -0,0 +1,15 @@ +import { pageProcessing } from "../_common/pageProcessing"; +import { ClientPage } from "../client/_page"; +import { IdbPersistProvider } from "./idb-persister"; + +export default async function Page(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const range = await pageProcessing(props); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/client/_page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/client/_page.tsx new file mode 100644 index 00000000000..df89245d2dd --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/client/_page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { + DateRangeSelector, + type Range, +} from "../../../../../../../components/analytics/date-range-selector"; +import { ChartUI } from "../_common/ChartUI"; +import { fetchTestData } from "../_common/fetchTestData"; + +export function ClientPage(props: { + range: Range; +}) { + const [range, setRange] = useState(props.range); + const chartDataQuery = useQuery({ + queryKey: [ + "fetchTestDate", + { + from: range.from.toDateString(), + to: range.to.toDateString(), + }, + ], + queryFn: () => fetchTestData(range), + staleTime: 3600 * 1000, // 1 hour + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + return ( +
+ { + setRange(v); + // update search params without reloading the page + const searchParams = new URLSearchParams(window.location.search); + searchParams.set("from", v.from.toDateString()); + searchParams.set("to", v.to.toDateString()); + window.history.pushState({}, "", `?${searchParams.toString()}`); + }} + /> +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/layout.tsx new file mode 100644 index 00000000000..311c2dde5b1 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/layout.tsx @@ -0,0 +1,40 @@ +import { SidebarLayout } from "@/components/blocks/SidebarLayout"; + +export default async function Layout(props: { + children: React.ReactNode; + params: Promise<{ + team_slug: string; + }>; +}) { + const params = await props.params; + + return ( + + {props.children} + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/page.tsx new file mode 100644 index 00000000000..4bafbd2c7df --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/page.tsx @@ -0,0 +1,10 @@ +import { pageProcessing } from "./_common/pageProcessing"; +import { ClientPage } from "./client/_page"; + +export default async function Page(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const range = await pageProcessing(props); + + return ; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/responsive-rsc/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/responsive-rsc/page.tsx new file mode 100644 index 00000000000..a55b7840a47 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/responsive-rsc/page.tsx @@ -0,0 +1,50 @@ +import { + ResponsiveSearchParamsProvider, + ResponsiveSuspense, +} from "responsive-rsc"; +import { ChartUI } from "../_common/ChartUI"; +import { simulatePageProcessingDelay } from "../_common/delays"; +import { getCachedFetchTestData } from "../_common/getCachedFetchTestData"; +import { getRange } from "../_common/getRange"; +import { RangeSelector } from "./range-selector"; + +export default async function Page(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + await simulatePageProcessingDelay(); + + const searchParams = await props.searchParams; + const from = + typeof searchParams.from === "string" ? searchParams.from : undefined; + const to = typeof searchParams.to === "string" ? searchParams.to : undefined; + + return ( + +
+ +
+ + } + > + + +
+ + ); +} + +export async function AsyncChartUI(props: { + from: string | undefined; + to: string | undefined; +}) { + const range = getRange(props); + const data = await getCachedFetchTestData(range); + return ; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/responsive-rsc/range-selector.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/responsive-rsc/range-selector.tsx new file mode 100644 index 00000000000..18269db956f --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/responsive-rsc/range-selector.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; +import { DateRangeSelector } from "../../../../../../../components/analytics/date-range-selector"; +import { getRange } from "../_common/getRange"; + +export function RangeSelector() { + const responsiveSearchParams = useResponsiveSearchParams(); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + const range = getRange({ + from: + typeof responsiveSearchParams.from === "string" + ? responsiveSearchParams.from + : undefined, + to: + typeof responsiveSearchParams.to === "string" + ? responsiveSearchParams.to + : undefined, + }); + + return ( + { + setResponsiveSearchParams((v) => { + return { + ...v, + from: newRange.from.toDateString(), + to: newRange.to.toDateString(), + }; + }); + }} + /> + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/server/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/server/page.tsx new file mode 100644 index 00000000000..e5c3fe1a52e --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/server/page.tsx @@ -0,0 +1,50 @@ +import { Suspense } from "react"; +import { ChartUI } from "../_common/ChartUI"; +import { getCachedFetchTestData } from "../_common/getCachedFetchTestData"; +import { pageProcessing } from "../_common/pageProcessing"; +import { RangeSelector } from "./range-selector"; + +export default async function Page(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const range = await pageProcessing(props); + + return ( +
+ +
+ +
+ ); +} + +function RSCChart(props: { + range: { + from: Date; + to: Date; + }; +}) { + return ( + } + > + + + ); +} + +async function AsyncChartUI(props: { + range: { + from: Date; + to: Date; + }; +}) { + const data = await getCachedFetchTestData(props.range); + return ; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/server/range-selector.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/server/range-selector.tsx new file mode 100644 index 00000000000..13ae64ac65f --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/test/server/range-selector.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { usePathname } from "next/navigation"; +import { + DateRangeSelector, + type Range, +} from "../../../../../../../components/analytics/date-range-selector"; + +export function RangeSelector(props: { + range: Range; +}) { + const pathname = usePathname(); + const router = useDashboardRouter(); + + return ( + { + const searchParams = new URLSearchParams(window.location.search); + searchParams.set("from", newRange.from.toDateString()); + searchParams.set("to", newRange.to.toDateString()); + + // triggers update + router.replace(`${pathname}?${searchParams.toString()}`); + }} + /> + ); +} diff --git a/apps/dashboard/src/components/analytics/date-range-selector.tsx b/apps/dashboard/src/components/analytics/date-range-selector.tsx index e4365b0f544..1de6c9c93a8 100644 --- a/apps/dashboard/src/components/analytics/date-range-selector.tsx +++ b/apps/dashboard/src/components/analytics/date-range-selector.tsx @@ -6,13 +6,17 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { format, subDays } from "date-fns"; +import { differenceInCalendarDays, format, subDays } from "date-fns"; export function DateRangeSelector(props: { range: Range; setRange: (range: Range) => void; }) { const { range, setRange } = props; + const daysDiff = differenceInCalendarDays(range.to, range.from); + const rangeType = + durationPresets.find((preset) => preset.days === daysDiff)?.id || + range.type; return (