Skip to content

Commit

Permalink
Merge pull request #1613 from visualize-admin/feat/set-up-chart-view-…
Browse files Browse the repository at this point in the history
…statistics

feat: Set up chart view statistics
  • Loading branch information
bprusinowski authored Jun 17, 2024
2 parents 4059756 + 9656256 commit f9ba4fc
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 51 deletions.
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
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

0 comments on commit f9ba4fc

Please sign in to comment.