From 4995a0e1e4ab571476f24d95048365034abd2fea Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 11 Jun 2025 00:05:34 -0700 Subject: [PATCH] Fixed attribution report. New metric cards. Converted ListTable. --- .../[websiteId]/WebsiteDetailsPage.tsx | 4 +- .../websites/[websiteId]/WebsiteTableView.tsx | 2 +- .../[websiteId]/events/EventsPage.tsx | 4 +- ...bsiteRealtimePage.tsx => RealtimePage.tsx} | 8 +- .../websites/[websiteId]/realtime/page.tsx | 4 +- .../reports/attribution/Attribution.tsx | 139 ++++++++---------- .../reports/attribution/AttributionPage.tsx | 3 +- .../reports/breakdown/BreakdownPage.tsx | 2 +- .../[websiteId]/reports/revenue/Revenue.tsx | 16 +- .../[websiteId]/sessions/SessionsPage.tsx | 6 +- src/components/common/FilterLink.module.css | 1 + src/components/common/Panel.tsx | 12 +- src/components/metrics/ListTable.module.css | 110 -------------- src/components/metrics/ListTable.tsx | 67 +++++---- src/components/metrics/MetricCard.module.css | 7 - src/components/metrics/MetricCard.tsx | 10 +- src/components/metrics/MetricsBar.tsx | 14 +- src/lib/filters.ts | 1 + src/queries/sql/reports/getAttribution.ts | 45 +++--- 19 files changed, 167 insertions(+), 288 deletions(-) rename src/app/(main)/websites/[websiteId]/realtime/{WebsiteRealtimePage.tsx => RealtimePage.tsx} (88%) delete mode 100644 src/components/metrics/ListTable.module.css delete mode 100644 src/components/metrics/MetricCard.module.css diff --git a/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx b/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx index 382041d1..2e146ec0 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteDetailsPage.tsx @@ -17,9 +17,7 @@ export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) { return ( - - - + diff --git a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx index 7395213d..1eb19744 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteTableView.tsx @@ -32,7 +32,7 @@ export function WebsiteTableView({ websiteId }: { websiteId: string }) { - + diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx index 01a60b98..7e7e0440 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx @@ -23,9 +23,7 @@ export function EventsPage({ websiteId }) { return ( - - - + diff --git a/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx similarity index 88% rename from src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx rename to src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx index b2a3a445..1801fe7f 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx @@ -13,7 +13,7 @@ import { RealtimeUrls } from './RealtimeUrls'; import { RealtimeCountries } from './RealtimeCountries'; import { percentFilter } from '@/lib/filters'; -export function WebsiteRealtimePage({ websiteId }: { websiteId: string }) { +export function RealtimePage({ websiteId }: { websiteId: string }) { const { data, isLoading, error } = useRealtimeQuery(websiteId); if (isLoading || error) { @@ -28,9 +28,7 @@ export function WebsiteRealtimePage({ websiteId }: { websiteId: string }) { return ( - - - + @@ -46,7 +44,7 @@ export function WebsiteRealtimePage({ websiteId }: { websiteId: string }) { - + diff --git a/src/app/(main)/websites/[websiteId]/realtime/page.tsx b/src/app/(main)/websites/[websiteId]/realtime/page.tsx index e6f82f7d..c1bdd4c5 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/page.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/page.tsx @@ -1,10 +1,10 @@ -import { WebsiteRealtimePage } from './WebsiteRealtimePage'; +import { RealtimePage } from './RealtimePage'; import { Metadata } from 'next'; export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { const { websiteId } = await params; - return ; + return ; } export const metadata: Metadata = { diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx index c9357351..cd3108b0 100644 --- a/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx @@ -2,13 +2,12 @@ import { Grid, Column } from '@umami/react-zen'; import { useMessages, useResultQuery } from '@/components/hooks'; import { Panel } from '@/components/common/Panel'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { formatLongNumber } from '@/lib/format'; -import { CHART_COLORS } from '@/lib/constants'; - -import { PieChart } from '@/components/charts/PieChart'; import { ListTable } from '@/components/metrics/ListTable'; import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { SectionHeader } from '@/components/common/SectionHeader'; +import { formatLongNumber } from '@/lib/format'; +import { percentFilter } from '@/lib/filters'; export interface AttributionProps { websiteId: string; @@ -44,16 +43,8 @@ export function Attribution({ const isEmpty = !Object.keys(data || {}).length; const { formatMessage, labels } = useMessages(); - const ATTRIBUTION_PARAMS = [ - { value: 'referrer', label: formatMessage(labels.referrers) }, - { value: 'paidAds', label: formatMessage(labels.paidAds) }, - ]; - if (!data) { - return null; - } - - const { pageviews, visitors, visits } = data.total; + const { pageviews, visitors, visits } = data?.total || {}; const metrics = data ? [ @@ -75,22 +66,18 @@ export function Attribution({ ] : []; - function UTMTable(UTMTableProps: { data: any; title: string; utm: string }) { - const { data, title, utm } = UTMTableProps; - const total = data[utm].reduce((sum, { value }) => { - return +sum + +value; - }, 0); - + function UTMTable({ data = [], title }: { data: any; title: string }) { return ( ({ - x: name, - y: Number(value), - z: (value / total) * 100, - }))} + data={percentFilter( + data.map(({ name, value }) => ({ + x: name, + y: Number(value), + })), + )} /> ); } @@ -98,58 +85,58 @@ export function Attribution({ return ( - - - {metrics?.map(({ label, value, formatValue }) => { - return ( - - ); - })} - - - {ATTRIBUTION_PARAMS.map(({ value, label }) => { - const items = data[value]; - const total = items.reduce((sum, { value }) => { - return +sum + +value; - }, 0); - - const chartData = { - labels: items.map(({ name }) => name), - datasets: [ - { - data: items.map(({ value }) => value), - backgroundColor: CHART_COLORS, - borderWidth: 0, - }, - ], - }; - - return ( - - - ({ - x: name, - y: Number(value), - z: (value / total) * 100, - }))} - /> - - - - ); - })} - - - - - - - - - + + {metrics?.map(({ label, value, formatValue }) => { + return ; + })} + + + + + ({ + x: name, + y: Number(value), + })), + )} + /> + + + ({ + x: name, + y: Number(value), + })), + )} + /> + + + + + + + + + + + + + + + + + + + + ); diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/AttributionPage.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/AttributionPage.tsx index 758c6b50..41d0bd72 100644 --- a/src/app/(main)/websites/[websiteId]/reports/attribution/AttributionPage.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/attribution/AttributionPage.tsx @@ -15,7 +15,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) { } = useDateRange(websiteId); return ( - + @@ -46,6 +46,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) { value={step} defaultValue={step} onSearch={setStep} + delay={1000} /> diff --git a/src/app/(main)/websites/[websiteId]/reports/breakdown/BreakdownPage.tsx b/src/app/(main)/websites/[websiteId]/reports/breakdown/BreakdownPage.tsx index 27e1ed4d..29f5848e 100644 --- a/src/app/(main)/websites/[websiteId]/reports/breakdown/BreakdownPage.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/breakdown/BreakdownPage.tsx @@ -22,7 +22,7 @@ export function BreakdownPage({ websiteId }: { websiteId: string }) { const { dateRange: { startDate, endDate }, } = useDateRange(websiteId); - const [fields, setFields] = useState([]); + const [fields, setFields] = useState(['url']); return ( diff --git a/src/app/(main)/websites/[websiteId]/reports/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/reports/revenue/Revenue.tsx index b48ba897..ed2e4ce9 100644 --- a/src/app/(main)/websites/[websiteId]/reports/revenue/Revenue.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/revenue/Revenue.tsx @@ -154,15 +154,13 @@ export function Revenue({ websiteId, startDate, endDate }: RevenueProps) { - - - {metricData?.map(({ label, value, formatValue }) => { - return ( - - ); - })} - - + + {metricData?.map(({ label, value, formatValue }) => { + return ( + + ); + })} + {data && ( <> diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx index 1cfdbe6b..1ff848e7 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx @@ -18,11 +18,9 @@ export function SessionsPage({ websiteId }) { return ( - - - + - + diff --git a/src/components/common/FilterLink.module.css b/src/components/common/FilterLink.module.css index 894d6e00..03898686 100644 --- a/src/components/common/FilterLink.module.css +++ b/src/components/common/FilterLink.module.css @@ -2,6 +2,7 @@ display: flex; align-items: center; gap: 10px; + width: 100%; } .row.inactive { diff --git a/src/components/common/Panel.tsx b/src/components/common/Panel.tsx index b96923d5..10a21330 100644 --- a/src/components/common/Panel.tsx +++ b/src/components/common/Panel.tsx @@ -15,6 +15,7 @@ import { useMessages } from '@/components/hooks'; export interface PanelProps extends ColumnProps { title?: string; allowFullscreen?: boolean; + noPadding?: boolean; } const fullscreenStyles = { @@ -27,7 +28,14 @@ const fullscreenStyles = { zIndex: 9999, } as any; -export function Panel({ title, allowFullscreen, style, children, ...props }: PanelProps) { +export function Panel({ + title, + allowFullscreen, + noPadding, + style, + children, + ...props +}: PanelProps) { const { formatMessage, labels } = useMessages(); const [isFullscreen, setIsFullscreen] = useState(false); @@ -37,7 +45,7 @@ export function Panel({ title, allowFullscreen, style, children, ...props }: Pan return ( { - const { x: label, y: value, z: percent } = row; + const { x: label, y: value, z: percent } = row || {}; return ( { + const ListTableRow = ({ index, style }) => { return
{getRow(data[index], index)}
; }; return ( -
-
-
{title}
-
{metric}
-
-
- {data?.length === 0 && } + + + {title} + + {metric} + + + + {data?.length === 0 && } {virtualize && data.length > 0 ? ( - {Row} + {ListTableRow} ) : ( data.map(getRow) )} -
-
+
+
); } @@ -102,22 +102,33 @@ const AnimatedRow = ({ }); return ( -
-
{label}
-
+ + + {label} + + {change} - - {currency - ? props.y?.to(n => formatLongCurrency(n, currency)) - : props.y?.to(formatLongNumber)} - -
+ + + {currency + ? props.y?.to(n => formatLongCurrency(n, currency)) + : props.y?.to(formatLongNumber)} + + + {showPercentage && ( -
- `${n}%`) }} /> + {props.width.to(n => `${n?.toFixed?.(0)}%`)} -
+ )} -
+ ); }; diff --git a/src/components/metrics/MetricCard.module.css b/src/components/metrics/MetricCard.module.css deleted file mode 100644 index 5d0bc16c..00000000 --- a/src/components/metrics/MetricCard.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.card { - border-right: 1px solid var(--border-color); -} - -.card:last-child { - border-right: 0; -} diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx index 0ac4b54a..99892ec5 100644 --- a/src/components/metrics/MetricCard.tsx +++ b/src/components/metrics/MetricCard.tsx @@ -3,7 +3,6 @@ import { useSpring } from '@react-spring/web'; import { formatNumber } from '@/lib/format'; import { AnimatedDiv } from '@/components/common/AnimatedDiv'; import { ChangeLabel } from '@/components/metrics/ChangeLabel'; -import styles from './MetricCard.module.css'; export interface MetricCardProps { value: number; @@ -34,7 +33,14 @@ export const MetricCard = ({ const prevProps = useSpring({ x: Number(diff) || 0, from: { x: 0 } }); return ( - + {showLabel && ( {label} diff --git a/src/components/metrics/MetricsBar.tsx b/src/components/metrics/MetricsBar.tsx index b35988be..a2db998e 100644 --- a/src/components/metrics/MetricsBar.tsx +++ b/src/components/metrics/MetricsBar.tsx @@ -1,24 +1,22 @@ import { ReactNode } from 'react'; -import { Grid, Loading } from '@umami/react-zen'; -import { ErrorMessage } from '@/components/common/ErrorMessage'; +import { Grid } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; export interface MetricsBarProps { isLoading?: boolean; isFetched?: boolean; - error?: unknown; + error?: Error; children?: ReactNode; } export function MetricsBar({ children, isLoading, isFetched, error }: MetricsBarProps) { return ( - <> - {isLoading && !isFetched && } - {error && } + {!isLoading && !error && isFetched && ( - + {children} )} - + ); } diff --git a/src/lib/filters.ts b/src/lib/filters.ts index 1b4a5a1e..ec50393e 100644 --- a/src/lib/filters.ts +++ b/src/lib/filters.ts @@ -47,6 +47,7 @@ export const emptyFilter = (data: any[]) => { }; export const percentFilter = (data: any[]) => { + if (!data) return []; const total = data.reduce((n, { y }) => n + y, 0); return data.map(({ x, y, ...props }) => ({ x, y, z: total ? (y / total) * 100 : 0, ...props })); }; diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts index 49439021..5f3deb1b 100644 --- a/src/queries/sql/reports/getAttribution.ts +++ b/src/queries/sql/reports/getAttribution.ts @@ -12,6 +12,17 @@ export interface AttributionCriteria { currency?: string; } +export interface AttributionResult { + referrer: { name: string; value: number }[]; + paidAds: { name: string; value: number }[]; + utm_source: { name: string; value: number }[]; + utm_medium: { name: string; value: number }[]; + utm_campaign: { name: string; value: number }[]; + utm_content: { name: string; value: number }[]; + utm_term: { name: string; value: number }[]; + total: { pageviews: number; visitors: number; visits: number }; +} + export async function getAttribution(...args: [websiteId: string, criteria: AttributionCriteria]) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -22,20 +33,11 @@ export async function getAttribution(...args: [websiteId: string, criteria: Attr async function relationalQuery( websiteId: string, criteria: AttributionCriteria, -): Promise<{ - referrer: { name: string; value: number }[]; - paidAds: { name: string; value: number }[]; - utm_source: { name: string; value: number }[]; - utm_medium: { name: string; value: number }[]; - utm_campaign: { name: string; value: number }[]; - utm_content: { name: string; value: number }[]; - utm_term: { name: string; value: number }[]; - total: { pageviews: number; visitors: number; visits: number }; -}> { +): Promise { const { startDate, endDate, model, type, step, currency } = criteria; const { rawQuery } = prisma; - const eventType = type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; - const column = type === 'url' ? 'url_path' : 'event_name'; + const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'page' ? 'url_path' : 'event_name'; const db = getDatabaseType(); const like = db === POSTGRESQL ? 'ilike' : 'like'; @@ -81,7 +83,7 @@ async function relationalQuery( group by 1),`; function getModelQuery(model: string) { - return model === 'firstClick' + return model === 'first-click' ? `\n model AS (select e.session_id, min(we.created_at) created_at @@ -239,20 +241,11 @@ async function relationalQuery( async function clickhouseQuery( websiteId: string, criteria: AttributionCriteria, -): Promise<{ - referrer: { name: string; value: number }[]; - paidAds: { name: string; value: number }[]; - utm_source: { name: string; value: number }[]; - utm_medium: { name: string; value: number }[]; - utm_campaign: { name: string; value: number }[]; - utm_content: { name: string; value: number }[]; - utm_term: { name: string; value: number }[]; - total: { pageviews: number; visitors: number; visits: number }; -}> { +): Promise { const { startDate, endDate, model, type, step, currency } = criteria; const { rawQuery } = clickhouse; - const eventType = type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; - const column = type === 'url' ? 'url_path' : 'event_name'; + const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'page' ? 'url_path' : 'event_name'; function getUTMQuery(utmColumn: string) { return ` @@ -296,7 +289,7 @@ async function clickhouseQuery( group by 1),`; function getModelQuery(model: string) { - return model === 'firstClick' + return model === 'first-click' ? `\n model AS (select e.session_id, min(we.created_at) created_at