Improvements to revenue screen

This commit is contained in:
Francis Cao
2026-03-06 09:58:43 -08:00
parent fda83acdea
commit acfd4462e3
10 changed files with 308 additions and 9 deletions
+1
View File
@@ -36,6 +36,7 @@ pm2.yml
.claude
.agents
tmpclaude*
CLAUDE.md
nul
# debug
+2
View File
@@ -338,6 +338,8 @@
"aov": "AOV",
"arpu": "ARPU",
"uniqueCustomers": "Unique Customers",
"customers": "Customers",
"orders": "Orders",
"unknown": "Unknown",
"untitled": "Untitled",
"update": "Update",
@@ -1,9 +1,16 @@
import { Column, Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { DataGrid } from '@/components/common/DataGrid';
import { GridRow } from '@/components/common/GridRow';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
import {
useDateRange,
useMessages,
useNavigation,
useResultQuery,
useRevenueSessionsQuery,
} from '@/components/hooks';
import { CurrencySelect } from '@/components/input/CurrencySelect';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
@@ -13,6 +20,31 @@ import { RevenueChart } from '@/components/metrics/RevenueChart';
import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage';
import { SessionModal } from '../../sessions/SessionModal';
import { SessionsTable } from '../../sessions/SessionsTable';
function RevenueSessionsDataTable({
websiteId,
currency,
}: {
websiteId: string;
currency: string;
}) {
const { updateParams } = useNavigation();
const queryResult = useRevenueSessionsQuery(websiteId, currency);
return (
<DataGrid query={queryResult} allowPaging allowSearch>
{({ data }) => (
<SessionsTable
data={data}
websiteId={websiteId}
getSessionHref={row => updateParams({ session: row.id })}
/>
)}
</DataGrid>
);
}
export interface RevenueProps {
websiteId: string;
@@ -56,18 +88,30 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
{
value: average,
label: t(labels.aov),
tooltip: (
<>
<div>Average Order Value</div>
<div>(Total Revenue / Orders)</div>
</>
),
change: comparison ? average - comparison.average : 0,
formatValue: (n: number) => formatLongCurrency(n, currency),
},
{
value: arpu,
label: t(labels.arpu),
tooltip: (
<>
<div>Average Revenue Per User</div>
<div>(Total Revenue / All Sessions)</div>
</>
),
change: comparison ? arpu - (comparison.arpu ?? 0) : 0,
formatValue: (n: number) => formatLongCurrency(n, currency),
},
{
value: count,
label: t(labels.transactions),
label: t(labels.orders),
change: comparison ? count - comparison.count : 0,
formatValue: formatLongNumber,
},
@@ -91,12 +135,13 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
{data && (
<Column gap>
<MetricsBar>
{metrics?.map(({ label, value, change, formatValue }) => {
{metrics?.map(({ label, value, change, formatValue, tooltip }) => {
return (
<MetricCard
key={label}
value={value}
label={label}
tooltip={tooltip}
change={change}
formatValue={formatValue}
showChange={!isAllTime}
@@ -204,9 +249,14 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
</Panel>
</GridRow>
</Grid>
<Panel>
<Heading size="2xl">{t(labels.customers)}</Heading>
<RevenueSessionsDataTable websiteId={websiteId} currency={currency} />
</Panel>
</Column>
)}
</LoadingPanel>
<SessionModal websiteId={websiteId} />
</Column>
);
}
@@ -5,7 +5,11 @@ import { DateDistance } from '@/components/common/DateDistance';
import { TypeIcon } from '@/components/common/TypeIcon';
import { useFormat, useMessages } from '@/components/hooks';
export function SessionsTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) {
export function SessionsTable({
websiteId,
getSessionHref,
...props
}: DataTableProps & { websiteId: string; getSessionHref?: (row: any) => string }) {
const { t, labels } = useMessages();
const { formatValue } = useFormat();
@@ -13,7 +17,11 @@ export function SessionsTable({ websiteId, ...props }: DataTableProps & { websit
<DataTable {...props}>
<DataColumn id="id" label={t(labels.session)} width="100px">
{(row: any) => (
<Link href={`/websites/${websiteId}/sessions/${row.id}`}>
<Link
href={
getSessionHref ? getSessionHref(row) : `/websites/${websiteId}/sessions/${row.id}`
}
>
<Avatar seed={row.id} size={32} />
</Link>
)}
@@ -0,0 +1,37 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { filterParams, pagingParams, searchParams, withDateRange } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getRevenueSessions } from '@/queries/sql/reports/getRevenueSessions';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = withDateRange({
currency: z.string(),
...filterParams,
...pagingParams,
...searchParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const { currency, ...rest } = query;
const filters = await getQueryFilters(rest, websiteId);
const data = await getRevenueSessions(websiteId, currency, filters);
return json(data);
}
+1
View File
@@ -31,6 +31,7 @@ export * from './queries/useReplaysQuery';
export * from './queries/useReportQuery';
export * from './queries/useReportsQuery';
export * from './queries/useResultQuery';
export * from './queries/useRevenueSessionsQuery';
export * from './queries/useSessionActivityQuery';
export * from './queries/useSessionDataPropertiesQuery';
export * from './queries/useSessionDataQuery';
@@ -0,0 +1,36 @@
import { useApi } from '../useApi';
import { useDateParameters } from '../useDateParameters';
import { useFilterParameters } from '../useFilterParameters';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useRevenueSessionsQuery(
websiteId: string,
currency: string,
params?: Record<string, string | number>,
) {
const { get } = useApi();
const { modified } = useModified(`revenue-sessions`);
const { startAt, endAt, unit, timezone } = useDateParameters();
const filters = useFilterParameters();
return usePagedQuery({
queryKey: [
'revenue-sessions',
{ websiteId, currency, modified, startAt, endAt, unit, timezone, ...params, ...filters },
],
queryFn: pageParams => {
return get(`/websites/${websiteId}/revenue/sessions`, {
currency,
startAt,
endAt,
unit,
timezone,
...filters,
...pageParams,
...params,
pageSize: 20,
});
},
});
}
+2
View File
@@ -258,6 +258,8 @@ export const labels: Record<string, string> = {
aov: 'label.aov',
arpu: 'label.arpu',
uniqueCustomers: 'label.uniqueCustomers',
customers: 'label.customers',
orders: 'label.orders',
viewedPage: 'message.viewed-page',
collectedData: 'message.collected-data',
triggeredEvent: 'message.triggered-event',
+20 -4
View File
@@ -1,6 +1,8 @@
import { useSpring } from '@react-spring/web';
import { Column, Text } from '@umami/react-zen';
import { Button, Column, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { AnimatedDiv } from '@/components/common/AnimatedDiv';
import { Info } from '@/components/icons';
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
import { formatNumber } from '@/lib/format';
@@ -9,6 +11,7 @@ export interface MetricCardProps {
previousValue?: number;
change?: number;
label?: string;
tooltip?: ReactNode;
reverseColors?: boolean;
formatValue?: (n: any) => string;
showLabel?: boolean;
@@ -19,6 +22,7 @@ export const MetricCard = ({
value = 0,
change = 0,
label,
tooltip,
reverseColors = false,
formatValue = formatNumber,
showLabel = true,
@@ -40,9 +44,21 @@ export const MetricCard = ({
gap="4"
>
{showLabel && (
<Text weight="bold" wrap="nowrap">
{label}
</Text>
<Row justifyContent="space-between" alignItems="flex-start">
<Text weight="bold" wrap="nowrap">
{label}
</Text>
{tooltip && (
<TooltipTrigger delay={0}>
<Button size="sm" variant="quiet">
<Icon size="sm">
<Info />
</Icon>
</Button>
<Tooltip placement="top">{tooltip}</Tooltip>
</TooltipTrigger>
)}
</Row>
)}
<Text size="4xl" weight="bold" wrap="nowrap">
<AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv>
@@ -0,0 +1,146 @@
import clickhouse from '@/lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
import type { QueryFilters } from '@/lib/types';
const FUNCTION_NAME = 'getRevenueSessions';
export async function getRevenueSessions(
...args: [websiteId: string, currency: string, filters: QueryFilters]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(websiteId: string, currency: string, filters: QueryFilters) {
const { pagedRawQuery, parseFilters } = prisma;
const { search } = filters;
const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
...filters,
websiteId,
currency,
search: search ? `%${search}%` : undefined,
});
const searchQuery = search
? `and (session.browser ilike {{search}}
or session.os ilike {{search}}
or session.device ilike {{search}}
or session.city ilike {{search}})`
: '';
return pagedRawQuery(
`
select
session.session_id as "id",
session.website_id as "websiteId",
website_event.hostname,
session.browser,
session.os,
session.device,
session.screen,
session.language,
session.country,
session.region,
session.city,
min(website_event.created_at) as "firstAt",
max(website_event.created_at) as "lastAt",
count(distinct website_event.visit_id) as "visits",
sum(case when website_event.event_type = 1 then 1 else 0 end) as "views",
sum(case when website_event.event_type = 2 then 1 else 0 end) as "events",
max(website_event.created_at) as "createdAt"
from website_event
${cohortQuery}
join session
on session.session_id = website_event.session_id
and session.website_id = website_event.website_id
where website_event.website_id = {{websiteId::uuid}}
${dateQuery}
${filterQuery}
${searchQuery}
and website_event.session_id in (
select distinct session_id
from revenue
where website_id = {{websiteId::uuid}}
${dateQuery}
and upper(currency) = {{currency}}
)
group by
session.session_id,
session.website_id,
website_event.hostname,
session.browser,
session.os,
session.device,
session.screen,
session.language,
session.country,
session.region,
session.city
order by max(website_event.created_at) desc
`,
queryParams,
filters,
FUNCTION_NAME,
);
}
async function clickhouseQuery(websiteId: string, currency: string, filters: QueryFilters) {
const { pagedRawQuery, parseFilters, getDateStringSQL } = clickhouse;
const { search } = filters;
const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
...filters,
websiteId,
currency,
});
const searchQuery = search
? `and ((positionCaseInsensitive(browser, {search:String}) > 0)
or (positionCaseInsensitive(city, {search:String}) > 0)
or (positionCaseInsensitive(os, {search:String}) > 0)
or (positionCaseInsensitive(device, {search:String}) > 0))`
: '';
return pagedRawQuery(
`
select
session_id as id,
website_id as websiteId,
hostname,
browser,
os,
device,
screen,
language,
country,
region,
city,
${getDateStringSQL('min(created_at)')} as firstAt,
${getDateStringSQL('max(created_at)')} as lastAt,
uniq(visit_id) as visits,
sumIf(1, event_type = 1) as views,
sumIf(1, event_type = 2) as events,
max(created_at) as createdAt
from website_event
${cohortQuery}
where website_id = {websiteId:UUID}
${dateQuery}
${filterQuery}
${searchQuery}
and session_id in (
select distinct session_id
from website_revenue
where website_id = {websiteId:UUID}
${dateQuery}
and upper(currency) = {currency:String}
)
group by session_id, website_id, hostname, browser, os, device, screen, language, country, region, city
order by max(created_at) desc
`,
queryParams,
filters,
FUNCTION_NAME,
);
}