Skip to content

Commit

Permalink
Properly reflect results in local timezone (#16)
Browse files Browse the repository at this point in the history
* Fix hourly timezone data

* Results by day now use local tz to determine start of day

* Fix lint errors

* Fix timestamp assertions to reflect fixed timezone code
  • Loading branch information
benvinegar authored Jan 20, 2024
1 parent 5fbb7e6 commit 9daf0c0
Show file tree
Hide file tree
Showing 6 changed files with 98 additions and 48 deletions.
57 changes: 33 additions & 24 deletions app/analytics/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,56 +46,62 @@ describe("AnalyticsEngineAPI", () => {

describe("getViewsGroupedByInterval", () => {
test("should return an array of [timestamp, count] tuples grouped by day", async () => {
expect(process.env.TZ).toBe("EST");

fetch.mockResolvedValue(new Promise(resolve => {
resolve(createFetchResponse({
data: [
{
count: 3,
// note: intentionally sparse data (data for some timestamps missing)
bucket: "2024-01-13 00:00:00",
bucket: "2024-01-13 05:00:00",
},
{
count: 2,
bucket: "2024-01-16 00:00:00"
bucket: "2024-01-16 05:00:00"
},
{
count: 1,
bucket: "2024-01-17 00:00:00"
bucket: "2024-01-17 05:00:00"
}
]
}))
}));

vi.setSystemTime(new Date("2024-01-18T05:33:02").getTime());
vi.setSystemTime(new Date("2024-01-18T09:33:02").getTime());

const result1 = await api.getViewsGroupedByInterval("example.com", "DAY", 7);

// results should all be at 05:00:00 because local timezone is UTC-5 --
// this set of results represents "start of day" in local tz, which is 5 AM UTC
expect(result1).toEqual([
["2024-01-11 00:00:00", 0],
["2024-01-12 00:00:00", 0],
["2024-01-13 00:00:00", 3],
["2024-01-14 00:00:00", 0],
["2024-01-15 00:00:00", 0],
["2024-01-16 00:00:00", 2],
["2024-01-17 00:00:00", 1],
["2024-01-18 00:00:00", 0],
["2024-01-11 05:00:00", 0],
["2024-01-12 05:00:00", 0],
["2024-01-13 05:00:00", 3],
["2024-01-14 05:00:00", 0],
["2024-01-15 05:00:00", 0],
["2024-01-16 05:00:00", 2],
["2024-01-17 05:00:00", 1],
["2024-01-18 05:00:00", 0],
]);

expect(await api.getViewsGroupedByInterval("example.com", "DAY", 5))

const result2 = await api.getViewsGroupedByInterval("example.com", "DAY", 5);
expect(result2).toEqual([
["2024-01-13 00:00:00", 3],
["2024-01-14 00:00:00", 0],
["2024-01-15 00:00:00", 0],
["2024-01-16 00:00:00", 2],
["2024-01-17 00:00:00", 1],
["2024-01-18 00:00:00", 0],
["2024-01-13 05:00:00", 3],
["2024-01-14 05:00:00", 0],
["2024-01-15 05:00:00", 0],
["2024-01-16 05:00:00", 2],
["2024-01-17 05:00:00", 1],
["2024-01-18 05:00:00", 0],
]);
});
});

test("should return an array of [timestamp, count] tuples grouped by hour", async () => {
expect(process.env.TZ).toBe("EST");

fetch.mockResolvedValue(new Promise(resolve => {
resolve(createFetchResponse({
data: [
Expand All @@ -120,12 +126,10 @@ describe("AnalyticsEngineAPI", () => {

const result1 = await api.getViewsGroupedByInterval("example.com", "HOUR", 1);

// reminder results are expressed as UTC
// so if we want the last 24 hours from 05:00:00 in local time (EST), the actual
// time range in UTC starts and ends at 10:00:00 (+5 hours)
expect(result1).toEqual([
['2024-01-17 05:00:00', 0],
['2024-01-17 06:00:00', 0],
['2024-01-17 07:00:00', 0],
['2024-01-17 08:00:00', 0],
['2024-01-17 09:00:00', 0],
['2024-01-17 10:00:00', 0],
['2024-01-17 11:00:00', 3],
['2024-01-17 12:00:00', 0],
Expand All @@ -145,7 +149,12 @@ describe("AnalyticsEngineAPI", () => {
['2024-01-18 02:00:00', 0],
['2024-01-18 03:00:00', 0],
['2024-01-18 04:00:00', 0],
['2024-01-18 05:00:00', 0]
['2024-01-18 05:00:00', 0],
['2024-01-18 06:00:00', 0],
['2024-01-18 07:00:00', 0],
['2024-01-18 08:00:00', 0],
['2024-01-18 09:00:00', 0],
['2024-01-18 10:00:00', 0]
]);
});

Expand Down
54 changes: 38 additions & 16 deletions app/analytics/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ interface AnalyticsCountResult {
visitors: number
}

/**
* Convert a Date object to YY-MM-DD HH:MM:SS
*/
function formatDateString(d: Date) {
function pad(n: number) { return n < 10 ? "0" + n : n }
const dash = "-";
const colon = ":";
return d.getFullYear() + dash +
pad(d.getMonth() + 1) + dash +
pad(d.getDate()) + " " +
pad(d.getHours()) + colon +
pad(d.getMinutes()) + colon +
pad(d.getSeconds())
}

/**
* returns an object with keys of the form "YYYY-MM-DD HH:00:00" and values of 0
Expand All @@ -44,7 +58,7 @@ interface AnalyticsCountResult {
* }
*
* */
function generateEmptyRowsOverInterval(intervalType: string, daysAgo: number): any {
function generateEmptyRowsOverInterval(intervalType: string, daysAgo: number): [Date, any] {
const startDateTime = new Date();
let intervalMs = 0;

Expand All @@ -70,17 +84,17 @@ function generateEmptyRowsOverInterval(intervalType: string, daysAgo: number): a
const initialRows: any = {};

for (let i = startDateTime.getTime(); i < Date.now(); i += intervalMs) {
// get date as utc
const rowDate = new Date(i);
const isoStringInLocalTZ = new Date(rowDate.getTime() - new Date().getTimezoneOffset() * 60 * 1000).toISOString()
const key =
isoStringInLocalTZ.split("T")[0] +
" " +
isoStringInLocalTZ.split("T")[1].split('.')[0];
// convert to UTC
const utcDateTime = new Date(rowDate.getTime() + rowDate.getTimezoneOffset() * 60_000);

const key = formatDateString(utcDateTime);
initialRows[key] = 0;
}


return initialRows;
return [startDateTime, initialRows];
}

/**
Expand Down Expand Up @@ -124,9 +138,7 @@ export class AnalyticsEngineAPI {
});
}

async getViewsGroupedByInterval(siteId: string, intervalType: string, sinceDays: number): Promise<any> {
// defaults to 1 day if not specified
const interval = sinceDays || 1;
async getViewsGroupedByInterval(siteId: string, intervalType: string, sinceDays: number, tz?: string): Promise<any> {
const siteIdColumn = ColumnMappings['siteId'];

let intervalCount = 1;
Expand All @@ -140,20 +152,27 @@ export class AnalyticsEngineAPI {
}

// note interval count hard-coded to hours at the moment
const initialRows = generateEmptyRowsOverInterval(intervalType, sinceDays);
const [startDateTime, initialRows] = generateEmptyRowsOverInterval(intervalType, sinceDays);

// NOTE: when using toStartOfInterval, cannot group by other columns
// like double1 (isVisitor) or double2 (isSession/isVisit). This
// is just a limitation of Cloudflare Analytics Engine.
// -- but you can filter on them (using WHERE)
const query = `
SELECT SUM(_sample_interval) as count,
toStartOfInterval(timestamp, INTERVAL '${intervalCount}' ${intervalType}) as bucket
/* interval start needs local timezone, e.g. 00:00 in America/New York means start of day in NYC */
toStartOfInterval(timestamp, INTERVAL '${intervalCount}' ${intervalType}, '${tz}') as _bucket,
/* format output date as UTC (otherwise will be users local TZ) */
toDateTime(_bucket, 'Etc/UTC') as bucket
FROM metricsDataset
WHERE timestamp > NOW() - INTERVAL '${interval}' DAY
WHERE timestamp > toDateTime('${formatDateString(startDateTime)}')
AND ${siteIdColumn} = '${siteId}'
GROUP BY bucket
ORDER BY bucket ASC`;
GROUP BY _bucket
ORDER BY _bucket ASC`;

const returnPromise = new Promise<any>((resolve, reject) => (async () => {
const response = await this.query(query);

Expand All @@ -166,7 +185,10 @@ export class AnalyticsEngineAPI {
// note this query will return sparse data (i.e. only rows where count > 0)
// merge returnedRows with initial rows to fill in any gaps
const rowsByDateTime = responseData.data.reduce((accum, row) => {
accum[row['bucket']] = row['count'];

const utcDateTime = new Date(row['bucket']);
const key = formatDateString(utcDateTime);
accum[key] = row['count'];
return accum;
}, initialRows);

Expand Down
22 changes: 18 additions & 4 deletions app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ export default function TimeSeriesChart({ data, intervalType }: InferProps<typeo
// get the max integer value of data views
const maxViews = Math.max(...data.map((item: any) => item.views));

function dateFormatter(date: string): string {
function xAxisDateFormatter(date: string): string {

const dateObj = new Date(date);

// convert from utc to local time
dateObj.setMinutes(dateObj.getMinutes() - dateObj.getTimezoneOffset());

switch (intervalType) {
case 'DAY':
return dateObj.toLocaleDateString('en-us', { weekday: "short", month: "short", day: "numeric" });
Expand All @@ -24,6 +28,16 @@ export default function TimeSeriesChart({ data, intervalType }: InferProps<typeo
}
}

function tooltipDateFormatter(date: string): string {

const dateObj = new Date(date);

// 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 (
<ResponsiveContainer width="100%" height="100%">
<AreaChart
Expand All @@ -38,14 +52,14 @@ export default function TimeSeriesChart({ data, intervalType }: InferProps<typeo
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tickFormatter={dateFormatter} />
<XAxis dataKey="date" tickFormatter={xAxisDateFormatter} />

{/* manually setting maxViews vs using recharts "dataMax" key cause it doesnt seem to work */}
<YAxis dataKey="views" domain={[0, maxViews]} />
<Tooltip />
<Tooltip labelFormatter={tooltipDateFormatter} />
<Area dataKey="views" stroke="#F46A3D" strokeWidth="2" fill="#F99C35" />
</AreaChart>
</ResponsiveContainer>
</ResponsiveContainer >
);

}
Expand Down
4 changes: 3 additions & 1 deletion app/routes/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ export const loader = async ({ context, request }: LoaderFunctionArgs) => {
break;
}

const viewsGroupedByInterval = analyticsEngine.getViewsGroupedByInterval(actualSiteId, intervalType, interval);
const tz = context.requestTimezone as string;

const viewsGroupedByInterval = analyticsEngine.getViewsGroupedByInterval(actualSiteId, intervalType, interval, tz);

return json({
siteId: siteId || '@unknown',
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"dev": "remix dev --manual -c \"npm start\"",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "wrangler dev ./build/index.js",
"test": "vitest run",
"test-ci": "vitest run --coverage",
"test": "TZ=EST vitest run",
"test-ci": "TZ=EST vitest run --coverage",
"typecheck": "tsc",
"prepare": "husky install"
},
Expand Down Expand Up @@ -67,4 +67,4 @@
"engines": {
"node": ">=20.0.0"
}
}
}
3 changes: 3 additions & 0 deletions server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { RequestInit } from "@cloudflare/workers-types";

import { getAssetFromKV } from "@cloudflare/kv-asset-handler";
import type { AppLoadContext } from "@remix-run/cloudflare";
import { createRequestHandler, logDevReady } from "@remix-run/cloudflare";
Expand Down Expand Up @@ -53,6 +55,7 @@ export default {
try {
const loadContext: AppLoadContext = {
env,
requestTimezone: (request as RequestInit).cf?.timezone as string
};
return await handleRemixRequest(request, loadContext);
} catch (error) {
Expand Down

0 comments on commit 9daf0c0

Please sign in to comment.