diff --git a/app/analytics/query.ts b/app/analytics/query.ts index 3e10758..2fde5e6 100644 --- a/app/analytics/query.ts +++ b/app/analytics/query.ts @@ -600,15 +600,12 @@ export class AnalyticsEngineAPI { ); } - async getSitesOrderedByHits(interval: string, tz?: string, limit?: number) { + async getSitesOrderedByHits(interval: string, limit?: number) { // defaults to 1 day if not specified limit = limit || 10; - const { startIntervalSql, endIntervalSql } = intervalToSql( - interval, - tz, - ); + const { startIntervalSql, endIntervalSql } = intervalToSql(interval); const query = ` SELECT SUM(_sample_interval) as count, diff --git a/app/components/PaginatedTableCard.tsx b/app/components/PaginatedTableCard.tsx index 805e45c..9e65131 100644 --- a/app/components/PaginatedTableCard.tsx +++ b/app/components/PaginatedTableCard.tsx @@ -5,6 +5,17 @@ import { Card } from "./ui/card"; import PaginationButtons from "./PaginationButtons"; import { SearchFilters } from "~/lib/types"; +interface PaginatedTableCardProps { + siteId: string; + interval: string; + dataFetcher: any; + columnHeaders: string[]; + filters?: SearchFilters; + loaderUrl: string; + onClick?: (key: string) => void; + timezone?: string; +} + const PaginatedTableCard = ({ siteId, interval, @@ -13,15 +24,8 @@ const PaginatedTableCard = ({ filters, loaderUrl, onClick, -}: { - siteId: string; - interval: string; - dataFetcher: any; // ignore type for now - columnHeaders: string[]; - filters?: SearchFilters; - loaderUrl: string; - onClick?: (key: string) => void; -}) => { + timezone, +}: PaginatedTableCardProps) => { const countsByProperty = dataFetcher.data?.countsByProperty || []; const page = dataFetcher.data?.page || 1; @@ -33,7 +37,7 @@ const PaginatedTableCard = ({ .join("") : ""; - let url = `${loaderUrl}?site=${siteId}&interval=${interval}${filterString}`; + let url = `${loaderUrl}?site=${siteId}&interval=${interval}&timezone=${timezone}${filterString}`; if (page) { url += `&page=${page}`; } diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 79c01a9..72cf192 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -13,6 +13,7 @@ import { export default function TimeSeriesChart({ data, intervalType, + timezone, }: InferProps) { // chart doesn't really work no data points, so just bail out if (data.length === 0) { @@ -51,13 +52,15 @@ export default function TimeSeriesChart({ // convert from utc to local time dateObj.setMinutes(dateObj.getMinutes() - dateObj.getTimezoneOffset()); - return dateObj.toLocaleString("en-us", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - }); + return ( + dateObj.toLocaleString("en-us", { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + }) + ` ${timezone}` + ); } return ( @@ -97,4 +100,5 @@ TimeSeriesChart.propTypes = { }).isRequired, ).isRequired, intervalType: PropTypes.string, + timezone: PropTypes.string, }; diff --git a/app/lib/utils.ts b/app/lib/utils.ts index d9e21dc..0a0836c 100644 --- a/app/lib/utils.ts +++ b/app/lib/utils.ts @@ -1,5 +1,11 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; + +dayjs.extend(utc); +dayjs.extend(timezone); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -43,3 +49,62 @@ export function getFiltersFromSearchParams(searchParams: URLSearchParams) { return filters; } + +export function getUserTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch (e) { + // Fallback to UTC if browser doesn't support Intl API + return "UTC"; + } +} + +export function getIntervalType(interval: string): "DAY" | "HOUR" { + switch (interval) { + case "today": + case "yesterday": + case "1d": + return "HOUR"; + case "7d": + case "30d": + case "90d": + return "DAY"; + default: + return "DAY"; + } +} + +export function getDateTimeRange(interval: string, tz: string) { + let localDateTime = dayjs().utc(); + let localEndDateTime: dayjs.Dayjs | undefined; + + if (interval === "today") { + localDateTime = localDateTime.tz(tz).startOf("day"); + } else if (interval === "yesterday") { + localDateTime = localDateTime.tz(tz).startOf("day").subtract(1, "day"); + localEndDateTime = localDateTime.endOf("day").add(2, "ms"); + } else { + const daysAgo = Number(interval.split("d")[0]); + const intervalType = getIntervalType(interval); + + if (intervalType === "DAY") { + localDateTime = localDateTime + .subtract(daysAgo, "day") + .tz(tz) + .startOf("day"); + } else if (intervalType === "HOUR") { + localDateTime = localDateTime + .subtract(daysAgo, "day") + .startOf("hour"); + } + } + + if (!localEndDateTime) { + localEndDateTime = dayjs().utc().tz(tz); + } + + return { + startDate: localDateTime.toDate(), + endDate: localEndDateTime.toDate(), + }; +} diff --git a/app/routes/__tests__/dashboard.test.tsx b/app/routes/__tests__/dashboard.test.tsx index 9e72759..8fdab73 100644 --- a/app/routes/__tests__/dashboard.test.tsx +++ b/app/routes/__tests__/dashboard.test.tsx @@ -115,24 +115,6 @@ describe("Dashboard route", () => { }), ); - // response for get counts - fetch.mockResolvedValueOnce( - createFetchResponse({ - data: [ - { isVisit: 1, isVisitor: 1, count: 1 }, - { isVisit: 1, isVisitor: 0, count: 2 }, - { isVisit: 0, isVisitor: 0, count: 3 }, - ], - }), - ); - - // response for getViewsGroupedByInterval - fetch.mockResolvedValueOnce( - createFetchResponse({ - data: [{ bucket: "2024-01-11 05:00:00", count: 4 }], - }), - ); - vi.setSystemTime(new Date("2024-01-18T09:33:02").getTime()); const response = await loader({ @@ -149,19 +131,6 @@ describe("Dashboard route", () => { filters: {}, siteId: "test-siteid", sites: ["test-siteid"], - views: 6, - visits: 3, - visitors: 1, - viewsGroupedByInterval: [ - ["2024-01-11 05:00:00", 4], - ["2024-01-12 05:00:00", 0], - ["2024-01-13 05:00:00", 0], - ["2024-01-14 05:00:00", 0], - ["2024-01-15 05:00:00", 0], - ["2024-01-16 05:00:00", 0], - ["2024-01-17 05:00:00", 0], - ["2024-01-18 05:00:00", 0], - ], intervalType: "DAY", interval: "7d", }); @@ -188,19 +157,6 @@ describe("Dashboard route", () => { filters: {}, siteId: "", sites: [], - views: 0, - visits: 0, - visitors: 0, - viewsGroupedByInterval: [ - ["2024-01-11 05:00:00", 0], - ["2024-01-12 05:00:00", 0], - ["2024-01-13 05:00:00", 0], - ["2024-01-14 05:00:00", 0], - ["2024-01-15 05:00:00", 0], - ["2024-01-16 05:00:00", 0], - ["2024-01-17 05:00:00", 0], - ["2024-01-18 05:00:00", 0], - ], intervalType: "DAY", interval: "7d", }); @@ -212,10 +168,6 @@ describe("Dashboard route", () => { return json({ siteId: "@unknown", sites: [], - views: [], - visits: [], - visitors: [], - viewsGroupedByInterval: [], intervalType: "day", }); } @@ -226,6 +178,22 @@ describe("Dashboard route", () => { Component: Dashboard, loader, children: [ + { + path: "/resources/timeseries", + loader: () => { + return json({ chartData: [] }); + }, + }, + { + path: "/resources/stats", + loader: () => { + return json({ + views: 0, + visits: 0, + visitors: 0, + }); + }, + }, { path: "/resources/paths", loader: () => { @@ -303,6 +271,22 @@ describe("Dashboard route", () => { Component: Dashboard, loader, children: [ + { + path: "/resources/stats", + loader: () => { + return json({ + views: 2133, + visits: 80, + visitors: 33, + }); + }, + }, + { + path: "/resources/timeseries", + loader: () => { + return json({}); + }, + }, { path: "/resources/paths", loader: () => { diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index d55b3b0..b566182 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -1,4 +1,3 @@ -import { Card, CardContent } from "~/components/ui/card"; import { Select, SelectContent, @@ -21,11 +20,15 @@ import { BrowserCard } from "./resources.browser"; import { CountryCard } from "./resources.country"; import { DeviceCard } from "./resources.device"; -import TimeSeriesChart from "~/components/TimeSeriesChart"; -import dayjs from "dayjs"; -import { getFiltersFromSearchParams } from "~/lib/utils"; +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"; export const meta: MetaFunction = () => { return [ @@ -70,12 +73,10 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => { } const siteId = url.searchParams.get("site") || ""; - const actualSiteId = siteId == "@unknown" ? "" : siteId; + const actualSiteId = siteId === "@unknown" ? "" : siteId; const filters = getFiltersFromSearchParams(url.searchParams); - const tz = context.cloudflare.cf.timezone as string; - // initiate requests to AE in parallel // sites by hits: This is to populate the "sites" dropdown. We query the full retention @@ -83,79 +84,21 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => { // will show up in the dropdown. const sitesByHits = analyticsEngine.getSitesOrderedByHits( `${MAX_RETENTION_DAYS}d`, - tz, ); - const counts = analyticsEngine.getCounts( - actualSiteId, - interval, - tz, - filters, - ); - let intervalType: "DAY" | "HOUR" = "DAY"; - switch (interval) { - case "today": - case "yesterday": - case "1d": - intervalType = "HOUR"; - break; - case "7d": - case "30d": - case "90d": - intervalType = "DAY"; - break; - } - // get start date in the past by subtracting interval * type - - let localDateTime = dayjs().utc(); - let localEndDateTime: dayjs.Dayjs | undefined; - if (interval === "today") { - localDateTime = localDateTime.tz(tz).startOf("day"); - } else if (interval === "yesterday") { - localDateTime = localDateTime.tz(tz).startOf("day").subtract(1, "day"); - localEndDateTime = localDateTime.endOf("day").add(2, "ms"); - } else { - const daysAgo = Number(interval.split("d")[0]); - if (intervalType === "DAY") { - localDateTime = localDateTime - .subtract(daysAgo, "day") - .tz(tz) - .startOf("day"); - } else if (intervalType === "HOUR") { - localDateTime = localDateTime - .subtract(daysAgo, "day") - .startOf("hour"); - } - } - - if (!localEndDateTime) localEndDateTime = dayjs().utc().tz(tz); - - const viewsGroupedByInterval = analyticsEngine.getViewsGroupedByInterval( - actualSiteId, - intervalType, - localDateTime.toDate(), - localEndDateTime.toDate(), - tz, - filters, - ); + const intervalType = getIntervalType(interval); // await all requests to AE then return the results let out; try { out = { - siteId: siteId, + siteId: actualSiteId, sites: (await sitesByHits).map( ([site, _]: [string, number]) => site, ), - views: (await counts).views, - visits: (await counts).visits, - visitors: (await counts).visitors, - // countByReferrer: await countByReferrer, - viewsGroupedByInterval: await viewsGroupedByInterval, intervalType, interval, - tz, filters, }; } catch (err) { @@ -191,16 +134,6 @@ export default function Dashboard() { }); } - const chartData: { date: string; views: number }[] = []; - data.viewsGroupedByInterval.forEach((row) => { - chartData.push({ - date: row[0], - views: row[1], - }); - }); - - const countFormatter = Intl.NumberFormat("en", { notation: "compact" }); - const handleFilterChange = (filters: SearchFilters) => { setSearchParams((prev) => { for (const key in filters) { @@ -222,6 +155,8 @@ export default function Dashboard() { }); }; + const userTimezone = getUserTimezone(); + return (
@@ -278,46 +213,20 @@ export default function Dashboard() {
- -
-
-
-
Views
-
- {countFormatter.format(data.views)} -
-
-
-
- Visits -
-
- {countFormatter.format(data.visits)} -
-
-
-
- Visitors -
-
- {countFormatter.format(data.visitors)} -
-
-
-
-
+
- - -
- -
-
-
+
@@ -339,6 +250,7 @@ export default function Dashboard() { interval={data.interval} filters={data.filters} onFilterChange={handleFilterChange} + timezone={userTimezone} />
diff --git a/app/routes/resources.browser.tsx b/app/routes/resources.browser.tsx index fb131ad..b14be7b 100644 --- a/app/routes/resources.browser.tsx +++ b/app/routes/resources.browser.tsx @@ -11,10 +11,9 @@ export async function loader({ context, request }: LoaderFunctionArgs) { const { analyticsEngine } = context; const { interval, site, page = 1 } = paramsFromUrl(request.url); - const tz = context.cloudflare.cf.timezone as string; - const url = new URL(request.url); - const filters = getFiltersFromSearchParams(new URL(url).searchParams); + const tz = url.searchParams.get("timezone") || "UTC"; + const filters = getFiltersFromSearchParams(url.searchParams); return json({ countsByProperty: await analyticsEngine.getCountByBrowser( @@ -33,11 +32,13 @@ export const BrowserCard = ({ interval, filters, onFilterChange, + timezone, }: { siteId: string; interval: string; filters: SearchFilters; onFilterChange: (filters: SearchFilters) => void; + timezone: string; }) => { return ( onFilterChange({ ...filters, browserName }) } + timezone={timezone} /> ); }; diff --git a/app/routes/resources.country.tsx b/app/routes/resources.country.tsx index 5a1cf34..9430636 100644 --- a/app/routes/resources.country.tsx +++ b/app/routes/resources.country.tsx @@ -1,8 +1,6 @@ import { useFetcher } from "@remix-run/react"; - import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; import { json } from "@remix-run/cloudflare"; - import { getFiltersFromSearchParams, paramsFromUrl } from "~/lib/utils"; import PaginatedTableCard from "~/components/PaginatedTableCard"; import { SearchFilters } from "~/lib/types"; @@ -28,14 +26,12 @@ function convertCountryCodesToNames( export async function loader({ context, request }: LoaderFunctionArgs) { const { analyticsEngine } = context; - const { interval, site, page = 1 } = paramsFromUrl(request.url); - const tz = context.cloudflare.cf.timezone as string; - const url = new URL(request.url); - const filters = getFiltersFromSearchParams(new URL(url).searchParams); + const tz = url.searchParams.get("timezone") || "UTC"; + const filters = getFiltersFromSearchParams(url.searchParams); - const countByCountry = await analyticsEngine.getCountByCountry( + const countsByCountry = await analyticsEngine.getCountByCountry( site, interval, tz, @@ -46,11 +42,11 @@ export async function loader({ context, request }: LoaderFunctionArgs) { // normalize country codes to country names // NOTE: this must be done ONLY on server otherwise hydration mismatches // can occur because Intl.DisplayNames produces different results - // in different browsers (see ) - const normalizedCountByCountry = convertCountryCodesToNames(countByCountry); + // in different browsers (see https://github.com/benvinegar/counterscale/issues/72) + const countsByProperty = convertCountryCodesToNames(countsByCountry); return json({ - countsByProperty: normalizedCountByCountry, + countsByProperty, page: Number(page), }); } @@ -60,11 +56,13 @@ export const CountryCard = ({ interval, filters, onFilterChange, + timezone, }: { siteId: string; interval: string; filters: SearchFilters; onFilterChange: (filters: SearchFilters) => void; + timezone: string; }) => { return ( onFilterChange({ ...filters, country })} + timezone={timezone} /> ); }; diff --git a/app/routes/resources.device.tsx b/app/routes/resources.device.tsx index a2b8c04..21267b2 100644 --- a/app/routes/resources.device.tsx +++ b/app/routes/resources.device.tsx @@ -11,10 +11,10 @@ export async function loader({ context, request }: LoaderFunctionArgs) { const { analyticsEngine } = context; const { interval, site, page = 1 } = paramsFromUrl(request.url); - const tz = context.cloudflare.cf.timezone as string; const url = new URL(request.url); - const filters = getFiltersFromSearchParams(new URL(url).searchParams); + const tz = url.searchParams.get("timezone") || "UTC"; + const filters = getFiltersFromSearchParams(url.searchParams); return json({ countsByProperty: await analyticsEngine.getCountByDevice( @@ -33,11 +33,13 @@ export const DeviceCard = ({ interval, filters, onFilterChange, + timezone, }: { siteId: string; interval: string; filters: SearchFilters; onFilterChange: (filters: SearchFilters) => void; + timezone: string; }) => { return ( onFilterChange({ ...filters, deviceModel }) } + timezone={timezone} /> ); }; diff --git a/app/routes/resources.paths.tsx b/app/routes/resources.paths.tsx index db19204..d6d1bac 100644 --- a/app/routes/resources.paths.tsx +++ b/app/routes/resources.paths.tsx @@ -14,10 +14,10 @@ export async function loader({ context, request }: LoaderFunctionArgs) { const { analyticsEngine } = context; const { interval, site, page = 1 } = paramsFromUrl(request.url); - const tz = context.cloudflare.cf.timezone as string; const url = new URL(request.url); - const filters = getFiltersFromSearchParams(new URL(url).searchParams); + const tz = url.searchParams.get("timezone") || "UTC"; + const filters = getFiltersFromSearchParams(url.searchParams); return json({ countsByProperty: await analyticsEngine.getCountByPath( @@ -36,11 +36,13 @@ export const PathsCard = ({ interval, filters, onFilterChange, + timezone, }: { siteId: string; interval: string; filters: SearchFilters; onFilterChange: (filters: SearchFilters) => void; + timezone: string; }) => { return ( onFilterChange({ ...filters, path })} + timezone={timezone} /> ); }; diff --git a/app/routes/resources.referrer.tsx b/app/routes/resources.referrer.tsx index 17cfd65..950f97f 100644 --- a/app/routes/resources.referrer.tsx +++ b/app/routes/resources.referrer.tsx @@ -12,10 +12,10 @@ export async function loader({ context, request }: LoaderFunctionArgs) { const { analyticsEngine } = context; const { interval, site, page = 1 } = paramsFromUrl(request.url); - const tz = context.cloudflare.cf.timezone as string; const url = new URL(request.url); - const filters = getFiltersFromSearchParams(new URL(url).searchParams); + const tz = url.searchParams.get("timezone") || "UTC"; + const filters = getFiltersFromSearchParams(url.searchParams); return json({ countsByProperty: await analyticsEngine.getCountByReferrer( @@ -34,11 +34,13 @@ export const ReferrerCard = ({ interval, filters, onFilterChange, + timezone, }: { siteId: string; interval: string; filters: SearchFilters; onFilterChange: (filters: SearchFilters) => void; + timezone: string; }) => { return ( onFilterChange({ ...filters, referrer })} + timezone={timezone} /> ); }; diff --git a/app/routes/resources.stats.tsx b/app/routes/resources.stats.tsx new file mode 100644 index 0000000..4afbd4c --- /dev/null +++ b/app/routes/resources.stats.tsx @@ -0,0 +1,89 @@ +import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; +import { json } from "@remix-run/cloudflare"; +import { getFiltersFromSearchParams, paramsFromUrl } from "~/lib/utils"; +import { useEffect } from "react"; +import { useFetcher } from "@remix-run/react"; +import { Card } from "~/components/ui/card"; +import { SearchFilters } from "~/lib/types"; + +export async function loader({ context, request }: LoaderFunctionArgs) { + 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 counts = await analyticsEngine.getCounts(site, interval, tz, filters); + + return json({ + views: counts.views, + visits: counts.visits, + visitors: counts.visitors, + }); +} + +export const StatsCard = ({ + siteId, + interval, + filters, + timezone, +}: { + siteId: string; + interval: string; + filters: SearchFilters; + timezone: string; +}) => { + const dataFetcher = useFetcher(); + const { views, visits, visitors } = dataFetcher.data || {}; + const countFormatter = Intl.NumberFormat("en", { notation: "compact" }); + + const loadData = () => { + const filterString = filters + ? Object.entries(filters) + .map(([key, value]) => `&${key}=${value}`) + .join("") + : ""; + + const url = `/resources/stats?site=${siteId}&interval=${interval}&timezone=${timezone}${filterString}`; + dataFetcher.load(url); + }; + + useEffect(() => { + if (dataFetcher.state === "idle") { + loadData(); + } + }, []); + + useEffect(() => { + if (dataFetcher.state === "idle") { + loadData(); + } + }, [siteId, interval, filters]); + + return ( + +
+
+
+
Views
+
+ {views ? countFormatter.format(views) : "-"} +
+
+
+
Visits
+
+ {visits ? countFormatter.format(visits) : "-"} +
+
+
+
Visitors
+
+ {visitors ? countFormatter.format(visitors) : "-"} +
+
+
+
+
+ ); +}; diff --git a/app/routes/resources.timeseries.tsx b/app/routes/resources.timeseries.tsx new file mode 100644 index 0000000..ecc12c5 --- /dev/null +++ b/app/routes/resources.timeseries.tsx @@ -0,0 +1,101 @@ +import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; +import { json } from "@remix-run/cloudflare"; +import { + getFiltersFromSearchParams, + paramsFromUrl, + getIntervalType, + getDateTimeRange, +} from "~/lib/utils"; +import { useEffect } from "react"; +import { useFetcher } from "@remix-run/react"; +import { Card, CardContent } from "~/components/ui/card"; +import TimeSeriesChart from "~/components/TimeSeriesChart"; +import { SearchFilters } from "~/lib/types"; + +export async function loader({ context, request }: LoaderFunctionArgs) { + 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 viewsGroupedByInterval = + await analyticsEngine.getViewsGroupedByInterval( + site, + intervalType, + startDate, + endDate, + tz, + filters, + ); + + const chartData: { date: string; views: number }[] = []; + viewsGroupedByInterval.forEach((row) => { + chartData.push({ + date: row[0], + views: row[1], + }); + }); + + return json({ + chartData: chartData, + intervalType: intervalType, + }); +} + +export const TimeSeriesCard = ({ + siteId, + interval, + filters, + timezone, +}: { + siteId: string; + interval: string; + filters: SearchFilters; + timezone: string; +}) => { + const dataFetcher = useFetcher(); + const { chartData, intervalType } = dataFetcher.data || {}; + + const loadData = () => { + const filterString = filters + ? Object.entries(filters) + .map(([key, value]) => `&${key}=${value}`) + .join("") + : ""; + + const url = `/resources/timeseries?site=${siteId}&interval=${interval}&timezone=${timezone}${filterString}`; + dataFetcher.load(url); + }; + + useEffect(() => { + if (dataFetcher.state === "idle") { + loadData(); + } + }, []); + + useEffect(() => { + if (dataFetcher.state === "idle") { + loadData(); + } + }, [siteId, interval, filters]); + + return ( + + +
+ {chartData && ( + + )} +
+
+
+ ); +};