Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(stats): populate empty chart axis #289

Merged
merged 15 commits into from
Jun 29, 2023
43 changes: 20 additions & 23 deletions routes/stats.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import type { Handlers, PageProps } from "$fresh/server.ts";
import { DAY } from "std/datetime/constants.ts";
import { SITE_WIDTH_STYLES } from "@/utils/constants.ts";
import Head from "@/components/Head.tsx";
import type { State } from "./_middleware.ts";
import { getDatesSince, getManyMetrics } from "@/utils/db.ts";
import { Chart } from "fresh_charts/mod.ts";
import { ChartColors } from "fresh_charts/utils.ts";
import { DAY } from "std/datetime/constants.ts";

interface StatsPageData extends State {
dates: Date[];
Expand All @@ -18,7 +18,7 @@ interface StatsPageData extends State {

export const handler: Handlers<StatsPageData, State> = {
async GET(_req, ctx) {
const msAgo = 10 * DAY;
const msAgo = 30 * DAY;
const dates = getDatesSince(msAgo).map((date) => new Date(date));

const [
Expand Down Expand Up @@ -60,7 +60,11 @@ function LineChart(
legend: { display: false },
},
scales: {
y: { grid: { display: false }, beginAtZero: true },
y: {
beginAtZero: true,
grid: { display: false },
ticks: { stepSize: 1 },
},
x: { grid: { display: false } },
},
}}
Expand All @@ -82,6 +86,12 @@ function LineChart(
}

export default function StatsPage(props: PageProps<StatsPageData>) {
const charts: [string, bigint[]][] = [
["Visits", props.data.visitsCounts],
["Users", props.data.usersCounts],
["Items", props.data.itemsCounts],
["Votes", props.data.votesCounts],
];
const x = props.data.dates.map((date) =>
new Date(date).toLocaleDateString("en-us", {
year: "numeric",
Expand All @@ -105,26 +115,13 @@ export default function StatsPage(props: PageProps<StatsPageData>) {
</Head>
<div class={`${SITE_WIDTH_STYLES} flex-1 px-4`}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<LineChart
title="Visits"
x={x}
y={props.data.visitsCounts}
/>
<LineChart
title="Users"
x={x}
y={props.data.usersCounts}
/>
<LineChart
title="Items"
x={x}
y={props.data.itemsCounts}
/>
<LineChart
title="Votes"
x={x}
y={props.data.votesCounts}
/>
{charts.map(([title, values]) => (
<LineChart
title={title}
x={x}
y={values}
/>
))}
</div>
</div>
</>
Expand Down
60 changes: 36 additions & 24 deletions utils/db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2023 the Deno authors. All rights reserved. MIT license.
import { chunk } from "std/collections/chunk.ts";

const KV_PATH_KEY = "KV_PATH";
let path = undefined;
Expand Down Expand Up @@ -29,6 +30,35 @@ async function getValues<T>(
return values;
}

/**
* Gets many values from KV. Uses batched requests to get values in chunks of 10.
*/
async function getManyValues<T>(
keys: Deno.KvKey[],
): Promise<(T | null)[]> {
const promises = [];
for (const batch of chunk(keys, 10)) {
promises.push(kv.getMany<T[]>(batch));
}
return (await Promise.all(promises))
.flat()
.map((entry) => entry?.value);
}

/** Gets all dates since a given number of milliseconds ago */
export function getDatesSince(msAgo: number) {
const dates = [];
const now = Date.now();
const start = new Date(now - msAgo);

while (+start < now) {
start.setDate(start.getDate() + 1);
dates.push(formatDate(new Date(start)));
}

return dates;
}

/** Converts `Date` to ISO format that is zero UTC offset */
export function formatDate(date: Date) {
return date.toISOString().split("T")[0];
Expand Down Expand Up @@ -58,7 +88,7 @@ export function newItemProps(): Pick<Item, "id" | "score" | "createdAt"> {
*
* @example New item creation
* ```ts
* import { newItemProps, createItem, incrementAnalyticsMetricPerDay } from "@/utils/db.ts";
* import { newItemProps, createItem } from "@/utils/db.ts";
*
* const item: Item = {
* userId: "example-user-id",
Expand Down Expand Up @@ -397,8 +427,8 @@ export async function getUserByStripeCustomer(stripeCustomerId: string) {

export async function getManyUsers(ids: string[]) {
const keys = ids.map((id) => ["users", id]);
const res = await kv.getMany<User[]>(keys);
return res.map((entry) => entry.value!);
const res = await getManyValues<User>(keys);
return res.filter(Boolean) as User[];
}

export async function getAreVotedBySessionId(
Expand All @@ -419,35 +449,17 @@ export function compareScore(a: Item, b: Item) {

// Analytics
export async function incrVisitsCountByDay(date: Date) {
// convert to ISO format that is zero UTC offset
const visitsKey = [
"visits_count",
formatDate(date),
];
const visitsKey = ["visits_count", formatDate(date)];
await kv.atomic()
.sum(visitsKey, 1n)
.commit();
}

/** Gets all dates since a given number of milliseconds ago */
export function getDatesSince(msAgo: number) {
const dates = [];
const now = Date.now();
const start = new Date(now - msAgo);

while (+start < now) {
start.setDate(start.getDate() + 1);
dates.push(formatDate(new Date(start)));
}

return dates;
}

export async function getManyMetrics(
metric: "visits_count" | "items_count" | "votes_count" | "users_count",
dates: Date[],
) {
const keys = dates.map((date) => [metric, formatDate(date)]);
const res = await kv.getMany<bigint[]>(keys);
return res.map(({ value }) => value?.valueOf() ?? 0n);
const res = await getManyValues<bigint>(keys);
return res.map((value) => value?.valueOf() ?? 0n);
}
8 changes: 8 additions & 0 deletions utils/db_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,14 @@ Deno.test("[db] votes", async () => {
assertRejects(async () => await deleteVote({ item, user }));
});

Deno.test("[db] getManyMetrics()", async () => {
const last5Days = getDatesSince(DAY * 5).map((date) => new Date(date));
const last30Days = getDatesSince(DAY * 30).map((date) => new Date(date));

assertEquals((await getManyMetrics("items_count", last5Days)).length, 5);
assertEquals((await getManyMetrics("items_count", last30Days)).length, 30);
});

Deno.test("[db] formatDate()", () => {
assertEquals(formatDate(new Date("2023-01-01")), "2023-01-01");
assertEquals(formatDate(new Date("2023-01-01T13:59:08.740Z")), "2023-01-01");
Expand Down