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

feat: Set up chart view statistics #1613

Merged
merged 10 commits into from
Jun 17, 2024
29 changes: 29 additions & 0 deletions app/db/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
3 changes: 2 additions & 1 deletion app/pages/embed/[chartId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -30,6 +30,7 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async ({
const config = await getConfig(query.chartId as string);

if (config?.data) {
await increaseConfigViewCount(config.key);
return {
props: serializeProps({
status: "found",
Expand Down
216 changes: 170 additions & 46 deletions app/pages/statistics.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,16 +14,26 @@ 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;
previousThreeMonthsDailyAverage: number;
};
};

type PageProps = {
charts: StatProps;
views: StatProps;
};

export const getServerSideProps: GetServerSideProps<PageProps> = 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,
Expand All @@ -45,15 +56,67 @@ export const getServerSideProps: GetServerSideProps<PageProps> = 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
bprusinowski marked this conversation as resolved.
Show resolved Hide resolved
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,
Expand All @@ -76,48 +139,63 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async () => {
]);
return {
props: serializeProps({
countByDay,
trendAverages,
charts: {
countByDay: chartCountByDay,
trendAverages: chartTrendAverages,
},
views: {
countByDay: viewCountByDay,
trendAverages: viewTrendAverages,
},
}),
};
};

const Statistics = (props: Serialized<PageProps>) => {
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 (
<AppLayout>
<Box
sx={{
width: "100%",
maxWidth: 840,
maxWidth: 1400,
mx: "auto",
my: `${BANNER_MARGIN_TOP + 36}px`,
px: 4,
}}
>
<h1 style={{ margin: 0 }}>Statistics</h1>
<CreatedChartsCard
title={`Visualize users created ${total} charts in total`}
subtitle={`${total ? ` It's around ${averageChartCountPerMonth} chart${averageChartCountPerMonth > 1 ? "s" : ""} per month on average.` : ""}`}
data={countByYearMonth}
trend={{
direction:
lastMonthDailyAverage > previousThreeMonthsDailyAverage
? "up"
: "down",
lastMonthDailyAverage,
previousThreeMonthsDailyAverage,
<Box
sx={{
display: "flex",
flexDirection: ["column", "column", "row"],
gap: 2,
my: [4, 6],
}}
/>
>
{charts.countByDay.length > 0 && (
<StatsCard
{...charts}
title={(total) =>
`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 && (
<StatsCard
{...views}
title={(total) =>
`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.` : ""}`
}
/>
)}
</Box>
</Box>
</AppLayout>
);
Expand All @@ -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) => ({
Expand All @@ -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,
Expand All @@ -178,7 +260,7 @@ const CreatedChartsCard = ({
return (
<Card
sx={{
my: 6,
width: "100%",
pt: 4,
boxShadow: 2,
borderRadius: 4,
Expand Down Expand Up @@ -295,7 +377,7 @@ const Bar = ({
monthStr,
count,
maxCount,
}: ComponentProps<typeof CreatedChartsCard>["data"][number][1] & {
}: ComponentProps<typeof BaseStatsCard>["data"][number][1] & {
dateStr: string;
maxCount: number;
}) => {
Expand Down Expand Up @@ -323,7 +405,7 @@ const Bar = ({
textAlign: "end",
}}
>
<Typography variant="caption">{count}</Typography>
<Typography variant="caption">{formatInteger(count)}</Typography>
</Box>
<Box
sx={{
Expand Down Expand Up @@ -369,3 +451,45 @@ const Bar = ({
</>
);
};

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 (
<BaseStatsCard
title={title(total)}
subtitle={subtitle(total, avgMonthlyCount)}
data={countByYearMonth}
trend={{
direction:
lastMonthDailyAverage > previousThreeMonthsDailyAverage
? "up"
: "down",
lastMonthDailyAverage,
previousThreeMonthsDailyAverage,
}}
/>
);
};

const formatInteger = formatLocale({
decimal: ".",
thousands: "\u00a0",
grouping: [3],
currency: ["", "\u00a0 CHF"],
minus: "\u2212",
percent: "%",
}).format(",d");
Loading
Loading