Skip to content

Commit

Permalink
Add visitors + bounce rate to timeseries chart (#124)
Browse files Browse the repository at this point in the history
* First commit of multiple data points on timeseries chart

* Fix tests

* Add bounce rate to chart
  • Loading branch information
benvinegar authored Dec 12, 2024
1 parent 1bd9b16 commit 964a91c
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 69 deletions.
84 changes: 45 additions & 39 deletions app/analytics/__tests__/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,14 @@ describe("AnalyticsEngineAPI", () => {
// 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 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],
["2024-01-11 05:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-12 05:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-13 05:00:00", { views: 3, visitors: 0, bounces: 0 }],
["2024-01-14 05:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-15 05:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-16 05:00:00", { views: 2, visitors: 0, bounces: 0 }],
["2024-01-17 05:00:00", { views: 1, visitors: 0, bounces: 0 }],
["2024-01-18 05:00:00", { views: 0, visitors: 0, bounces: 0 }],
]);

const result2 = await api.getViewsGroupedByInterval(
Expand All @@ -111,12 +111,12 @@ describe("AnalyticsEngineAPI", () => {
"America/New_York",
);
expect(result2).toEqual([
["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],
["2024-01-13 05:00:00", { views: 3, visitors: 0, bounces: 0 }],
["2024-01-14 05:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-15 05:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-16 05:00:00", { views: 2, visitors: 0, bounces: 0 }],
["2024-01-17 05:00:00", { views: 1, visitors: 0, bounces: 0 }],
["2024-01-18 05:00:00", { views: 0, visitors: 0, bounces: 0 }],
]);
});
});
Expand All @@ -129,15 +129,21 @@ describe("AnalyticsEngineAPI", () => {
data: [
{
count: 3,
isVisitor: 0,
isBounce: 0,
// note: intentionally sparse data (data for some timestamps missing)
bucket: "2024-01-17 11:00:00",
},
{
count: 2,
isVisitor: 0,
isBounce: 0,
bucket: "2024-01-17 14:00:00",
},
{
count: 1,
isVisitor: 0,
isBounce: 0,
bucket: "2024-01-17 16:00:00",
},
],
Expand All @@ -158,31 +164,31 @@ describe("AnalyticsEngineAPI", () => {
// 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 10:00:00", 0],
["2024-01-17 11:00:00", 3],
["2024-01-17 12:00:00", 0],
["2024-01-17 13:00:00", 0],
["2024-01-17 14:00:00", 2],
["2024-01-17 15:00:00", 0],
["2024-01-17 16:00:00", 1],
["2024-01-17 17:00:00", 0],
["2024-01-17 18:00:00", 0],
["2024-01-17 19:00:00", 0],
["2024-01-17 20:00:00", 0],
["2024-01-17 21:00:00", 0],
["2024-01-17 22:00:00", 0],
["2024-01-17 23:00:00", 0],
["2024-01-18 00:00:00", 0],
["2024-01-18 01:00:00", 0],
["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 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],
["2024-01-17 10:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 11:00:00", { views: 3, visitors: 0, bounces: 0 }],
["2024-01-17 12:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 13:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 14:00:00", { views: 2, visitors: 0, bounces: 0 }],
["2024-01-17 15:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 16:00:00", { views: 1, visitors: 0, bounces: 0 }],
["2024-01-17 17:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 18:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 19:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 20:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 21:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 22:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-17 23:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 00:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 01:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 02:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 03:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 04:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 05:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 06:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 07:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 08:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 09:00:00", { views: 0, visitors: 0, bounces: 0 }],
["2024-01-18 10:00:00", { views: 0, visitors: 0, bounces: 0 }],
]);
});

Expand Down
28 changes: 22 additions & 6 deletions app/analytics/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,20 @@ function generateEmptyRowsOverInterval(
startDateTime: Date,
endDateTime: Date,
tz?: string,
): { [key: string]: number } {
): { [key: string]: AnalyticsCountResult } {
if (!tz) {
tz = "Etc/UTC";
}

const initialRows: { [key: string]: number } = {};
const initialRows: { [key: string]: AnalyticsCountResult } = {};

while (startDateTime.getTime() < endDateTime.getTime()) {
const key = dayjs(startDateTime).utc().format("YYYY-MM-DD HH:mm:ss");
initialRows[key] = 0;
initialRows[key] = {
views: 0,
visitors: 0,
bounces: 0,
};

if (intervalType === "DAY") {
// WARNING: Daylight savings hack. Cloudflare Workers uses a different Date
Expand Down Expand Up @@ -226,6 +230,8 @@ export class AnalyticsEngineAPI {
/* 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,
${ColumnMappings.newVisitor} as isVisitor,
${ColumnMappings.bounce} as isBounce,
/* output as UTC */
toDateTime(_bucket, 'Etc/UTC') as bucket
Expand All @@ -234,16 +240,18 @@ export class AnalyticsEngineAPI {
AND timestamp < toDateTime('${localEndTime.format("YYYY-MM-DD HH:mm:ss")}')
AND ${ColumnMappings.siteId} = '${siteId}'
${filterStr}
GROUP BY _bucket
GROUP BY _bucket, isVisitor, isBounce
ORDER BY _bucket ASC`;

type SelectionSet = {
count: number;
bucket: string;
isVisitor: number;
isBounce: number;
};

const queryResult = this.query(query);
const returnPromise = new Promise<[string, number][]>(
const returnPromise = new Promise<[string, AnalyticsCountResult][]>(
(resolve, reject) =>
(async () => {
const response = await queryResult;
Expand All @@ -263,7 +271,15 @@ export class AnalyticsEngineAPI {
const key = dayjs(utcDateTime).format(
"YYYY-MM-DD HH:mm:ss",
);
accum[key] = Number(row["count"]);
if (!Object.hasOwn(accum, key)) {
accum[key] = {
views: 0,
visitors: 0,
bounces: 0,
};
}
accumulateCountsFromRowResult(accum[key], row);

return accum;
},
initialRows,
Expand Down
62 changes: 45 additions & 17 deletions app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
import PropTypes, { InferProps } from "prop-types";

import {
AreaChart,
Line,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ComposedChart,
} from "recharts";

interface TimeSeriesChartProps {
data: Array<{
date: string;
views: number;
visitors: number;
bounceRate: number;
}>;
intervalType?: string;
timezone?: string;
}

export default function TimeSeriesChart({
data,
intervalType,
timezone,
}: InferProps<typeof TimeSeriesChart.propTypes>) {
}: TimeSeriesChartProps) {
// chart doesn't really work no data points, so just bail out
if (data.length === 0) {
return null;
}

const MAX_Y_VALUE_MULTIPLIER = 1.2;

// get the max integer value of data views
const maxViews = Math.max(...data.map((item) => item.views));

Expand Down Expand Up @@ -65,7 +77,7 @@ export default function TimeSeriesChart({

return (
<ResponsiveContainer width="100%" height="100%" minWidth={100}>
<AreaChart
<ComposedChart
width={500}
height={400}
data={data}
Expand All @@ -80,25 +92,41 @@ export default function TimeSeriesChart({
<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]} />
<YAxis
yAxisId="count"
dataKey="views"
domain={[0, Math.floor(maxViews * MAX_Y_VALUE_MULTIPLIER)]} // set max Y value a little higher than what was recorded
/>
<YAxis
yAxisId="bounceRate"
dataKey="bounceRate"
domain={[0, Math.floor(100 * MAX_Y_VALUE_MULTIPLIER)]}
hide={true}
/>

<Tooltip labelFormatter={tooltipDateFormatter} />
<Area
yAxisId="count"
dataKey="views"
stroke="#F46A3D"
strokeWidth="2"
fill="#F99C35"
/>
</AreaChart>
<Area
yAxisId="count"
dataKey="visitors"
stroke="#F46A3D"
strokeWidth="2"
fill="#f96d3e"
/>
<Line
yAxisId="bounceRate"
dataKey="bounceRate"
stroke="#56726C"
strokeWidth="2"
dot={false}
/>
</ComposedChart>
</ResponsiveContainer>
);
}

TimeSeriesChart.propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
views: PropTypes.number.isRequired,
}).isRequired,
).isRequired,
intervalType: PropTypes.string,
timezone: PropTypes.string,
};
23 changes: 18 additions & 5 deletions app/routes/__tests__/resources.timeseries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ describe("resources.timeseries loader", () => {
context.analyticsEngine,
"getViewsGroupedByInterval",
).mockResolvedValue([
["2024-01-15T00:00:00Z", 100],
["2024-01-16T00:00:00Z", 200],
["2024-01-15T00:00:00Z", { views: 100, visitors: 0, bounces: 0 }],
["2024-01-16T00:00:00Z", { views: 200, visitors: 0, bounces: 0 }],
]);

// mock out responsive container to just return a standard div, otherwise
Expand Down Expand Up @@ -60,8 +60,18 @@ describe("resources.timeseries loader", () => {

const data = await result.json();
expect(data.chartData).toEqual([
{ date: "2024-01-15T00:00:00Z", views: 100 },
{ date: "2024-01-16T00:00:00Z", views: 200 },
{
date: "2024-01-15T00:00:00Z",
views: 100,
visitors: 0,
bounceRate: 0,
},
{
date: "2024-01-16T00:00:00Z",
views: 200,
visitors: 0,
bounceRate: 0,
},
]);
expect(data.intervalType).toBe("DAY");

Expand Down Expand Up @@ -142,7 +152,10 @@ describe("TimeSeriesCard", () => {
// Wait for the chart to be rendered
await waitFor(() => screen.getAllByText("Mon, Jan 15").length > 0);
// assert data appears in chart
expect(screen.getAllByText("100")).toHaveLength(2);

// 120 because 100 is max data point, then gets multiplied by 1.2 (MAX_Y_VALUE_MULTIPLIER)
// and this becomes a label in the chart
expect(screen.getAllByText("120")).toHaveLength(2);
});

test("refetches when props change", () => {
Expand Down
1 change: 1 addition & 0 deletions app/routes/resources.stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const StatsCard = ({
{visitors ? countFormatter.format(visitors) : "-"}
</div>
</div>

<div>
<div className="text-md sm:text-lg">Views</div>
<div className="text-4xl">
Expand Down
15 changes: 13 additions & 2 deletions app/routes/resources.timeseries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,22 @@ export async function loader({ context, request }: LoaderFunctionArgs) {
filters,
);

const chartData: { date: string; views: number }[] = [];
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: row[1],
views,
visitors,
bounceRate: Math.floor(
(visitors > 0 ? bounces / visitors : 0) * 100,
),
});
});

Expand Down

0 comments on commit 964a91c

Please sign in to comment.