diff --git a/app/analytics/collect.test.ts b/app/analytics/collect.test.ts index c24f8fe..07ebe77 100644 --- a/app/analytics/collect.test.ts +++ b/app/analytics/collect.test.ts @@ -29,6 +29,8 @@ function generateRequestParams(headers: Record) { // Cloudflare-specific request properties cf: { country: "US", + region: "Colorado", + city: "Denver", }, }; } @@ -65,6 +67,8 @@ describe("collectRequestHandler", () => { "Chrome", // browser name "", "example", // site id + "Colorado", // region + "Denver", // city ], doubles: [ 1, // new visitor diff --git a/app/analytics/collect.ts b/app/analytics/collect.ts index a140e4d..ec8bf31 100644 --- a/app/analytics/collect.ts +++ b/app/analytics/collect.ts @@ -78,10 +78,19 @@ export function collectRequestHandler(request: Request, env: Environment) { // NOTE: location is derived from Cloudflare-specific request properties // see: https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties - const country = (request as RequestInit).cf?.country; + const cfIncomingRequestProperties = (request as RequestInit).cf; + const city = cfIncomingRequestProperties?.city; + if (typeof city === "string") { + data.city = city; + } + const country = cfIncomingRequestProperties?.country; if (typeof country === "string") { data.country = country; } + const region = cfIncomingRequestProperties?.region; + if (typeof region === "string") { + data.region = region; + } writeDataPoint(env.WEB_COUNTER_AE, data); @@ -116,7 +125,9 @@ interface DataPoint { host?: string | undefined; userAgent?: string; path?: string; + city?: string; country?: string; + region?: string; referrer?: string; browserName?: string; deviceModel?: string; @@ -144,6 +155,8 @@ export function writeDataPoint( data.browserName || "", // blob6 data.deviceModel || "", // blob7 data.siteId || "", // blob8 + data.region || "", // blob9 + data.city || "", // blob10 ], doubles: [data.newVisitor || 0, data.newSession || 0], }; diff --git a/app/analytics/query.ts b/app/analytics/query.ts index fce0e48..2608045 100644 --- a/app/analytics/query.ts +++ b/app/analytics/query.ts @@ -478,6 +478,15 @@ export class AnalyticsEngineAPI { ); } + async getCountByCity( + siteId: string, + interval: string, + tz?: string, + page: number = 1, + ) { + return this.getVisitorCountByColumn(siteId, "city", interval, tz, page); + } + async getCountByCountry( siteId: string, interval: string, @@ -493,6 +502,21 @@ export class AnalyticsEngineAPI { ); } + async getCountByRegion( + siteId: string, + interval: string, + tz?: string, + page: number = 1, + ) { + return this.getVisitorCountByColumn( + siteId, + "region", + interval, + tz, + page, + ); + } + async getCountByReferrer( siteId: string, interval: string, diff --git a/app/analytics/schema.ts b/app/analytics/schema.ts index 07b429e..6438ee0 100644 --- a/app/analytics/schema.ts +++ b/app/analytics/schema.ts @@ -21,6 +21,8 @@ export const ColumnMappings = { browserName: "blob6", deviceModel: "blob7", siteId: "blob8", + region: "blob9", + city: "blob10", /** * doubles diff --git a/app/routes/dashboard.test.tsx b/app/routes/dashboard.test.tsx index 2a30191..cf65ced 100644 --- a/app/routes/dashboard.test.tsx +++ b/app/routes/dashboard.test.tsx @@ -286,12 +286,24 @@ describe("Dashboard route", () => { return json({ countsByProperty: [] }); }, }, + { + path: "/resources/city", + loader: () => { + return json({ countsByProperty: [] }); + }, + }, { path: "/resources/country", loader: () => { return json({ countsByProperty: [] }); }, }, + { + path: "/resources/region", + loader: () => { + return json({ countsByProperty: [] }); + }, + }, { path: "/resources/device", loader: () => { @@ -308,7 +320,9 @@ describe("Dashboard route", () => { expect(screen.getByText("Path")).toBeInTheDocument(); expect(screen.getByText("Referrer")).toBeInTheDocument(); expect(screen.getByText("Browser")).toBeInTheDocument(); + expect(screen.getByText("City")).toBeInTheDocument(); expect(screen.getByText("Country")).toBeInTheDocument(); + expect(screen.getByText("Region")).toBeInTheDocument(); expect(screen.getByText("Device")).toBeInTheDocument(); }); @@ -378,6 +392,18 @@ describe("Dashboard route", () => { }); }, }, + { + path: "/resources/city", + loader: () => { + return json({ + countsByProperty: [ + ["Chicago", 100], + ["Denver", 80], + ["San Diego", 60], + ], + }); + }, + }, { path: "/resources/country", loader: () => { @@ -390,6 +416,18 @@ describe("Dashboard route", () => { }); }, }, + { + path: "/resources/region", + loader: () => { + return json({ + countsByProperty: [ + ["California", 100], + ["Colorado", 80], + ["Illinois", 60], + ], + }); + }, + }, { path: "/resources/device", loader: () => { @@ -418,7 +456,9 @@ describe("Dashboard route", () => { expect(screen.getByText("/about")).toBeInTheDocument(); expect(screen.getByText("Chrome")).toBeInTheDocument(); expect(screen.getByText("google.com")).toBeInTheDocument(); + expect(screen.getByText("Denver")).toBeInTheDocument(); expect(screen.getByText("Canada")).toBeInTheDocument(); // assert converted CA -> Canada + expect(screen.getByText("California")).toBeInTheDocument(); expect(screen.getByText("Mobile")).toBeInTheDocument(); }); }); diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index ae5888f..c91db55 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -21,6 +21,8 @@ import { ReferrerCard } from "./resources.referrer"; import { PathsCard } from "./resources.paths"; import { BrowserCard } from "./resources.browser"; import { CountryCard } from "./resources.country"; +import { RegionCard } from "./resources.region"; +import { CityCard } from "./resources.city"; import { DeviceCard } from "./resources.device"; import TimeSeriesChart from "~/components/TimeSeriesChart"; @@ -286,18 +288,21 @@ export default function Dashboard() { interval={data.interval} /> -
+
+ +
+
- - + +
diff --git a/app/routes/resources.city.tsx b/app/routes/resources.city.tsx new file mode 100644 index 0000000..5b47a8a --- /dev/null +++ b/app/routes/resources.city.tsx @@ -0,0 +1,42 @@ +import { useFetcher } from "@remix-run/react"; + +import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; +import { json } from "@remix-run/cloudflare"; + +import { paramsFromUrl } from "~/lib/utils"; +import PaginatedTableCard from "~/components/PaginatedTableCard"; + +export async function loader({ context, request }: LoaderFunctionArgs) { + const { analyticsEngine } = context; + + const { interval, site, page = 1 } = paramsFromUrl(request.url); + const tz = context.requestTimezone as string; + + return json({ + countsByProperty: await analyticsEngine.getCountByCity( + site, + interval, + tz, + Number(page), + ), + page: Number(page), + }); +} + +export const CityCard = ({ + siteId, + interval, +}: { + siteId: string; + interval: string; +}) => { + return ( + ()} + loaderUrl="/resources/city" + /> + ); +}; diff --git a/app/routes/resources.region.tsx b/app/routes/resources.region.tsx new file mode 100644 index 0000000..5be5aa8 --- /dev/null +++ b/app/routes/resources.region.tsx @@ -0,0 +1,42 @@ +import { useFetcher } from "@remix-run/react"; + +import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; +import { json } from "@remix-run/cloudflare"; + +import { paramsFromUrl } from "~/lib/utils"; +import PaginatedTableCard from "~/components/PaginatedTableCard"; + +export async function loader({ context, request }: LoaderFunctionArgs) { + const { analyticsEngine } = context; + + const { interval, site, page = 1 } = paramsFromUrl(request.url); + const tz = context.requestTimezone as string; + + return json({ + countsByProperty: await analyticsEngine.getCountByRegion( + site, + interval, + tz, + Number(page), + ), + page: Number(page), + }); +} + +export const RegionCard = ({ + siteId, + interval, +}: { + siteId: string; + interval: string; +}) => { + return ( + ()} + loaderUrl="/resources/region" + /> + ); +};