From f46c45e3864bb4c6e1d2e811cee554d43fd85800 Mon Sep 17 00:00:00 2001 From: Cody Brouwers <11965195+codybrouwers@users.noreply.github.com> Date: Mon, 9 Jun 2025 12:05:02 -0400 Subject: [PATCH 1/5] Refresh data on page focus and add refresh button --- .../app/components/PaginatedTableCard.tsx | 142 ++-- packages/server/app/routes/dashboard.tsx | 630 ++++++++++-------- .../app/routes/resources.timeseries.tsx | 172 +++-- 3 files changed, 515 insertions(+), 429 deletions(-) diff --git a/packages/server/app/components/PaginatedTableCard.tsx b/packages/server/app/components/PaginatedTableCard.tsx index 324db749..38f12677 100644 --- a/packages/server/app/components/PaginatedTableCard.tsx +++ b/packages/server/app/components/PaginatedTableCard.tsx @@ -1,77 +1,97 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useFetcher } from "react-router"; import TableCard from "~/components/TableCard"; -import { Card } from "./ui/card"; +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; - onClick?: (key: string) => void; - timezone?: string; - labelFormatter?: (label: string) => string; + siteId: string; + interval: string; + columnHeaders: string[]; + filters?: SearchFilters; + loaderUrl: string; + onClick?: (key: string) => void; + timezone?: string; + labelFormatter?: (label: string) => string; } const PaginatedTableCard = ({ - siteId, - interval, - dataFetcher, - columnHeaders, - filters, - loaderUrl, - onClick, - timezone, - labelFormatter, + siteId, + interval, + columnHeaders, + filters, + loaderUrl, + onClick, + timezone, + labelFormatter, }: PaginatedTableCardProps) => { - const countsByProperty = dataFetcher.data?.countsByProperty || []; - const [page, setPage] = useState(1); + const fetcher = useFetcher(); + const [page, setPage] = useState(1); + const lastParamsRef = useRef(""); + + 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); + } + + // Add filter parameters + if (filters) { + for (const [key, value] of Object.entries(filters)) { + if (value) { + url.searchParams.set(key, value); + } + } + } + + 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]); - useEffect(() => { - const params = { - site: siteId, - interval, - timezone, - ...filters, - page, - }; + // Load data when parameters change + useEffect(() => { + loadData(); + }, [loadData]); - 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]); // + function handlePagination(newPage: number) { + setPage(newPage); + } - function handlePagination(page: number) { - setPage(page); - } + const hasMore = countsByProperty.length === 10; - const hasMore = countsByProperty.length === 10; - return ( - - {countsByProperty ? ( -
- - -
- ) : null} -
- ); + return ( + + {countsByProperty ? ( +
+ + +
+ ) : null} +
+ ); }; export default PaginatedTableCard; diff --git a/packages/server/app/routes/dashboard.tsx b/packages/server/app/routes/dashboard.tsx index aa706f08..89100f6e 100644 --- a/packages/server/app/routes/dashboard.tsx +++ b/packages/server/app/routes/dashboard.tsx @@ -1,309 +1,379 @@ import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "~/components/ui/select"; import type { LoaderFunctionArgs, MetaFunction } from "react-router"; import { - isRouteErrorResponse, - redirect, - useLoaderData, - useNavigation, - useRouteError, - useSearchParams, + isRouteErrorResponse, + 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 { Suspense, useEffect, useRef } from "react"; +import SearchFilterBadges from "~/components/SearchFilterBadges"; +import type { SearchFilters } from "~/lib/types"; import { - getFiltersFromSearchParams, - getIntervalType, - getUserTimezone, + 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"; export const meta: MetaFunction = () => { - return [ - { title: "Counterscale: Web Analytics" }, - { name: "description", content: "Counterscale: Web Analytics" }, - ]; + return [ + { title: "Counterscale: Web Analytics" }, + { name: "description", content: "Counterscale: Web Analytics" }, + ]; }; const MAX_RETENTION_DAYS = 90; export const loader = async ({ context, request }: LoaderFunctionArgs) => { - // NOTE: probably duped from getLoadContext / need to de-duplicate - if (!context.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) { - throw new Response("Missing credentials: CF_BEARER_TOKEN is not set.", { - status: 501, - }); - } - const { analyticsEngine } = context; - - const url = new URL(request.url); - - let interval; - try { - interval = url.searchParams.get("interval") || "7d"; - } catch { - interval = "7d"; - } - - // 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); - - // if at least one result - const redirectSite = sitesByHits[0]?.[0] || ""; - const redirectUrl = new URL(request.url); - redirectUrl.searchParams.set("site", redirectSite); - throw redirect(redirectUrl.toString()); - } - - const siteId = url.searchParams.get("site") || ""; - const actualSiteId = siteId === "@unknown" ? "" : siteId; - - const filters = getFiltersFromSearchParams(url.searchParams); - - // initiate requests to AE in parallel - - // sites by hits: This is to populate the "sites" dropdown. We query the full retention - // period (90 days) so that any site that has been active in the past 90 days - // will show up in the dropdown. - const sitesByHits = analyticsEngine.getSitesOrderedByHits( - `${MAX_RETENTION_DAYS}d`, - ); - - const intervalType = getIntervalType(interval); - - // await all requests to AE then return the results - - let out; - try { - out = { - siteId: actualSiteId, - sites: (await sitesByHits).map( - ([site, _]: [string, number]) => site, - ), - intervalType, - interval, - filters, - }; - } catch (err) { - console.error(err); - throw new Error("Failed to fetch data from Analytics Engine"); - } - - return out; + // NOTE: probably duped from getLoadContext / need to de-duplicate + if (!context.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) { + throw new Response("Missing credentials: CF_BEARER_TOKEN is not set.", { + status: 501, + }); + } + const { analyticsEngine } = context; + + const url = new URL(request.url); + + let interval: string; + try { + interval = url.searchParams.get("interval") || "7d"; + } catch { + interval = "7d"; + } + + // 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); + + // if at least one result + const redirectSite = sitesByHits[0]?.[0] || ""; + const redirectUrl = new URL(request.url); + redirectUrl.searchParams.set("site", redirectSite); + throw redirect(redirectUrl.toString()); + } + + const siteId = url.searchParams.get("site") || ""; + const actualSiteId = siteId === "@unknown" ? "" : siteId; + + const filters = getFiltersFromSearchParams(url.searchParams); + + // initiate requests to AE in parallel + + // sites by hits: This is to populate the "sites" dropdown. We query the full retention + // period (90 days) so that any site that has been active in the past 90 days + // will show up in the dropdown. + const sitesByHits = analyticsEngine.getSitesOrderedByHits( + `${MAX_RETENTION_DAYS}d`, + ); + + const intervalType = getIntervalType(interval); + + // await all requests to AE then return the results + + let out: { + siteId: string; + sites: string[]; + intervalType: string; + interval: string; + filters: SearchFilters; + }; + try { + out = { + siteId: actualSiteId, + sites: (await sitesByHits).map(([site, _]: [string, number]) => site), + intervalType, + interval, + filters, + }; + } catch (err) { + console.error(err); + throw new Error("Failed to fetch data from Analytics Engine"); + } + + return out; }; export default function Dashboard() { - const [, setSearchParams] = useSearchParams(); - - const data = useLoaderData(); - const navigation = useNavigation(); - const loading = navigation.state === "loading"; - - function changeSite(site: string) { - // intentionally not updating prev params; don't want search - // filters (e.g. referrer, path) to persist - - // TODO: might revisit if this is considered unexpected behavior - setSearchParams({ - site, - interval: data.interval, - }); - } - - function changeInterval(interval: string) { - setSearchParams((prev) => { - prev.set("interval", interval); - return prev; - }); - } - - const handleFilterChange = (filters: SearchFilters) => { - setSearchParams((prev) => { - for (const key in filters) { - if (Object.hasOwnProperty.call(filters, key)) { - prev.set( - key, - filters[key as keyof SearchFilters] as string, - ); - } - } - return prev; - }); - }; - - const handleFilterDelete = (key: string) => { - setSearchParams((prev) => { - prev.delete(key); - return prev; - }); - }; - - const userTimezone = getUserTimezone(); - - return ( -
-
-
- -
- -
- -
- -
-
- -
-
-
- -
-
- -
-
- -
-
- - -
-
- {data.filters && data.filters.browserName ? ( - - ) : ( - - )} - - - - -
-
-
- ); + const [, setSearchParams] = useSearchParams(); + + 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 = 1000; // 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 + + // TODO: might revisit if this is considered unexpected behavior + setSearchParams({ + site, + interval: data.interval, + }); + } + + function changeInterval(interval: string) { + setSearchParams((prev) => { + prev.set("interval", interval); + return prev; + }); + } + + const handleFilterChange = (filters: SearchFilters) => { + setSearchParams((prev) => { + for (const key in filters) { + if (Object.hasOwnProperty.call(filters, key)) { + prev.set(key, filters[key as keyof SearchFilters] as string); + } + } + return prev; + }); + }; + + const handleFilterDelete = (key: string) => { + setSearchParams((prev) => { + prev.delete(key); + return prev; + }); + }; + + const userTimezone = getUserTimezone(); + + return ( +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ {data.filters?.browserName ? ( + + ) : ( + + )} + + + + +
+
+
+ ); } export function ErrorBoundary() { - const error = useRouteError(); - - const errorTitle = isRouteErrorResponse(error) ? error.status : "Error"; - const errorBody = isRouteErrorResponse(error) - ? error.data - : error instanceof Error - ? error.message - : "Unknown error"; - - return ( -
-

{errorTitle}

-

{errorBody}

-
- ); + const error = useRouteError(); + + const errorTitle = isRouteErrorResponse(error) ? error.status : "Error"; + const errorBody = isRouteErrorResponse(error) + ? error.data + : error instanceof Error + ? error.message + : "Unknown error"; + + return ( +
+

{errorTitle}

+

{errorBody}

+
+ ); } diff --git a/packages/server/app/routes/resources.timeseries.tsx b/packages/server/app/routes/resources.timeseries.tsx index 567bd773..f94455e5 100644 --- a/packages/server/app/routes/resources.timeseries.tsx +++ b/packages/server/app/routes/resources.timeseries.tsx @@ -1,111 +1,107 @@ +import { useEffect } from "react"; import type { LoaderFunctionArgs } from "react-router"; +import { useFetcher } from "react-router"; import type { AnalyticsEngineAPI } from "~/analytics/query"; +import TimeSeriesChart from "~/components/TimeSeriesChart"; +import { Card, CardContent } from "~/components/ui/card"; +import type { SearchFilters } from "~/lib/types"; import { - getFiltersFromSearchParams, - paramsFromUrl, - getIntervalType, - getDateTimeRange, + getDateTimeRange, + getFiltersFromSearchParams, + getIntervalType, + paramsFromUrl, } from "~/lib/utils"; -import { useEffect } from "react"; -import { useFetcher } from "react-router"; -import { Card, CardContent } from "~/components/ui/card"; -import TimeSeriesChart from "~/components/TimeSeriesChart"; -import { SearchFilters } from "~/lib/types"; export async function loader({ - context, - request, + context, + request, }: LoaderFunctionArgs<{ - analyticsEngine: AnalyticsEngineAPI; + analyticsEngine: AnalyticsEngineAPI; }>) { - if (!context) throw new Error("Context is not defined"); + if (!context) throw new Error("Context is not defined"); - const { analyticsEngine } = context; - const { interval, site } = paramsFromUrl(request.url); - const url = new URL(request.url); - const tz = url.searchParams.get("timezone") || "UTC"; - const filters = getFiltersFromSearchParams(url.searchParams); + const { analyticsEngine } = context; + const { interval, site } = paramsFromUrl(request.url); + const url = new URL(request.url); + const tz = url.searchParams.get("timezone") || "UTC"; + const filters = getFiltersFromSearchParams(url.searchParams); - const intervalType = getIntervalType(interval); - const { startDate, endDate } = getDateTimeRange(interval, tz); + const intervalType = getIntervalType(interval); + const { startDate, endDate } = getDateTimeRange(interval, tz); - const viewsGroupedByInterval = - await analyticsEngine.getViewsGroupedByInterval( - site, - intervalType, - startDate, - endDate, - tz, - filters, - ); + const viewsGroupedByInterval = + await analyticsEngine.getViewsGroupedByInterval( + site, + intervalType, + startDate, + endDate, + tz, + filters, + ); - const chartData: { - date: string; - views: number; - visitors: number; - bounceRate: number; - }[] = []; - viewsGroupedByInterval.forEach((row) => { - const { views, visitors, bounces } = row[1]; + const chartData: { + date: string; + views: number; + visitors: number; + bounceRate: number; + }[] = []; + viewsGroupedByInterval.forEach((row) => { + const { views, visitors, bounces } = row[1]; - chartData.push({ - date: row[0], - views, - visitors, - bounceRate: Math.floor( - (visitors > 0 ? bounces / visitors : 0) * 100, - ), - }); - }); + chartData.push({ + date: row[0], + views, + visitors, + bounceRate: Math.floor((visitors > 0 ? bounces / visitors : 0) * 100), + }); + }); - return { - chartData: chartData, - intervalType: intervalType, - }; + return { + chartData: chartData, + intervalType: intervalType, + }; } export const TimeSeriesCard = ({ - siteId, - interval, - filters, - timezone, + siteId, + interval, + filters, + timezone, }: { - siteId: string; - interval: string; - filters: SearchFilters; - timezone: string; + siteId: string; + interval: string; + filters: SearchFilters; + timezone: string; }) => { - const dataFetcher = useFetcher(); - const { chartData, intervalType } = dataFetcher.data || {}; + const dataFetcher = useFetcher(); + + const { chartData, intervalType } = dataFetcher.data || {}; - useEffect(() => { - const params = { - site: siteId, - interval, - timezone, - ...filters, - }; + useEffect(() => { + const params = { + site: siteId, + interval, + timezone, + ...filters, + }; - dataFetcher.submit(params, { - method: "get", - action: `/resources/timeseries`, - }); - // NOTE: dataFetcher is intentionally omitted from the useEffect dependency array - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [siteId, interval, filters, timezone]); + dataFetcher.submit(params, { + method: "get", + action: `/resources/timeseries`, + }); + // NOTE: dataFetcher is intentionally omitted from the useEffect dependency array + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [siteId, interval, filters, timezone]); - return ( - - -
- {chartData && ( - - )} -
-
-
- ); + return ( + + +
+ {chartData && ( + + )} +
+
+
+ ); }; From 7156787415f22a72ac8133bdfca04feeb6c7e17c Mon Sep 17 00:00:00 2001 From: Cody Brouwers <11965195+codybrouwers@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:12:46 -0400 Subject: [PATCH 2/5] Refactor: Update type imports and enhance PaginatedTableCard with generic support - Changed import statements for `AppLoadContext` and `PlatformProxy` to use type-only imports. - Updated `Cloudflare` type to be exported. - Enhanced `PaginatedTableCard` to accept generic types for better type safety. - Modified `TableCard` to handle `CountByProperty` more robustly. - Adjusted various route components to utilize the updated `PaginatedTableCard` and loader types, ensuring proper type definitions for `analyticsEngine` and `cloudflare` in loaders. --- .../app/components/PaginatedTableCard.tsx | 7 +- packages/server/app/components/TableCard.tsx | 14 +- packages/server/app/load-context.ts | 36 ++-- packages/server/app/routes/dashboard.tsx | 41 +++-- .../server/app/routes/resources.browser.tsx | 13 +- .../app/routes/resources.browserversion.tsx | 5 +- .../server/app/routes/resources.country.tsx | 4 +- .../server/app/routes/resources.device.tsx | 5 +- .../server/app/routes/resources.paths.tsx | 5 +- .../server/app/routes/resources.referrer.tsx | 5 +- .../app/routes/resources.timeseries.tsx | 172 +++++++++--------- 11 files changed, 152 insertions(+), 155 deletions(-) diff --git a/packages/server/app/components/PaginatedTableCard.tsx b/packages/server/app/components/PaginatedTableCard.tsx index 38f12677..ad19df85 100644 --- a/packages/server/app/components/PaginatedTableCard.tsx +++ b/packages/server/app/components/PaginatedTableCard.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useFetcher } from "react-router"; -import TableCard from "~/components/TableCard"; - +import TableCard, { CountByProperty } from "~/components/TableCard"; import type { SearchFilters } from "~/lib/types"; import PaginationButtons from "./PaginationButtons"; import { Card } from "./ui/card"; @@ -17,7 +16,7 @@ interface PaginatedTableCardProps { labelFormatter?: (label: string) => string; } -const PaginatedTableCard = ({ +const PaginatedTableCard = ({ siteId, interval, columnHeaders, @@ -27,7 +26,7 @@ const PaginatedTableCard = ({ timezone, labelFormatter, }: PaginatedTableCardProps) => { - const fetcher = useFetcher(); + const fetcher = useFetcher(); const [page, setPage] = useState(1); const lastParamsRef = useRef(""); diff --git a/packages/server/app/components/TableCard.tsx b/packages/server/app/components/TableCard.tsx index 3fbda393..b1224458 100644 --- a/packages/server/app/components/TableCard.tsx +++ b/packages/server/app/components/TableCard.tsx @@ -7,16 +7,19 @@ import { TableRow, } from "~/components/ui/table"; -type CountByProperty = [string, string, string?][]; +export type CountByProperty = [string | [string, string], string | number, string?][]; function calculateCountPercentages(countByProperty: CountByProperty) { const totalCount = countByProperty.reduce( - (sum, row) => 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..94e91fec 100644 --- a/packages/server/app/load-context.ts +++ b/packages/server/app/load-context.ts @@ -1,34 +1,34 @@ -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; + CF_PAGES_COMMIT_SHA: string; } -type Cloudflare = Omit, "dispose">; +export type Cloudflare = Omit, "dispose">; declare module "react-router" { - interface AppLoadContext { - cloudflare: Cloudflare; - analyticsEngine: AnalyticsEngineAPI; - } + interface AppLoadContext { + cloudflare: Cloudflare; + analyticsEngine: AnalyticsEngineAPI; + } } type GetLoadContext = (args: { - request: Request; - context: { cloudflare: Cloudflare }; // load context _before_ augmentation + request: Request; + context: { cloudflare: Cloudflare }; // load context _before_ augmentation }) => AppLoadContext; // Shared implementation compatible with Vite, Wrangler, and Cloudflare Pages export const getLoadContext: GetLoadContext = ({ context }) => { - const analyticsEngine = new AnalyticsEngineAPI( - context.cloudflare.env.CF_ACCOUNT_ID, - context.cloudflare.env.CF_BEARER_TOKEN, - ); + const analyticsEngine = new AnalyticsEngineAPI( + context.cloudflare.env.CF_ACCOUNT_ID, + context.cloudflare.env.CF_BEARER_TOKEN, + ); - return { - ...context, - analyticsEngine: analyticsEngine, - }; + return { + ...context, + analyticsEngine: analyticsEngine, + }; }; diff --git a/packages/server/app/routes/dashboard.tsx b/packages/server/app/routes/dashboard.tsx index 89100f6e..3d2da205 100644 --- a/packages/server/app/routes/dashboard.tsx +++ b/packages/server/app/routes/dashboard.tsx @@ -24,7 +24,7 @@ import { DeviceCard } from "./resources.device"; import { PathsCard } from "./resources.paths"; import { ReferrerCard } from "./resources.referrer"; -import { Suspense, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import SearchFilterBadges from "~/components/SearchFilterBadges"; import type { SearchFilters } from "~/lib/types"; import { @@ -34,6 +34,8 @@ import { } from "~/lib/utils"; 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 [ @@ -44,19 +46,24 @@ 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); @@ -96,16 +103,8 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => { const intervalType = getIntervalType(interval); // await all requests to AE then return the results - - let out: { - siteId: string; - sites: string[]; - intervalType: string; - interval: string; - filters: SearchFilters; - }; try { - out = { + return { siteId: actualSiteId, sites: (await sitesByHits).map(([site, _]: [string, number]) => site), intervalType, @@ -116,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() { @@ -133,7 +130,7 @@ export default function Dashboard() { // Refetch data when window regains focus useEffect(() => { - const DEBOUNCE_MS = 1000; // Prevent multiple revalidations within 1 second + const DEBOUNCE_MS = 1_000; // Prevent multiple revalidations within 1 second const debouncedRevalidate = () => { const now = Date.now(); @@ -192,7 +189,10 @@ export default function Dashboard() { setSearchParams((prev) => { for (const key in filters) { if (Object.hasOwnProperty.call(filters, key)) { - prev.set(key, filters[key as keyof SearchFilters] as string); + prev.set( + key, + filters[key as keyof SearchFilters] as string + ); } } return prev; @@ -222,7 +222,10 @@ export default function Dashboard() { {/* SelectItem explodes if given an empty string for `value` so coerce to @unknown */} {data.sites.map((siteId: string) => ( - + {siteId || "(unknown)"} ))} diff --git a/packages/server/app/routes/resources.browser.tsx b/packages/server/app/routes/resources.browser.tsx index 0d855bc5..a9c70503 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..5d45410b 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..bbf3be9d 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..57cfa18e 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..6f4f9bd3 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..a13b3abb 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 })} diff --git a/packages/server/app/routes/resources.timeseries.tsx b/packages/server/app/routes/resources.timeseries.tsx index f94455e5..567bd773 100644 --- a/packages/server/app/routes/resources.timeseries.tsx +++ b/packages/server/app/routes/resources.timeseries.tsx @@ -1,107 +1,111 @@ -import { useEffect } from "react"; import type { LoaderFunctionArgs } from "react-router"; -import { useFetcher } from "react-router"; import type { AnalyticsEngineAPI } from "~/analytics/query"; -import TimeSeriesChart from "~/components/TimeSeriesChart"; -import { Card, CardContent } from "~/components/ui/card"; -import type { SearchFilters } from "~/lib/types"; import { - getDateTimeRange, - getFiltersFromSearchParams, - getIntervalType, - paramsFromUrl, + getFiltersFromSearchParams, + paramsFromUrl, + getIntervalType, + getDateTimeRange, } from "~/lib/utils"; +import { useEffect } from "react"; +import { useFetcher } from "react-router"; +import { Card, CardContent } from "~/components/ui/card"; +import TimeSeriesChart from "~/components/TimeSeriesChart"; +import { SearchFilters } from "~/lib/types"; export async function loader({ - context, - request, + context, + request, }: LoaderFunctionArgs<{ - analyticsEngine: AnalyticsEngineAPI; + analyticsEngine: AnalyticsEngineAPI; }>) { - if (!context) throw new Error("Context is not defined"); + if (!context) throw new Error("Context is not defined"); - const { analyticsEngine } = context; - const { interval, site } = paramsFromUrl(request.url); - const url = new URL(request.url); - const tz = url.searchParams.get("timezone") || "UTC"; - const filters = getFiltersFromSearchParams(url.searchParams); + const { analyticsEngine } = context; + const { interval, site } = paramsFromUrl(request.url); + const url = new URL(request.url); + const tz = url.searchParams.get("timezone") || "UTC"; + const filters = getFiltersFromSearchParams(url.searchParams); - const intervalType = getIntervalType(interval); - const { startDate, endDate } = getDateTimeRange(interval, tz); + const intervalType = getIntervalType(interval); + const { startDate, endDate } = getDateTimeRange(interval, tz); - const viewsGroupedByInterval = - await analyticsEngine.getViewsGroupedByInterval( - site, - intervalType, - startDate, - endDate, - tz, - filters, - ); + const viewsGroupedByInterval = + await analyticsEngine.getViewsGroupedByInterval( + site, + intervalType, + startDate, + endDate, + tz, + filters, + ); - const chartData: { - date: string; - views: number; - visitors: number; - bounceRate: number; - }[] = []; - viewsGroupedByInterval.forEach((row) => { - const { views, visitors, bounces } = row[1]; + const chartData: { + date: string; + views: number; + visitors: number; + bounceRate: number; + }[] = []; + viewsGroupedByInterval.forEach((row) => { + const { views, visitors, bounces } = row[1]; - chartData.push({ - date: row[0], - views, - visitors, - bounceRate: Math.floor((visitors > 0 ? bounces / visitors : 0) * 100), - }); - }); + chartData.push({ + date: row[0], + views, + visitors, + bounceRate: Math.floor( + (visitors > 0 ? bounces / visitors : 0) * 100, + ), + }); + }); - return { - chartData: chartData, - intervalType: intervalType, - }; + return { + chartData: chartData, + intervalType: intervalType, + }; } export const TimeSeriesCard = ({ - siteId, - interval, - filters, - timezone, + siteId, + interval, + filters, + timezone, }: { - siteId: string; - interval: string; - filters: SearchFilters; - timezone: string; + siteId: string; + interval: string; + filters: SearchFilters; + timezone: string; }) => { - const dataFetcher = useFetcher(); - - const { chartData, intervalType } = dataFetcher.data || {}; + const dataFetcher = useFetcher(); + const { chartData, intervalType } = dataFetcher.data || {}; - useEffect(() => { - const params = { - site: siteId, - interval, - timezone, - ...filters, - }; + useEffect(() => { + const params = { + site: siteId, + interval, + timezone, + ...filters, + }; - dataFetcher.submit(params, { - method: "get", - action: `/resources/timeseries`, - }); - // NOTE: dataFetcher is intentionally omitted from the useEffect dependency array - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [siteId, interval, filters, timezone]); + dataFetcher.submit(params, { + method: "get", + action: `/resources/timeseries`, + }); + // NOTE: dataFetcher is intentionally omitted from the useEffect dependency array + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [siteId, interval, filters, timezone]); - return ( - - -
- {chartData && ( - - )} -
-
-
- ); + return ( + + +
+ {chartData && ( + + )} +
+
+
+ ); }; From 25f33f1482ce695dc9c1ee7d3d025600c54673bd Mon Sep 17 00:00:00 2001 From: Cody Brouwers <11965195+codybrouwers@users.noreply.github.com> Date: Wed, 23 Jul 2025 13:41:50 -0400 Subject: [PATCH 3/5] Replace tabs with spaces --- .../app/components/PaginatedTableCard.tsx | 142 +++++++++--------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/packages/server/app/components/PaginatedTableCard.tsx b/packages/server/app/components/PaginatedTableCard.tsx index ad19df85..91cda77e 100644 --- a/packages/server/app/components/PaginatedTableCard.tsx +++ b/packages/server/app/components/PaginatedTableCard.tsx @@ -6,91 +6,91 @@ import PaginationButtons from "./PaginationButtons"; import { Card } from "./ui/card"; interface PaginatedTableCardProps { - siteId: string; - interval: string; - columnHeaders: string[]; - filters?: SearchFilters; - loaderUrl: string; - onClick?: (key: string) => void; - timezone?: string; - labelFormatter?: (label: string) => string; + siteId: string; + interval: string; + columnHeaders: string[]; + filters?: SearchFilters; + loaderUrl: string; + onClick?: (key: string) => void; + timezone?: string; + labelFormatter?: (label: string) => string; } const PaginatedTableCard = ({ - siteId, - interval, - columnHeaders, - filters, - loaderUrl, - onClick, - timezone, - labelFormatter, + siteId, + interval, + columnHeaders, + filters, + loaderUrl, + onClick, + timezone, + labelFormatter, }: PaginatedTableCardProps) => { - const fetcher = useFetcher(); - const [page, setPage] = useState(1); - const lastParamsRef = useRef(""); + const fetcher = useFetcher(); + const [page, setPage] = useState(1); + const lastParamsRef = useRef(""); - const countsByProperty = fetcher.data?.countsByProperty || []; + 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()); + // 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); - } + if (timezone) { + url.searchParams.set("timezone", timezone); + } - // Add filter parameters - if (filters) { - for (const [key, value] of Object.entries(filters)) { - if (value) { - url.searchParams.set(key, value); - } - } - } + // Add filter parameters + if (filters) { + for (const [key, value] of Object.entries(filters)) { + if (value) { + url.searchParams.set(key, value); + } + } + } - const fullUrl = url.pathname + url.search; + 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]); + // 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]); + // Load data when parameters change + useEffect(() => { + loadData(); + }, [loadData]); - function handlePagination(newPage: number) { - setPage(newPage); - } + function handlePagination(newPage: number) { + setPage(newPage); + } - const hasMore = countsByProperty.length === 10; + const hasMore = countsByProperty.length === 10; - return ( - - {countsByProperty ? ( -
- - -
- ) : null} -
- ); + return ( + + {countsByProperty ? ( +
+ + +
+ ) : null} +
+ ); }; export default PaginatedTableCard; From f0bf94237abb0890659fbe477edf22da13c89452 Mon Sep 17 00:00:00 2001 From: Cody Brouwers <11965195+codybrouwers@users.noreply.github.com> Date: Wed, 23 Jul 2025 13:43:08 -0400 Subject: [PATCH 4/5] Replace tabs with spaces --- packages/server/app/load-context.ts | 30 +- packages/server/app/routes/dashboard.tsx | 688 +++++++++++------------ 2 files changed, 359 insertions(+), 359 deletions(-) diff --git a/packages/server/app/load-context.ts b/packages/server/app/load-context.ts index 94e91fec..0b71d7e4 100644 --- a/packages/server/app/load-context.ts +++ b/packages/server/app/load-context.ts @@ -3,32 +3,32 @@ import type { PlatformProxy } from "wrangler"; import { AnalyticsEngineAPI } from "./analytics/query"; interface ExtendedEnv extends Env { - CF_PAGES_COMMIT_SHA: string; + CF_PAGES_COMMIT_SHA: string; } export type Cloudflare = Omit, "dispose">; declare module "react-router" { - interface AppLoadContext { - cloudflare: Cloudflare; - analyticsEngine: AnalyticsEngineAPI; - } + interface AppLoadContext { + cloudflare: Cloudflare; + analyticsEngine: AnalyticsEngineAPI; + } } type GetLoadContext = (args: { - request: Request; - context: { cloudflare: Cloudflare }; // load context _before_ augmentation + request: Request; + context: { cloudflare: Cloudflare }; // load context _before_ augmentation }) => AppLoadContext; // Shared implementation compatible with Vite, Wrangler, and Cloudflare Pages export const getLoadContext: GetLoadContext = ({ context }) => { - const analyticsEngine = new AnalyticsEngineAPI( - context.cloudflare.env.CF_ACCOUNT_ID, - context.cloudflare.env.CF_BEARER_TOKEN, - ); + const analyticsEngine = new AnalyticsEngineAPI( + context.cloudflare.env.CF_ACCOUNT_ID, + context.cloudflare.env.CF_BEARER_TOKEN, + ); - return { - ...context, - analyticsEngine: analyticsEngine, - }; + return { + ...context, + analyticsEngine: analyticsEngine, + }; }; diff --git a/packages/server/app/routes/dashboard.tsx b/packages/server/app/routes/dashboard.tsx index 3d2da205..c371080d 100644 --- a/packages/server/app/routes/dashboard.tsx +++ b/packages/server/app/routes/dashboard.tsx @@ -1,20 +1,20 @@ import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "~/components/ui/select"; import type { LoaderFunctionArgs, MetaFunction } from "react-router"; import { - isRouteErrorResponse, - redirect, - useLoaderData, - useNavigation, - useRevalidator, - useRouteError, - useSearchParams, + isRouteErrorResponse, + redirect, + useLoaderData, + useNavigation, + useRevalidator, + useRouteError, + useSearchParams, } from "react-router"; import { BrowserCard } from "./resources.browser"; @@ -28,9 +28,9 @@ import { useEffect, useRef } from "react"; import SearchFilterBadges from "~/components/SearchFilterBadges"; import type { SearchFilters } from "~/lib/types"; import { - getFiltersFromSearchParams, - getIntervalType, - getUserTimezone, + getFiltersFromSearchParams, + getIntervalType, + getUserTimezone, } from "~/lib/utils"; import { StatsCard } from "./resources.stats"; import { TimeSeriesCard } from "./resources.timeseries"; @@ -38,345 +38,345 @@ import { AnalyticsEngineAPI } from "~/analytics/query"; import { Cloudflare } from "~/load-context"; export const meta: MetaFunction = () => { - return [ - { title: "Counterscale: Web Analytics" }, - { name: "description", content: "Counterscale: Web Analytics" }, - ]; + return [ + { title: "Counterscale: Web Analytics" }, + { name: "description", content: "Counterscale: Web Analytics" }, + ]; }; const MAX_RETENTION_DAYS = 90; export const loader = async ({ context, request }: LoaderFunctionArgs<{ - analyticsEngine: AnalyticsEngineAPI; - cloudflare: Cloudflare; + 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 (!cloudflare?.env?.CF_ACCOUNT_ID) { - throw new Response("Missing credentials: CF_ACCOUNT_ID is not set.", { - status: 501, - }); - } - if (!cloudflare?.env?.CF_BEARER_TOKEN) { - throw new Response("Missing credentials: CF_BEARER_TOKEN is not set.", { - status: 501, - }); - } - - const url = new URL(request.url); - - let interval: string; - try { - interval = url.searchParams.get("interval") || "7d"; - } catch { - interval = "7d"; - } - - // 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); - - // if at least one result - const redirectSite = sitesByHits[0]?.[0] || ""; - const redirectUrl = new URL(request.url); - redirectUrl.searchParams.set("site", redirectSite); - throw redirect(redirectUrl.toString()); - } - - const siteId = url.searchParams.get("site") || ""; - const actualSiteId = siteId === "@unknown" ? "" : siteId; - - const filters = getFiltersFromSearchParams(url.searchParams); - - // initiate requests to AE in parallel - - // sites by hits: This is to populate the "sites" dropdown. We query the full retention - // period (90 days) so that any site that has been active in the past 90 days - // will show up in the dropdown. - const sitesByHits = analyticsEngine.getSitesOrderedByHits( - `${MAX_RETENTION_DAYS}d`, - ); - - const intervalType = getIntervalType(interval); - - // await all requests to AE then return the results - try { - return { - siteId: actualSiteId, - sites: (await sitesByHits).map(([site, _]: [string, number]) => site), - intervalType, - interval, - filters, - }; - } catch (err) { - console.error(err); - throw new Error("Failed to fetch data from Analytics Engine"); - } + if (!context) throw new Error("Context is not defined"); + + const { analyticsEngine, cloudflare } = context; + // NOTE: probably duped from getLoadContext / need to de-duplicate + if (!cloudflare?.env?.CF_ACCOUNT_ID) { + throw new Response("Missing credentials: CF_ACCOUNT_ID is not set.", { + status: 501, + }); + } + if (!cloudflare?.env?.CF_BEARER_TOKEN) { + throw new Response("Missing credentials: CF_BEARER_TOKEN is not set.", { + status: 501, + }); + } + + const url = new URL(request.url); + + let interval: string; + try { + interval = url.searchParams.get("interval") || "7d"; + } catch { + interval = "7d"; + } + + // 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); + + // if at least one result + const redirectSite = sitesByHits[0]?.[0] || ""; + const redirectUrl = new URL(request.url); + redirectUrl.searchParams.set("site", redirectSite); + throw redirect(redirectUrl.toString()); + } + + const siteId = url.searchParams.get("site") || ""; + const actualSiteId = siteId === "@unknown" ? "" : siteId; + + const filters = getFiltersFromSearchParams(url.searchParams); + + // initiate requests to AE in parallel + + // sites by hits: This is to populate the "sites" dropdown. We query the full retention + // period (90 days) so that any site that has been active in the past 90 days + // will show up in the dropdown. + const sitesByHits = analyticsEngine.getSitesOrderedByHits( + `${MAX_RETENTION_DAYS}d`, + ); + + const intervalType = getIntervalType(interval); + + // await all requests to AE then return the results + try { + return { + siteId: actualSiteId, + sites: (await sitesByHits).map(([site, _]: [string, number]) => site), + intervalType, + interval, + filters, + }; + } catch (err) { + console.error(err); + throw new Error("Failed to fetch data from Analytics Engine"); + } }; export default function Dashboard() { - const [, setSearchParams] = useSearchParams(); - - 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 - - // TODO: might revisit if this is considered unexpected behavior - setSearchParams({ - site, - interval: data.interval, - }); - } - - function changeInterval(interval: string) { - setSearchParams((prev) => { - prev.set("interval", interval); - return prev; - }); - } - - const handleFilterChange = (filters: SearchFilters) => { - setSearchParams((prev) => { - for (const key in filters) { - if (Object.hasOwnProperty.call(filters, key)) { - prev.set( - key, - filters[key as keyof SearchFilters] as string - ); - } - } - return prev; - }); - }; - - const handleFilterDelete = (key: string) => { - setSearchParams((prev) => { - prev.delete(key); - return prev; - }); - }; - - const userTimezone = getUserTimezone(); - - return ( -
-
-
- -
- -
- -
- -
- -
- -
-
- -
-
-
- -
-
- -
-
- -
-
- - -
-
- {data.filters?.browserName ? ( - - ) : ( - - )} - - - - -
-
-
- ); + const [, setSearchParams] = useSearchParams(); + + 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 + + // TODO: might revisit if this is considered unexpected behavior + setSearchParams({ + site, + interval: data.interval, + }); + } + + function changeInterval(interval: string) { + setSearchParams((prev) => { + prev.set("interval", interval); + return prev; + }); + } + + const handleFilterChange = (filters: SearchFilters) => { + setSearchParams((prev) => { + for (const key in filters) { + if (Object.hasOwnProperty.call(filters, key)) { + prev.set( + key, + filters[key as keyof SearchFilters] as string + ); + } + } + return prev; + }); + }; + + const handleFilterDelete = (key: string) => { + setSearchParams((prev) => { + prev.delete(key); + return prev; + }); + }; + + const userTimezone = getUserTimezone(); + + return ( +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ {data.filters?.browserName ? ( + + ) : ( + + )} + + + + +
+
+
+ ); } export function ErrorBoundary() { - const error = useRouteError(); - - const errorTitle = isRouteErrorResponse(error) ? error.status : "Error"; - const errorBody = isRouteErrorResponse(error) - ? error.data - : error instanceof Error - ? error.message - : "Unknown error"; - - return ( -
-

{errorTitle}

-

{errorBody}

-
- ); + const error = useRouteError(); + + const errorTitle = isRouteErrorResponse(error) ? error.status : "Error"; + const errorBody = isRouteErrorResponse(error) + ? error.data + : error instanceof Error + ? error.message + : "Unknown error"; + + return ( +
+

{errorTitle}

+

{errorBody}

+
+ ); } From b889efd3cea244c899289572b519b73a3a8a5684 Mon Sep 17 00:00:00 2001 From: Cody Brouwers <11965195+codybrouwers@users.noreply.github.com> Date: Wed, 23 Jul 2025 13:48:04 -0400 Subject: [PATCH 5/5] Update generic --- packages/server/app/components/PaginatedTableCard.tsx | 4 ++-- packages/server/app/routes/resources.browser.tsx | 2 +- packages/server/app/routes/resources.browserversion.tsx | 2 +- packages/server/app/routes/resources.country.tsx | 2 +- packages/server/app/routes/resources.device.tsx | 2 +- packages/server/app/routes/resources.paths.tsx | 2 +- packages/server/app/routes/resources.referrer.tsx | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/server/app/components/PaginatedTableCard.tsx b/packages/server/app/components/PaginatedTableCard.tsx index 91cda77e..f8636716 100644 --- a/packages/server/app/components/PaginatedTableCard.tsx +++ b/packages/server/app/components/PaginatedTableCard.tsx @@ -16,7 +16,7 @@ interface PaginatedTableCardProps { labelFormatter?: (label: string) => string; } -const PaginatedTableCard = ({ +const PaginatedTableCard = Promise<{ countsByProperty: CountByProperty }>>({ siteId, interval, columnHeaders, @@ -26,7 +26,7 @@ const PaginatedTableCard = ({ timezone, labelFormatter, }: PaginatedTableCardProps) => { - const fetcher = useFetcher(); + const fetcher = useFetcher>>(); const [page, setPage] = useState(1); const lastParamsRef = useRef(""); diff --git a/packages/server/app/routes/resources.browser.tsx b/packages/server/app/routes/resources.browser.tsx index a9c70503..c6809a34 100644 --- a/packages/server/app/routes/resources.browser.tsx +++ b/packages/server/app/routes/resources.browser.tsx @@ -42,7 +42,7 @@ export const BrowserCard = ({ timezone: string; }) => { return ( - >> + siteId={siteId} interval={interval} columnHeaders={["Browser", "Visitors"]} diff --git a/packages/server/app/routes/resources.browserversion.tsx b/packages/server/app/routes/resources.browserversion.tsx index 5d45410b..cb3046ee 100644 --- a/packages/server/app/routes/resources.browserversion.tsx +++ b/packages/server/app/routes/resources.browserversion.tsx @@ -38,7 +38,7 @@ export const BrowserVersionCard = ({ timezone: string; }) => { return ( - >> + siteId={siteId} interval={interval} columnHeaders={[`${filters.browserName} Versions`, "Visitors"]} diff --git a/packages/server/app/routes/resources.country.tsx b/packages/server/app/routes/resources.country.tsx index bbf3be9d..8b3059ec 100644 --- a/packages/server/app/routes/resources.country.tsx +++ b/packages/server/app/routes/resources.country.tsx @@ -63,7 +63,7 @@ export const CountryCard = ({ timezone: string; }) => { return ( - >> + siteId={siteId} interval={interval} columnHeaders={["Country", "Visitors"]} diff --git a/packages/server/app/routes/resources.device.tsx b/packages/server/app/routes/resources.device.tsx index 57cfa18e..57f75cb2 100644 --- a/packages/server/app/routes/resources.device.tsx +++ b/packages/server/app/routes/resources.device.tsx @@ -39,7 +39,7 @@ export const DeviceCard = ({ timezone: string; }) => { return ( - >> + siteId={siteId} interval={interval} columnHeaders={["Device", "Visitors"]} diff --git a/packages/server/app/routes/resources.paths.tsx b/packages/server/app/routes/resources.paths.tsx index 6f4f9bd3..70234a78 100644 --- a/packages/server/app/routes/resources.paths.tsx +++ b/packages/server/app/routes/resources.paths.tsx @@ -42,7 +42,7 @@ export const PathsCard = ({ timezone: string; }) => { return ( - >> + siteId={siteId} interval={interval} columnHeaders={["Path", "Visitors", "Views"]} diff --git a/packages/server/app/routes/resources.referrer.tsx b/packages/server/app/routes/resources.referrer.tsx index a13b3abb..86869656 100644 --- a/packages/server/app/routes/resources.referrer.tsx +++ b/packages/server/app/routes/resources.referrer.tsx @@ -40,7 +40,7 @@ export const ReferrerCard = ({ timezone: string; }) => { return ( - >> + siteId={siteId} interval={interval} columnHeaders={["Referrer", "Visitors", "Views"]}