diff --git a/packages/server/app/components/PaginatedTableCard.tsx b/packages/server/app/components/PaginatedTableCard.tsx index 324db749..f8636716 100644 --- a/packages/server/app/components/PaginatedTableCard.tsx +++ b/packages/server/app/components/PaginatedTableCard.tsx @@ -1,14 +1,13 @@ -import { useEffect, useState } from "react"; -import TableCard from "~/components/TableCard"; - -import { Card } from "./ui/card"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useFetcher } from "react-router"; +import TableCard, { CountByProperty } from "~/components/TableCard"; +import type { SearchFilters } from "~/lib/types"; import PaginationButtons from "./PaginationButtons"; -import { SearchFilters } from "~/lib/types"; +import { Card } from "./ui/card"; interface PaginatedTableCardProps { siteId: string; interval: string; - dataFetcher: any; columnHeaders: string[]; filters?: SearchFilters; loaderUrl: string; @@ -17,10 +16,9 @@ interface PaginatedTableCardProps { labelFormatter?: (label: string) => string; } -const PaginatedTableCard = ({ +const PaginatedTableCard = Promise<{ countsByProperty: CountByProperty }>>({ siteId, interval, - dataFetcher, columnHeaders, filters, loaderUrl, @@ -28,33 +26,54 @@ const PaginatedTableCard = ({ timezone, labelFormatter, }: PaginatedTableCardProps) => { - const countsByProperty = dataFetcher.data?.countsByProperty || []; + const fetcher = useFetcher>>(); const [page, setPage] = useState(1); + const lastParamsRef = useRef(""); - useEffect(() => { - const params = { - site: siteId, - interval, - timezone, - ...filters, - page, - }; + const countsByProperty = fetcher.data?.countsByProperty || []; + + // Create a stable function to load data + const loadData = useCallback(() => { + const url = new URL(loaderUrl, window.location.origin); + url.searchParams.set("site", siteId); + url.searchParams.set("interval", interval); + url.searchParams.set("page", page.toString()); + + if (timezone) { + url.searchParams.set("timezone", timezone); + } - dataFetcher.submit(params, { - method: "get", - action: loaderUrl, - }); - // NOTE: dataFetcher is intentionally omitted from the useEffect dependency array - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loaderUrl, siteId, interval, filters, timezone, page]); // + // Add filter parameters + if (filters) { + for (const [key, value] of Object.entries(filters)) { + if (value) { + url.searchParams.set(key, value); + } + } + } - function handlePagination(page: number) { - setPage(page); + const fullUrl = url.pathname + url.search; + + // Only load if parameters have actually changed + if (lastParamsRef.current !== fullUrl) { + lastParamsRef.current = fullUrl; + fetcher.load(fullUrl); + } + }, [fetcher.load, loaderUrl, siteId, interval, filters, timezone, page]); + + // Load data when parameters change + useEffect(() => { + loadData(); + }, [loadData]); + + function handlePagination(newPage: number) { + setPage(newPage); } const hasMore = countsByProperty.length === 10; + return ( - + {countsByProperty ? (
sum + parseInt(row[1]), + (sum, row) => { + const value = typeof row[1] === "number" ? row[1] : parseInt(row[1], 10); + return sum + value; + }, 0, ); return countByProperty.map((row) => { - const count = parseInt(row[1]); + const count = typeof row[1] === "number" ? row[1] : parseInt(row[1], 10); const percentage = ((count / totalCount) * 100).toFixed(2); return `${percentage}%`; }); @@ -62,6 +65,7 @@ export default function TableCard({ {(countByProperty || []).map((item, index) => { const desc = item[0]; + const value = String(item[1]); // the description can be either a single string (that is both the key and the label), // or a tuple of type [key, label] @@ -76,7 +80,7 @@ export default function TableCard({ return ( @@ -94,7 +98,7 @@ export default function TableCard({ - {countFormatter.format(parseInt(item[1], 10))} + {countFormatter.format(parseInt(value, 10))} {item.length > 2 && item[2] !== undefined && ( diff --git a/packages/server/app/load-context.ts b/packages/server/app/load-context.ts index 6e7fa07a..0b71d7e4 100644 --- a/packages/server/app/load-context.ts +++ b/packages/server/app/load-context.ts @@ -1,12 +1,12 @@ -import { type AppLoadContext } from "react-router"; -import { type PlatformProxy } from "wrangler"; +import type { AppLoadContext } from "react-router"; +import type { PlatformProxy } from "wrangler"; import { AnalyticsEngineAPI } from "./analytics/query"; interface ExtendedEnv extends Env { CF_PAGES_COMMIT_SHA: string; } -type Cloudflare = Omit, "dispose">; +export type Cloudflare = Omit, "dispose">; declare module "react-router" { interface AppLoadContext { diff --git a/packages/server/app/routes/dashboard.tsx b/packages/server/app/routes/dashboard.tsx index aa706f08..c371080d 100644 --- a/packages/server/app/routes/dashboard.tsx +++ b/packages/server/app/routes/dashboard.tsx @@ -12,26 +12,30 @@ import { redirect, useLoaderData, useNavigation, + useRevalidator, useRouteError, useSearchParams, } from "react-router"; -import { ReferrerCard } from "./resources.referrer"; -import { PathsCard } from "./resources.paths"; import { BrowserCard } from "./resources.browser"; import { BrowserVersionCard } from "./resources.browserversion"; import { CountryCard } from "./resources.country"; import { DeviceCard } from "./resources.device"; +import { PathsCard } from "./resources.paths"; +import { ReferrerCard } from "./resources.referrer"; +import { useEffect, useRef } from "react"; +import SearchFilterBadges from "~/components/SearchFilterBadges"; +import type { SearchFilters } from "~/lib/types"; import { getFiltersFromSearchParams, getIntervalType, getUserTimezone, } from "~/lib/utils"; -import { SearchFilters } from "~/lib/types"; -import SearchFilterBadges from "~/components/SearchFilterBadges"; -import { TimeSeriesCard } from "./resources.timeseries"; import { StatsCard } from "./resources.stats"; +import { TimeSeriesCard } from "./resources.timeseries"; +import { AnalyticsEngineAPI } from "~/analytics/query"; +import { Cloudflare } from "~/load-context"; export const meta: MetaFunction = () => { return [ @@ -42,23 +46,28 @@ export const meta: MetaFunction = () => { const MAX_RETENTION_DAYS = 90; -export const loader = async ({ context, request }: LoaderFunctionArgs) => { +export const loader = async ({ context, request }: LoaderFunctionArgs<{ + analyticsEngine: AnalyticsEngineAPI; + cloudflare: Cloudflare; +}>) => { + if (!context) throw new Error("Context is not defined"); + + const { analyticsEngine, cloudflare } = context; // NOTE: probably duped from getLoadContext / need to de-duplicate - if (!context.cloudflare?.env?.CF_ACCOUNT_ID) { + if (!cloudflare?.env?.CF_ACCOUNT_ID) { throw new Response("Missing credentials: CF_ACCOUNT_ID is not set.", { status: 501, }); } - if (!context.cloudflare?.env?.CF_BEARER_TOKEN) { + if (!cloudflare?.env?.CF_BEARER_TOKEN) { throw new Response("Missing credentials: CF_BEARER_TOKEN is not set.", { status: 501, }); } - const { analyticsEngine } = context; const url = new URL(request.url); - let interval; + let interval: string; try { interval = url.searchParams.get("interval") || "7d"; } catch { @@ -68,8 +77,7 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => { // if no siteId is set, redirect to the site with the most hits // during the default interval (e.g. 7d) if (url.searchParams.has("site") === false) { - const sitesByHits = - await analyticsEngine.getSitesOrderedByHits(interval); + const sitesByHits = await analyticsEngine.getSitesOrderedByHits(interval); // if at least one result const redirectSite = sitesByHits[0]?.[0] || ""; @@ -95,14 +103,10 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => { const intervalType = getIntervalType(interval); // await all requests to AE then return the results - - let out; try { - out = { + return { siteId: actualSiteId, - sites: (await sitesByHits).map( - ([site, _]: [string, number]) => site, - ), + sites: (await sitesByHits).map(([site, _]: [string, number]) => site), intervalType, interval, filters, @@ -111,8 +115,6 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => { console.error(err); throw new Error("Failed to fetch data from Analytics Engine"); } - - return out; }; export default function Dashboard() { @@ -120,8 +122,51 @@ export default function Dashboard() { const data = useLoaderData(); const navigation = useNavigation(); + const revalidator = useRevalidator(); const loading = navigation.state === "loading"; + // Use ref to debounce revalidation calls + const lastRevalidateTime = useRef(0); + + // Refetch data when window regains focus + useEffect(() => { + const DEBOUNCE_MS = 1_000; // Prevent multiple revalidations within 1 second + + const debouncedRevalidate = () => { + const now = Date.now(); + if (now - lastRevalidateTime.current > DEBOUNCE_MS) { + lastRevalidateTime.current = now; + revalidator.revalidate(); + } + }; + + const handleVisibilityChange = () => { + if (!document.hidden) { + debouncedRevalidate(); + } + }; + + const handleFocus = () => { + debouncedRevalidate(); + }; + + // Ensure we're in the browser environment + if (typeof window !== "undefined" && typeof document !== "undefined") { + // visibilitychange is more comprehensive (handles tab switching, minimizing, etc.) + // focus provides additional coverage for window focus scenarios + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleFocus); + + return () => { + document.removeEventListener( + "visibilitychange", + handleVisibilityChange, + ); + window.removeEventListener("focus", handleFocus); + }; + } + }, [revalidator.revalidate]); + function changeSite(site: string) { // intentionally not updating prev params; don't want search // filters (e.g. referrer, path) to persist @@ -146,7 +191,7 @@ export default function Dashboard() { if (Object.hasOwnProperty.call(filters, key)) { prev.set( key, - filters[key as keyof SearchFilters] as string, + filters[key as keyof SearchFilters] as string ); } } @@ -207,6 +252,34 @@ export default function Dashboard() {
+
+ +
+
- {data.filters && data.filters.browserName ? ( + {data.filters?.browserName ? ( diff --git a/packages/server/app/routes/resources.browser.tsx b/packages/server/app/routes/resources.browser.tsx index 0d855bc5..c6809a34 100644 --- a/packages/server/app/routes/resources.browser.tsx +++ b/packages/server/app/routes/resources.browser.tsx @@ -1,13 +1,15 @@ -import { useFetcher } from "react-router"; - import type { LoaderFunctionArgs } from "react-router"; import { getFiltersFromSearchParams, paramsFromUrl } from "~/lib/utils"; import PaginatedTableCard from "~/components/PaginatedTableCard"; import { SearchFilters } from "~/lib/types"; +import { AnalyticsEngineAPI } from "~/analytics/query"; -export async function loader({ context, request }: LoaderFunctionArgs) { - const { analyticsEngine } = context; +export async function loader({ context, request }: LoaderFunctionArgs<{ + analyticsEngine: AnalyticsEngineAPI; +}>) { + const { analyticsEngine } = context || {}; + if (!analyticsEngine) throw new Error("Analytics engine is not defined"); const { interval, site, page = 1 } = paramsFromUrl(request.url); const url = new URL(request.url); @@ -40,11 +42,10 @@ export const BrowserCard = ({ timezone: string; }) => { return ( - siteId={siteId} interval={interval} columnHeaders={["Browser", "Visitors"]} - dataFetcher={useFetcher()} loaderUrl="/resources/browser" filters={filters} onClick={(browserName) => diff --git a/packages/server/app/routes/resources.browserversion.tsx b/packages/server/app/routes/resources.browserversion.tsx index ba7ef7ab..cb3046ee 100644 --- a/packages/server/app/routes/resources.browserversion.tsx +++ b/packages/server/app/routes/resources.browserversion.tsx @@ -1,5 +1,3 @@ -import { useFetcher } from "react-router"; - import type { LoaderFunctionArgs } from "react-router"; import { getFiltersFromSearchParams, paramsFromUrl } from "~/lib/utils"; @@ -40,11 +38,10 @@ export const BrowserVersionCard = ({ timezone: string; }) => { return ( - siteId={siteId} interval={interval} columnHeaders={[`${filters.browserName} Versions`, "Visitors"]} - dataFetcher={useFetcher()} loaderUrl="/resources/browserversion" onClick={(browserVersion) => onFilterChange({ ...filters, browserVersion }) diff --git a/packages/server/app/routes/resources.country.tsx b/packages/server/app/routes/resources.country.tsx index e9471f84..8b3059ec 100644 --- a/packages/server/app/routes/resources.country.tsx +++ b/packages/server/app/routes/resources.country.tsx @@ -1,4 +1,3 @@ -import { useFetcher } from "react-router"; import type { LoaderFunctionArgs } from "react-router"; import { getFiltersFromSearchParams, paramsFromUrl } from "~/lib/utils"; import PaginatedTableCard from "~/components/PaginatedTableCard"; @@ -64,11 +63,10 @@ export const CountryCard = ({ timezone: string; }) => { return ( - siteId={siteId} interval={interval} columnHeaders={["Country", "Visitors"]} - dataFetcher={useFetcher()} loaderUrl="/resources/country" filters={filters} onClick={(country) => onFilterChange({ ...filters, country })} diff --git a/packages/server/app/routes/resources.device.tsx b/packages/server/app/routes/resources.device.tsx index 93d90f55..57f75cb2 100644 --- a/packages/server/app/routes/resources.device.tsx +++ b/packages/server/app/routes/resources.device.tsx @@ -1,5 +1,3 @@ -import { useFetcher } from "react-router"; - import type { LoaderFunctionArgs } from "react-router"; import { getFiltersFromSearchParams, paramsFromUrl } from "~/lib/utils"; @@ -41,11 +39,10 @@ export const DeviceCard = ({ timezone: string; }) => { return ( - siteId={siteId} interval={interval} columnHeaders={["Device", "Visitors"]} - dataFetcher={useFetcher()} loaderUrl="/resources/device" filters={filters} onClick={(deviceType) => onFilterChange({ ...filters, deviceType })} diff --git a/packages/server/app/routes/resources.paths.tsx b/packages/server/app/routes/resources.paths.tsx index 6180a888..70234a78 100644 --- a/packages/server/app/routes/resources.paths.tsx +++ b/packages/server/app/routes/resources.paths.tsx @@ -1,5 +1,3 @@ -import { useFetcher } from "react-router"; - import type { LoaderFunctionArgs } from "react-router"; import { @@ -44,11 +42,10 @@ export const PathsCard = ({ timezone: string; }) => { return ( - siteId={siteId} interval={interval} columnHeaders={["Path", "Visitors", "Views"]} - dataFetcher={useFetcher()} filters={filters} loaderUrl="/resources/paths" onClick={(path) => onFilterChange({ ...filters, path })} diff --git a/packages/server/app/routes/resources.referrer.tsx b/packages/server/app/routes/resources.referrer.tsx index f3112aa6..86869656 100644 --- a/packages/server/app/routes/resources.referrer.tsx +++ b/packages/server/app/routes/resources.referrer.tsx @@ -1,5 +1,3 @@ -import { useFetcher } from "react-router"; - import type { LoaderFunctionArgs } from "react-router"; import PaginatedTableCard from "~/components/PaginatedTableCard"; @@ -42,11 +40,10 @@ export const ReferrerCard = ({ timezone: string; }) => { return ( - siteId={siteId} interval={interval} columnHeaders={["Referrer", "Visitors", "Views"]} - dataFetcher={useFetcher()} loaderUrl="/resources/referrer" filters={filters} onClick={(referrer) => onFilterChange({ ...filters, referrer })}