diff --git a/app/db/config.ts b/app/db/config.ts index 61819a701..b06cc3834 100644 --- a/app/db/config.ts +++ b/app/db/config.ts @@ -185,6 +185,35 @@ export const getAllConfigs = async () => { return await Promise.all(parsedConfigs.map(upgradeDbConfig)); }; +export const getConfigViewCount = async (configKey: string) => { + return await prisma.config + .findFirstOrThrow({ + where: { + key: configKey, + }, + include: { + _count: { + select: { + views: true, + }, + }, + }, + }) + .then((config) => config._count.views) + .catch(() => 0); +}; + +/** + * Increase the view count of a config. + */ +export const increaseConfigViewCount = async (configKey: string) => { + await prisma.configView.create({ + data: { + config_key: configKey, + }, + }); +}; + /** * Get config from a user. */ diff --git a/app/pages/embed/[chartId].tsx b/app/pages/embed/[chartId].tsx index 4247a8d4c..bab65f00c 100644 --- a/app/pages/embed/[chartId].tsx +++ b/app/pages/embed/[chartId].tsx @@ -8,7 +8,7 @@ import { ConfiguratorStateProvider, ConfiguratorStatePublished, } from "@/configurator"; -import { getConfig } from "@/db/config"; +import { getConfig, increaseConfigViewCount } from "@/db/config"; import { serializeProps } from "@/db/serialize"; import { EmbedOptionsProvider } from "@/utils/embed"; @@ -30,6 +30,7 @@ export const getServerSideProps: GetServerSideProps = async ({ const config = await getConfig(query.chartId as string); if (config?.data) { + await increaseConfigViewCount(config.key); return { props: serializeProps({ status: "found", diff --git a/app/pages/statistics.tsx b/app/pages/statistics.tsx index 033b10a9d..7ef869e9a 100644 --- a/app/pages/statistics.tsx +++ b/app/pages/statistics.tsx @@ -1,6 +1,7 @@ /* eslint-disable visualize-admin/no-large-sx */ import { Box, Card, Tooltip, Typography } from "@mui/material"; import { max, rollups, sum } from "d3-array"; +import { formatLocale } from "d3-format"; import { timeFormat } from "d3-time-format"; import { motion } from "framer-motion"; import uniq from "lodash/uniq"; @@ -13,7 +14,7 @@ import prisma from "@/db/client"; import { Serialized, deserializeProps, serializeProps } from "@/db/serialize"; import { useFlag } from "@/flags"; -type PageProps = { +type StatProps = { countByDay: { day: Date; count: number }[]; trendAverages: { lastMonthDailyAverage: number; @@ -21,8 +22,18 @@ type PageProps = { }; }; +type PageProps = { + charts: StatProps; + views: StatProps; +}; + export const getServerSideProps: GetServerSideProps = async () => { - const [countByDay, trendAverages] = await Promise.all([ + const [ + chartCountByDay, + chartTrendAverages, + viewCountByDay, + viewTrendAverages, + ] = await Promise.all([ prisma.$queryRaw` SELECT DATE_TRUNC('day', created_at) AS day, @@ -45,15 +56,67 @@ export const getServerSideProps: GetServerSideProps = async () => { SELECT COUNT(*) / 30.0 AS daily_average FROM config WHERE - created_at > CURRENT_DATE - INTERVAL '30 days' - AND created_at <= CURRENT_DATE + created_at > CURRENT_DATE - INTERVAL '30 days' + AND created_at <= CURRENT_DATE ), last_three_months_daily_average AS ( SELECT COUNT(*) / 90.0 AS daily_average FROM config WHERE - created_at > CURRENT_DATE - INTERVAL '90 days' - AND created_at <= CURRENT_DATE + created_at > CURRENT_DATE - INTERVAL '90 days' + AND created_at <= CURRENT_DATE + ) + SELECT + (SELECT daily_average FROM last_month_daily_average) AS last_month_daily_average, + (SELECT daily_average FROM last_three_months_daily_average) AS previous_three_months_daily_average; + `.then((rows) => { + const row = ( + rows as { + last_month_daily_average: number; + previous_three_months_daily_average: number; + }[] + )[0]; + return { + // superjson conversion breaks when we use default BigInt + lastMonthDailyAverage: Number(row.last_month_daily_average), + previousThreeMonthsDailyAverage: Number( + row.previous_three_months_daily_average + ), + }; + }), + // Unfortunately we can't abstract this out to a function because of the way Prisma works + // see https://www.prisma.io/docs/orm/prisma-client/queries/raw-database-access/raw-queries#considerations + prisma.$queryRaw` + SELECT + DATE_TRUNC('day', viewed_at) AS day, + COUNT(*) AS count + FROM + view + GROUP BY + DATE_TRUNC('day', viewed_at) + ORDER BY + day DESC;`.then((rows) => + (rows as { day: Date; count: BigInt }[]).map((row) => ({ + ...row, + // superjson conversion breaks when we use default BigInt + count: Number(row.count), + })) + ), + prisma.$queryRaw` + WITH + last_month_daily_average AS ( + SELECT COUNT(*) / 30.0 AS daily_average + FROM view + WHERE + viewed_at > CURRENT_DATE - INTERVAL '30 days' + AND viewed_at <= CURRENT_DATE + ), + last_three_months_daily_average AS ( + SELECT COUNT(*) / 90.0 AS daily_average + FROM view + WHERE + viewed_at > CURRENT_DATE - INTERVAL '90 days' + AND viewed_at <= CURRENT_DATE ) SELECT (SELECT daily_average FROM last_month_daily_average) AS last_month_daily_average, @@ -76,48 +139,63 @@ export const getServerSideProps: GetServerSideProps = async () => { ]); return { props: serializeProps({ - countByDay, - trendAverages, + charts: { + countByDay: chartCountByDay, + trendAverages: chartTrendAverages, + }, + views: { + countByDay: viewCountByDay, + trendAverages: viewTrendAverages, + }, }), }; }; const Statistics = (props: Serialized) => { - const { countByDay, trendAverages } = deserializeProps(props); - const { countByYearMonth, total } = useMemo(() => { - return { - countByYearMonth: groupByYearMonth(countByDay), - total: sum(countByDay, (d) => d.count) ?? 0, - }; - }, [countByDay]); - const averageChartCountPerMonth = Math.round(total / countByYearMonth.length); - const { lastMonthDailyAverage, previousThreeMonthsDailyAverage } = - trendAverages; + const { charts, views } = deserializeProps(props); return (

Statistics

- 1 ? "s" : ""} per month on average.` : ""}`} - data={countByYearMonth} - trend={{ - direction: - lastMonthDailyAverage > previousThreeMonthsDailyAverage - ? "up" - : "down", - lastMonthDailyAverage, - previousThreeMonthsDailyAverage, + + > + {charts.countByDay.length > 0 && ( + + `Visualize users created ${formatInteger(total)} charts in total` + } + subtitle={(total, avgMonthlyCount) => + `${total ? ` It's around ${formatInteger(avgMonthlyCount)} chart${avgMonthlyCount > 1 ? "s" : ""} per month on average.` : ""}` + } + /> + )} + {views.countByDay.length > 0 && ( + + `Charts were viewed ${formatInteger(total)} times in total` + } + subtitle={(total, avgMonthlyCount) => + `${total ? ` It's around ${formatInteger(avgMonthlyCount)} view${avgMonthlyCount > 1 ? "s" : ""} per month on average.` : ""}` + } + /> + )} +
); @@ -128,7 +206,9 @@ export default Statistics; const formatShortMonth = timeFormat("%b"); const formatYearMonth = timeFormat("%Y-%m"); -const groupByYearMonth = (countByDay: PageProps["countByDay"]) => { +const groupByYearMonth = ( + countByDay: PageProps[keyof PageProps]["countByDay"] +) => { const countByDate = rollups( countByDay, (v) => ({ @@ -143,23 +223,25 @@ const groupByYearMonth = (countByDay: PageProps["countByDay"]) => { ); const start = countByDate[0][1].date; const end = countByDate[countByDate.length - 1][1].date; - for (let date = start; date <= end; date.setMonth(date.getMonth() + 1)) { - if (!allYearMonthStrings.includes(formatYearMonth(date))) { - countByDate.push([ - formatYearMonth(date), - { - count: 0, - date, - monthStr: formatShortMonth(date), - }, - ]); + if (start.getTime() !== end.getTime()) { + for (let date = start; date <= end; date.setMonth(date.getMonth() + 1)) { + if (!allYearMonthStrings.includes(formatYearMonth(date))) { + countByDate.push([ + formatYearMonth(date), + { + count: 0, + date, + monthStr: formatShortMonth(date), + }, + ]); + } } } countByDate.sort(([a], [b]) => b.localeCompare(a)); return countByDate; }; -const CreatedChartsCard = ({ +const BaseStatsCard = ({ title, subtitle, data, @@ -178,7 +260,7 @@ const CreatedChartsCard = ({ return ( ["data"][number][1] & { +}: ComponentProps["data"][number][1] & { dateStr: string; maxCount: number; }) => { @@ -323,7 +405,7 @@ const Bar = ({ textAlign: "end", }} > - {count} + {formatInteger(count)} ); }; + +const StatsCard = ( + props: PageProps["charts"] & { + title: (total: number) => string; + subtitle: (total: number, avgMonthlyCount: number) => string; + } +) => { + const { title, subtitle, countByDay, trendAverages } = props; + const { countByYearMonth, total } = useMemo(() => { + return { + countByYearMonth: groupByYearMonth(countByDay), + total: sum(countByDay, (d) => d.count) ?? 0, + }; + }, [countByDay]); + const avgMonthlyCount = Math.round(total / countByYearMonth.length); + const { lastMonthDailyAverage, previousThreeMonthsDailyAverage } = + trendAverages; + return ( + previousThreeMonthsDailyAverage + ? "up" + : "down", + lastMonthDailyAverage, + previousThreeMonthsDailyAverage, + }} + /> + ); +}; + +const formatInteger = formatLocale({ + decimal: ".", + thousands: "\u00a0", + grouping: [3], + currency: ["", "\u00a0 CHF"], + minus: "\u2212", + percent: "%", +}).format(",d"); diff --git a/app/pages/v/[chartId].tsx b/app/pages/v/[chartId].tsx index d453b49c7..eafc38ac2 100644 --- a/app/pages/v/[chartId].tsx +++ b/app/pages/v/[chartId].tsx @@ -24,7 +24,7 @@ import { ContentLayout } from "@/components/layout"; import { PublishActions } from "@/components/publish-actions"; import { ConfiguratorStatePublished, getChartConfig } from "@/config-types"; import { ConfiguratorStateProvider } from "@/configurator/configurator-state"; -import { getConfig } from "@/db/config"; +import { getConfig, increaseConfigViewCount } from "@/db/config"; import { deserializeProps, Serialized, serializeProps } from "@/db/serialize"; import { useLocale } from "@/locales/use-locale"; import { useDataSourceStore } from "@/stores/data-source"; @@ -49,13 +49,18 @@ export const getServerSideProps: GetServerSideProps = async ({ const config = await getConfig(query.chartId as string); if (config && config.data) { - // TODO validate configuration - return { props: serializeProps({ status: "found", config }) }; + await increaseConfigViewCount(config.key); + return { + props: serializeProps({ + status: "found", + config, + }), + }; } res.statusCode = 404; - return { props: { status: "notfound", config: null } }; + return { props: { status: "notfound", config: null, viewCount: null } }; }; const useStyles = makeStyles((theme: Theme) => ({ @@ -63,6 +68,7 @@ const useStyles = makeStyles((theme: Theme) => ({ backgroundColor: "white", padding: `${theme.spacing(3)} 2.25rem`, justifyContent: "flex-end", + alignItems: "center", display: "flex", width: "100%", borderBottom: "1px solid", @@ -71,6 +77,14 @@ const useStyles = makeStyles((theme: Theme) => ({ padding: `${theme.spacing(3)} 0.75rem`, }, }, + viewCount: { + display: "flex", + alignItems: "center", + "& svg": { + marginRight: theme.spacing(1), + }, + color: theme.palette.text.secondary, + }, })); const VisualizationPage = (props: Serialized) => { diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 77f2e7a7c..2888643d9 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -23,6 +23,8 @@ model Config { user User? @relation(fields: [user_id], references: [id]) user_id Int? + views ConfigView[] + published_state PUBLISHED_STATE @default(PUBLISHED) @@map("config") @@ -37,6 +39,16 @@ model User { @@map("users") } +model ConfigView { + id Int @id @default(autoincrement()) + viewed_at DateTime @default(now()) @db.Timestamp(6) + + config Config @relation(fields: [config_key], references: [key]) + config_key String + + @@map("config_view") +} + model OldMigrations { id Int @id name String @unique @db.VarChar(100)