From 9dd54e9809b7d4d5db19992083c31625e484c864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 27 Dec 2024 21:44:29 +0100 Subject: [PATCH] feat(analytics): add some stats --- apps/frontend/src/pages/Account/Analytics.tsx | 411 +++++++++++++----- apps/frontend/src/pages/Account/Settings.tsx | 164 +++---- apps/frontend/src/ui/Charts.tsx | 76 +++- 3 files changed, 468 insertions(+), 183 deletions(-) diff --git a/apps/frontend/src/pages/Account/Analytics.tsx b/apps/frontend/src/pages/Account/Analytics.tsx index c55a34aa3..ca949ee06 100644 --- a/apps/frontend/src/pages/Account/Analytics.tsx +++ b/apps/frontend/src/pages/Account/Analytics.tsx @@ -2,17 +2,28 @@ import { Suspense, useCallback, useEffect, useMemo } from "react"; import { useQuery } from "@apollo/client"; import { invariant } from "@argos/util/invariant"; import NumberFlow from "@number-flow/react"; +import clsx from "clsx"; import moment from "moment"; import { Helmet } from "react-helmet"; import { Navigate, useParams, useSearchParams } from "react-router-dom"; -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { + Area, + AreaChart, + CartesianGrid, + Pie, + PieChart, + XAxis, + YAxis, +} from "recharts"; -import { DocumentType, graphql } from "@/gql"; +import { graphql } from "@/gql"; import { TimeSeriesGroupBy } from "@/gql/graphql"; import { Card } from "@/ui/Card"; import { ChartConfig, ChartContainer, + ChartLegend, + ChartLegendContent, ChartTooltip, ChartTooltipContent, getChartColorFromIndex, @@ -69,20 +80,18 @@ const AccountQuery = graphql(` } `); -type Account = NonNullable["account"]>; - /** @route */ export function Component() { const { accountSlug } = useParams(); invariant(accountSlug); - const [params, setParams] = useSearchParams({ period: "last-365-days" }); + const [params, setParams] = useSearchParams({ period: DEFAULT_PERIOD }); const period = parsePeriod(params.get("period")); const setPeriod = useCallback( (value: Period) => { setParams((prev) => { const next = new URLSearchParams(prev); - if (value === "last-365-days") { + if (value === DEFAULT_PERIOD) { next.delete("period"); } else { next.set("period", value); @@ -98,35 +107,31 @@ export function Component() { }, [setPeriod, period]); return ( - -
-
- - Analytics - -

- Track builds and screenshots to monitor your visual testing activity - at a glance. -

+
+ +
+
+ + Analytics + +

+ Track builds and screenshots to monitor your visual testing + activity at a glance. +

+
+
- -
- - {accountSlug} • Analytics - - }> - - - + + {accountSlug} • Analytics + + }> + + + +
); } -const emptyMetric = { - all: { total: 0, projects: {} }, - series: [], - projects: [], -}; - function Charts(props: { accountSlug: string; period: Period }) { const { accountSlug, period } = props; const { from, to, groupBy } = Periods[period]; @@ -139,82 +144,278 @@ function Charts(props: { accountSlug: string; period: Period }) { }, }); - if (data && !data.account) { + const metrics = data?.account?.metrics; + + const screenshotByBuildSeries: Metric | null = useMemo(() => { + if (!metrics) { + return null; + } + + const series = metrics.screenshots.series.reduce( + (acc, serie, index) => { + const screenshots = serie; + const builds = metrics.builds.series[index]; + invariant(builds); + acc.push({ + projects: metrics.screenshots.projects.reduce>( + (acc, project) => { + const nbScreenshots = screenshots.projects[project.id]; + const nbBuilds = builds.projects[project.id]; + acc[project.id] = + nbBuilds > 0 ? Math.round(nbScreenshots / nbBuilds) : 0; + return acc; + }, + {}, + ), + total: Math.round(screenshots.total / builds.total), + ts: serie.ts, + }); + return acc; + }, + [], + ); + const all = series.reduce<{ + total: number; + projects: Record; + }>( + (acc, serie) => { + acc.total += serie.total; + Object.entries(serie.projects).forEach(([projectId, count]) => { + acc.projects[projectId] = (acc.projects[projectId] ?? 0) + count; + }); + return acc; + }, + { total: 0, projects: {} }, + ); + + return { + all, + projects: metrics.screenshots.projects, + series, + }; + }, [metrics]); + + if (data && !metrics) { return ; } return ( -
- - +
+ + + Builds + + + + {metrics?.builds ? ( + metrics.builds.all.total === 0 ? ( + + ) : ( + + ) + ) : null} + + + + + Screenshots + + + + {metrics ? ( + metrics.screenshots.all.total === 0 ? ( + + ) : ( + + ) + ) : null} + + + + + Screenshots by Project + + + {metrics ? : null} + + +
+ + + Usage by {GroupByLabels[groupBy]} + +
+
+ Builds + +
+
+ Screenshots + +
+
+
+
+ + + + Screenshots by Build + + + + {screenshotByBuildSeries ? ( + screenshotByBuildSeries.all.total === 0 ? ( + + ) : ( + + ) + ) : null} + +
); } -type Metric = Account["metrics"]["builds"] | Account["metrics"]["screenshots"]; +type Metric = { + all: { + total: number; + projects: Record; + }; + projects: { id: string; name: string }[]; + series: { + ts: number; + total: number; + projects: Record; + }[]; +}; -function ChartCard(props: { - metric: Metric | null; - from: Date; - to: Date; - groupBy: TimeSeriesGroupBy; - title: string; - emptyTitle: string; - emptyDescription: string; +function ChartCardHeader(props: { children: React.ReactNode }) { + return
{props.children}
; +} + +function Count(props: { count: number | null }) { + const isLoading = props.count === null; + return ( +
+ + {isLoading && ( +
+ )} +
+ ); +} + +function ChartCardHeading(props: { + children: React.ReactNode; + className?: string; }) { - const { metric, from, to, groupBy, title, emptyTitle, emptyDescription } = - props; return ( - -
-
-

{title}

-
- - {!metric && ( -
- )} -
-
-
-
- {metric && metric.all.total === 0 ? ( -
-
{emptyTitle}
-

{emptyDescription}

-
- ) : ( -
- - {/* */} -
- )} -
- +

+ {props.children} +

+ ); +} + +function ChartCardDescription(props: { children: React.ReactNode }) { + return

{props.children}

; +} + +function ChartCardBody(props: { children: React.ReactNode }) { + return ( +
+ {props.children} +
+ ); +} + +function EmptyState(props: { title: string; description: string }) { + return ( +
+
{props.title}
+

{props.description}

+
+ ); +} + +function ProjectPieChart(props: { metric: Metric }) { + const chartConfig = props.metric.projects.reduce( + (config, project, index) => { + config[project.name] = { + label: project.name, + count: props.metric.all.projects[project.id], + color: getChartColorFromIndex(index), + }; + return config; + }, + { screenshots: { label: "Screenshots" } }, + ); + const data = props.metric.projects.reduce< + { project: string; screenshots: number; fill: string }[] + >((acc, project, index) => { + const screenshots = props.metric.all.projects[project.id]; + invariant(typeof screenshots === "number"); + acc.push({ + project: project.name, + screenshots, + fill: getChartColorFromIndex(index), + }); + return acc; + }, []); + return ( + + + } + /> + + } /> + + ); } @@ -379,6 +580,8 @@ const Periods: Record< }, }; +const DEFAULT_PERIOD: Period = "last-30-days"; + function parsePeriod(value: string | null): Period { switch (value) { case "last-7-days": @@ -387,15 +590,21 @@ function parsePeriod(value: string | null): Period { case "last-365-days": return value; default: - return "last-365-days"; + return DEFAULT_PERIOD; } } const PeriodLabels: Record = { - "last-7-days": "Last week", - "last-30-days": "Last month", - "last-90-days": "Last 3 months", - "last-365-days": "Last year", + "last-7-days": "Last 7 days", + "last-30-days": "Last 30 days", + "last-90-days": "Last 90 days", + "last-365-days": "Last 365 days", +}; + +const GroupByLabels: Record = { + [TimeSeriesGroupBy.Day]: "Day", + [TimeSeriesGroupBy.Week]: "Week", + [TimeSeriesGroupBy.Month]: "Month", }; function PeriodSelect(props: { diff --git a/apps/frontend/src/pages/Account/Settings.tsx b/apps/frontend/src/pages/Account/Settings.tsx index f5ed21000..842505df3 100644 --- a/apps/frontend/src/pages/Account/Settings.tsx +++ b/apps/frontend/src/pages/Account/Settings.tsx @@ -65,89 +65,93 @@ export function Component() { const hasAdminPermission = permissions.includes(AccountPermission.Admin); return ( - - - {accountSlug} • Settings - - - {userSlug === accountSlug ? "Personal" : "Team"} Settings - - } - query={AccountQuery} - variables={{ slug: accountSlug }} - > - {({ account }) => { - if (!account) { - return ; - } - const isTeam = account.__typename === "Team"; - const isUser = account.__typename === "User"; - const fineGrainedAccessControlIncluded = Boolean( - isTeam && account.plan?.fineGrainedAccessControlIncluded, - ); +
+ + + {accountSlug} • Settings + + + {userSlug === accountSlug ? "Personal" : "Team"} Settings + + } + query={AccountQuery} + variables={{ slug: accountSlug }} + > + {({ account }) => { + if (!account) { + return ; + } + const isTeam = account.__typename === "Team"; + const isUser = account.__typename === "User"; + const fineGrainedAccessControlIncluded = Boolean( + isTeam && account.plan?.fineGrainedAccessControlIncluded, + ); - return ( - - {hasAdminPermission && - (() => { - switch (account.__typename) { - case "User": - return ( - <> - - - - ); - case "Team": - return ( - <> - + + + + ); + case "Team": + return ( + <> + - - - ); - } - return null; - })()} - {isUser && hasAdminPermission && } - {hasAdminPermission && } - {isTeam && } - {isTeam && hasAdminPermission && } - {isTeam && - hasAdminPermission && - fineGrainedAccessControlIncluded && ( - + /> + + ); + } + return null; + })()} + {isUser && hasAdminPermission && } + {hasAdminPermission && } + {isTeam && } + {isTeam && hasAdminPermission && ( + )} - {isTeam && } - {isTeam && hasAdminPermission && ( - - )} - {hasAdminPermission && } - {isTeam && hasAdminPermission && } - - ); - }} - - + {isTeam && + hasAdminPermission && + fineGrainedAccessControlIncluded && ( + + )} + {isTeam && } + {isTeam && hasAdminPermission && ( + + )} + {hasAdminPermission && } + {isTeam && hasAdminPermission && } + + ); + }} + + +
); } diff --git a/apps/frontend/src/ui/Charts.tsx b/apps/frontend/src/ui/Charts.tsx index 43d28a2bc..2196b9e21 100644 --- a/apps/frontend/src/ui/Charts.tsx +++ b/apps/frontend/src/ui/Charts.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { invariant } from "@argos/util/invariant"; +import NumberFlow from "@number-flow/react"; import clsx from "clsx"; import * as RechartsPrimitive from "recharts"; @@ -16,6 +17,7 @@ export function getChartColorFromIndex(index: number) { export type ChartConfig = { [k in string]: { label?: React.ReactNode; + count?: number; icon?: React.ComponentType; } & ( | { color?: string; theme?: never } @@ -242,7 +244,7 @@ function ChartTooltipContent({
{item.value && ( - + {item.value.toLocaleString()} )} @@ -296,4 +298,74 @@ function getPayloadConfigFromPayload( : config[key as keyof typeof config]; } -export { ChartContainer, ChartTooltip, ChartTooltipContent }; +const ChartLegend = RechartsPrimitive.Legend; + +function ChartLegendContent({ + ref, + className, + hideIcon = false, + payload, + verticalAlign = "bottom", + nameKey, +}: React.ComponentPropsWithRef<"div"> & + Pick & { + hideIcon?: boolean; + nameKey?: string; + }) { + const { config } = useChart(); + + if (!payload?.length) { + return null; + } + + return ( +
+ {payload.map((item) => { + const key = `${nameKey || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + + return ( +
+
svg]:text-low flex items-center gap-1.5 [&>svg]:size-3", + )} + > + {itemConfig?.icon && !hideIcon ? ( + + ) : ( +
+ )} + {itemConfig?.label} +
+ {typeof itemConfig?.count === "number" && ( +
+ +
+ )} +
+ ); + })} +
+ ); +} + +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, +};