diff --git a/routes/index.tsx b/routes/index.tsx index 7985456d0..cfefa237e 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -10,7 +10,7 @@ import { getAllItemsInPastWeek, getAreVotedBySessionId, getManyUsers, - incrementVisitsPerDay, + incrementAnalyticsMetricPerDay, type Item, type User, } from "@/utils/db.ts"; @@ -34,7 +34,7 @@ function calcLastPage(total = 0, pageLength = PAGE_LENGTH): number { export const handler: Handlers = { async GET(req, ctx) { - await incrementVisitsPerDay(new Date()); + await incrementAnalyticsMetricPerDay("visits_count", new Date()); const pageNum = calcPageNum(new URL(req.url)); const allItems = await getAllItemsInPastWeek(); diff --git a/routes/stats.tsx b/routes/stats.tsx index 75025f6cb..86352fd53 100644 --- a/routes/stats.tsx +++ b/routes/stats.tsx @@ -4,23 +4,36 @@ import { SITE_WIDTH_STYLES } from "@/utils/constants.ts"; import Layout from "@/components/Layout.tsx"; import Head from "@/components/Head.tsx"; import type { State } from "./_middleware.ts"; -import { getAllVisitsPerDay } from "@/utils/db.ts"; +import { getManyAnalyticsMetricsPerDay } from "@/utils/db.ts"; import { Chart } from "fresh_charts/mod.ts"; import { ChartColors } from "fresh_charts/utils.ts"; +interface AnalyticsByDay { + metricsValue: number[]; + dates: string[]; +} + interface StatsPageData extends State { - visits?: number[]; - dates?: string[]; + metricsByDay: AnalyticsByDay[]; + metricsTitles: string[]; } export const handler: Handlers = { async GET(_, ctx) { const daysBefore = 30; - const { visits, dates } = await getAllVisitsPerDay({ + + const metricsKeys = [ + "visits_count", + "users_count", + "items_count", + "votes_count", + ]; + const metricsTitles = ["Visits", "New Users", "New Items", "New Votes"]; + const metricsByDay = await getManyAnalyticsMetricsPerDay(metricsKeys, { limit: daysBefore, }); - return ctx.render({ ...ctx.state, visits, dates }); + return ctx.render({ ...ctx.state, metricsByDay, metricsTitles }); }, }; @@ -28,11 +41,14 @@ function LineChart( props: { title: string; x: string[]; y: number[] }, ) { return ( - <> +

{props.title}

- +
); } @@ -64,18 +80,20 @@ export default function StatsPage(props: PageProps) {
-
- - new Date(date).toLocaleDateString("en-us", { - year: "numeric", - month: "short", - day: "numeric", - }) - )} - y={props.data.visits!} - /> +
+ {props.data.metricsByDay.map((metric, index) => ( + + new Date(date).toLocaleDateString("en-us", { + year: "numeric", + month: "short", + day: "numeric", + }) + )} + y={metric.metricsValue!} + /> + ))}
diff --git a/utils/db.ts b/utils/db.ts index fdf3642a4..eb575cf2b 100644 --- a/utils/db.ts +++ b/utils/db.ts @@ -58,6 +58,8 @@ export async function createItem(initItem: InitItem) { if (!res.ok) throw new Error(`Failed to set item: ${item}`); + await incrementAnalyticsMetricPerDay("items_count", new Date()); + return item; } @@ -165,6 +167,8 @@ export async function createVote(vote: Vote) { if (!res.ok) throw new Error(`Failed to set vote: ${vote}`); + await incrementAnalyticsMetricPerDay("votes_count", new Date()); + return vote; } @@ -251,6 +255,8 @@ export async function createUser(initUser: InitUser) { if (!res.ok) throw new Error(`Failed to create user: ${user}`); + await incrementAnalyticsMetricPerDay("users_count", new Date()); + return user; } @@ -317,7 +323,37 @@ export async function getManyUsers(ids: string[]) { return res.map((entry) => entry.value!); } -// Visit +export async function getAreVotedBySessionId( + items: Item[], + sessionId?: string, +) { + if (!sessionId) return []; + const sessionUser = await getUserBySessionId(sessionId); + if (!sessionUser) return []; + const votedItems = await getVotedItemsByUser(sessionUser.id); + const votedItemIds = votedItems.map((item) => item.id); + return items.map((item) => votedItemIds.includes(item.id)); +} + +export function compareScore(a: Item, b: Item) { + return Number(b.score) - Number(a.score); +} + +// Analytics +export async function incrementAnalyticsMetricPerDay( + metric: string, + date: Date, +) { + // convert to ISO format that is zero UTC offset + const metricKey = [ + metric, + `${date.toISOString().split("T")[0]}`, + ]; + await kv.atomic() + .sum(metricKey, 1n) + .commit(); +} + export async function incrementVisitsPerDay(date: Date) { // convert to ISO format that is zero UTC offset const visitsKey = [ @@ -336,16 +372,29 @@ export async function getVisitsPerDay(date: Date) { ]); } -export async function getAreVotedBySessionId( - items: Item[], - sessionId?: string, +export async function getAnalyticsMetricsPerDay( + metric: string, + options?: Deno.KvListOptions, ) { - if (!sessionId) return []; - const sessionUser = await getUserBySessionId(sessionId); - if (!sessionUser) return []; - const votedItems = await getVotedItemsByUser(sessionUser.id); - const votedItemIds = votedItems.map((item) => item.id); - return items.map((item) => votedItemIds.includes(item.id)); + const iter = await kv.list({ prefix: [metric] }, options); + const metricsValue = []; + const dates = []; + for await (const res of iter) { + metricsValue.push(Number(res.value)); + dates.push(String(res.key[1])); + } + return { metricsValue, dates }; +} + +export async function getManyAnalyticsMetricsPerDay( + metrics: string[], + options?: Deno.KvListOptions, +) { + const analyticsByDay = await Promise.all( + metrics.map((metric) => getAnalyticsMetricsPerDay(metric, options)), + ); + + return analyticsByDay; } export async function getAllVisitsPerDay(options?: Deno.KvListOptions) { @@ -358,7 +407,3 @@ export async function getAllVisitsPerDay(options?: Deno.KvListOptions) { } return { visits, dates }; } - -export function compareScore(a: Item, b: Item) { - return Number(b.score) - Number(a.score); -}