Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 46 additions & 27 deletions packages/server/app/components/PaginatedTableCard.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,44 +16,64 @@
labelFormatter?: (label: string) => string;
}

const PaginatedTableCard = ({
const PaginatedTableCard = <T extends (...args: any[]) => Promise<{ countsByProperty: CountByProperty }>>({

Check warning on line 19 in packages/server/app/components/PaginatedTableCard.tsx

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
siteId,
interval,
dataFetcher,
columnHeaders,
filters,
loaderUrl,
onClick,
timezone,
labelFormatter,
}: PaginatedTableCardProps) => {
const countsByProperty = dataFetcher.data?.countsByProperty || [];
const fetcher = useFetcher<Awaited<ReturnType<T>>>();
const [page, setPage] = useState(1);
const lastParamsRef = useRef<string>("");

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]);

Check warning on line 62 in packages/server/app/components/PaginatedTableCard.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has a missing dependency: 'fetcher'. Either include it or remove the dependency array
Copy link

Copilot AI Jul 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including fetcher.load in the dependency array can cause infinite re-renders since fetcher.load is not a stable reference and changes on each render. Remove fetcher.load from the dependency array as it's not needed for the callback's functionality.

Suggested change
}, [fetcher.load, loaderUrl, siteId, interval, filters, timezone, page]);
}, [loaderUrl, siteId, interval, filters, timezone, page]);

Copilot uses AI. Check for mistakes.

// Load data when parameters change
useEffect(() => {
loadData();
}, [loadData]);

function handlePagination(newPage: number) {
setPage(newPage);
}

const hasMore = countsByProperty.length === 10;

return (
<Card className={dataFetcher.state === "loading" ? "opacity-60" : ""}>
<Card className={fetcher.state === "loading" ? "opacity-60" : ""}>
{countsByProperty ? (
<div className="grid grid-rows-[auto,40px] h-full">
<TableCard
Expand Down
14 changes: 9 additions & 5 deletions packages/server/app/components/TableCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}%`;
});
Expand Down Expand Up @@ -62,6 +65,7 @@ export default function TableCard({
<TableBody>
{(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]
Expand All @@ -76,7 +80,7 @@ export default function TableCard({

return (
<TableRow
key={item[0]}
key={String(item[0])}
className={`group [&_td]:last:rounded-b-md ${gridCols}`}
width={barChartPercentages[index]}
>
Expand All @@ -94,7 +98,7 @@ export default function TableCard({
</TableCell>

<TableCell className="text-right min-w-16">
{countFormatter.format(parseInt(item[1], 10))}
{countFormatter.format(parseInt(value, 10))}
</TableCell>

{item.length > 2 && item[2] !== undefined && (
Expand Down
6 changes: 3 additions & 3 deletions packages/server/app/load-context.ts
Original file line number Diff line number Diff line change
@@ -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<PlatformProxy<ExtendedEnv>, "dispose">;
export type Cloudflare = Omit<PlatformProxy<ExtendedEnv>, "dispose">;

declare module "react-router" {
interface AppLoadContext {
Expand Down
121 changes: 97 additions & 24 deletions packages/server/app/routes/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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 {
Expand All @@ -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] || "";
Expand All @@ -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,
Expand All @@ -111,17 +115,58 @@ 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() {
const [, setSearchParams] = useSearchParams();

const data = useLoaderData<typeof loader>();
const navigation = useNavigation();
const revalidator = useRevalidator();
const loading = navigation.state === "loading";

// Use ref to debounce revalidation calls
const lastRevalidateTime = useRef<number>(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
Expand All @@ -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
);
}
}
Expand Down Expand Up @@ -207,6 +252,34 @@ export default function Dashboard() {
</Select>
</div>

<div className="flex items-center">
<button
type="button"
onClick={() => revalidator.revalidate()}
disabled={revalidator.state === "loading"}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
aria-label="Refresh dashboard data"
title="Refresh data"
>
<svg
className={`h-4 w-4 ${revalidator.state === "loading" ? "animate-spin" : ""}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span className="ml-2">Refresh</span>
</button>
</div>

<div className="basis-auto flex">
<div className="m-auto">
<SearchFilterBadges
Expand Down Expand Up @@ -251,7 +324,7 @@ export default function Dashboard() {
/>
</div>
<div className="grid md:grid-cols-3 gap-4 mb-4">
{data.filters && data.filters.browserName ? (
{data.filters?.browserName ? (
<BrowserVersionCard
siteId={data.siteId}
interval={data.interval}
Expand Down Expand Up @@ -297,8 +370,8 @@ export function ErrorBoundary() {
const errorBody = isRouteErrorResponse(error)
? error.data
: error instanceof Error
? error.message
: "Unknown error";
? error.message
: "Unknown error";

return (
<div className="border-2 rounded p-4 bg-card">
Expand Down
Loading
Loading