ch attribution report, schema changes, and migration

This commit is contained in:
Francis Cao 2025-04-13 18:12:03 -07:00
parent 64dcc5af80
commit b9a2145766
10 changed files with 689 additions and 353 deletions

View file

@ -13,6 +13,7 @@ import {
Popup,
PopupTrigger,
SubmitButton,
Toggle,
} from 'react-basics';
import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[reportId]/ParameterList';
@ -21,6 +22,7 @@ import { ReportContext } from '../[reportId]/Report';
import FunnelStepAddForm from '../funnel/FunnelStepAddForm';
import styles from './AttributionParameters.module.css';
import AttributionStepAddForm from './AttributionStepAddForm';
import useRevenueValues from '@/components/hooks/queries/useRevenueValues';
export function AttributionParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
@ -29,14 +31,32 @@ export function AttributionParameters() {
const { websiteId, dateRange, steps } = parameters || {};
const queryEnabled = websiteId && dateRange && steps.length > 0;
const [model, setModel] = useState('');
const [revenueMode, setRevenueMode] = useState(false);
const { data: currencyValues = [] } = useRevenueValues(
websiteId,
dateRange?.startDate,
dateRange?.endDate,
);
const handleSubmit = (data: any, e: any) => {
if (revenueMode === false) {
delete data.currency;
}
e.stopPropagation();
e.preventDefault();
runReport(data);
};
const handleCheck = () => {
setRevenueMode(!revenueMode);
};
const handleAddStep = (step: { type: string; value: string }) => {
if (step.type === 'url') {
setRevenueMode(false);
}
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
};
@ -45,6 +65,9 @@ export function AttributionParameters() {
index: number,
step: { type: string; value: string },
) => {
if (step.type === 'url') {
setRevenueMode(false);
}
const steps = [...parameters.steps];
steps[index] = step;
updateReport({ parameters: { steps } });
@ -135,6 +158,24 @@ export function AttributionParameters() {
})}
</ParameterList>
</FormRow>
<FormRow>
<Toggle
checked={revenueMode}
onChecked={handleCheck}
disabled={currencyValues.length === 0 || steps[0]?.type === 'url'}
>
<b>Revenue Mode</b>
</Toggle>
</FormRow>
{revenueMode && (
<FormRow label={formatMessage(labels.currency)}>
<FormInput name="currency" rules={{ required: formatMessage(labels.required) }}>
<Dropdown items={currencyValues.map(item => item.currency)}>
{item => <Item key={item}>{item}</Item>}
</Dropdown>
</FormInput>
</FormRow>
)}
<FormButtons>
<SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}

View file

@ -4,8 +4,17 @@
margin-bottom: 40px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
.title {
font-size: 24px;
line-height: 36px;
font-weight: 700;
}
.row {
display: grid;
grid-template-columns: 50% 50%;
gap: 20px;
border-top: 1px solid var(--base300);
padding-top: 30px;
margin-bottom: 30px;
}

View file

@ -1,19 +1,13 @@
import classNames from 'classnames';
import { colord } from 'colord';
import BarChart from '@/components/charts/BarChart';
import PieChart from '@/components/charts/PieChart';
import TypeIcon from '@/components/common/TypeIcon';
import { useCountryNames, useLocale, useMessages } from '@/components/hooks';
import { GridRow } from '@/components/layout/Grid';
import { useMessages } from '@/components/hooks';
import { Grid, GridRow } from '@/components/layout/Grid';
import ListTable from '@/components/metrics/ListTable';
import MetricCard from '@/components/metrics/MetricCard';
import MetricsBar from '@/components/metrics/MetricsBar';
import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants';
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { useCallback, useContext, useMemo } from 'react';
import { formatLongNumber } from '@/lib/format';
import { useContext } from 'react';
import { ReportContext } from '../[reportId]/Report';
import AttributionTable from './AttributionTable';
import styles from './AttributionView.module.css';
export interface AttributionViewProps {
@ -22,134 +16,118 @@ export interface AttributionViewProps {
export function AttributionView({ isLoading }: AttributionViewProps) {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const { countryNames } = useCountryNames(locale);
const { report } = useContext(ReportContext);
const {
data,
parameters: { dateRange, currency },
parameters: { currency },
} = report || {};
const showTable = data?.table.length > 1;
const ATTRIBUTION_PARAMS = [
{ value: 'referrer', label: formatMessage(labels.referrers) },
{ value: 'paidAds', label: formatMessage(labels.paidAds) },
];
const renderCountryName = useCallback(
({ x: code }) => (
<span className={classNames(locale, styles.row)}>
<TypeIcon type="country" value={code?.toLowerCase()} />
{countryNames[code]}
</span>
),
[countryNames, locale],
);
if (!data) {
return null;
}
const chartData = useMemo(() => {
if (!data) return [];
const { pageviews, visitors, visits } = data.total;
const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
if (!obj[x]) {
obj[x] = [];
}
const metrics = data
? [
{
value: pageviews,
label: formatMessage(labels.views),
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
formatValue: formatLongNumber,
},
{
value: visitors,
label: formatMessage(labels.visitors),
formatValue: formatLongNumber,
},
]
: [];
obj[x].push({ x: t, y });
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);
return obj;
}, {});
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: map[key],
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
};
}, [data]);
const countryData = useMemo(() => {
if (!data) return [];
const labels = data.country.map(({ name }) => name);
const datasets = [
{
data: data.country.map(({ value }) => value),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
];
return { labels, datasets };
}, [data]);
const metricData = useMemo(() => {
if (!data) return [];
const { sum, count, unique_count } = data.total;
return [
{
value: sum,
label: formatMessage(labels.total),
formatValue: n => formatLongCurrency(n, currency),
},
{
value: count ? sum / count : 0,
label: formatMessage(labels.average),
formatValue: n => formatLongCurrency(n, currency),
},
{
value: count,
label: formatMessage(labels.transactions),
formatValue: formatLongNumber,
},
{
value: unique_count,
label: formatMessage(labels.uniqueCustomers),
formatValue: formatLongNumber,
},
] as any;
}, [data, locale]);
return (
<ListTable
title={title}
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={data[utm].map(({ name, value }) => ({
x: name,
y: Number(value),
z: (value / total) * 100,
}))}
/>
);
}
return (
<>
<div className={styles.container}>
<MetricsBar isFetched={data}>
{metricData?.map(({ label, value, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
})}
</MetricsBar>
{data && (
<>
<BarChart
minDate={dateRange?.startDate}
maxDate={dateRange?.endDate}
data={chartData}
unit={dateRange?.unit}
stacked={true}
currency={currency}
renderXLabel={renderDateLabels(dateRange?.unit, locale)}
isLoading={isLoading}
/>
<GridRow columns="two">
<div className={styles.container}>
<MetricsBar isFetched={data}>
{metrics?.map(({ label, value, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
})}
</MetricsBar>
{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 (
<div key={value} className={styles.row}>
<div>
<div className={styles.title}>{label}</div>
<ListTable
metric={formatMessage(labels.country)}
data={data?.country.map(({ name, value }) => ({
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={items.map(({ name, value }) => ({
x: name,
y: Number(value),
z: (value / data?.total.sum) * 100,
z: (value / total) * 100,
}))}
renderLabel={renderCountryName}
/>
<PieChart type="doughnut" data={countryData} />
</GridRow>
</>
)}
{showTable && <AttributionTable />}
</div>
</>
</div>
<div>
<PieChart type="doughnut" data={chartData} isLoading={isLoading} />
</div>
</div>
);
})}
<Grid>
<GridRow columns="two">
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
</GridRow>
<GridRow columns="three">
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
</GridRow>
</Grid>
</div>
);
}

View file

@ -1,14 +1,14 @@
import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { parseRequest } from '@/lib/request';
import { getFunnel } from '@/queries';
import { json, unauthorized } from '@/lib/response';
import { reportParms } from '@/lib/schema';
import { getAttribution } from '@/queries/sql/reports/getAttribution';
import { z } from 'zod';
export async function POST(request: Request) {
const schema = z.object({
...reportParms,
window: z.coerce.number().positive(),
model: z.string().regex(/firstClick|lastClick/i),
steps: z
.array(
z.object({
@ -16,7 +16,8 @@ export async function POST(request: Request) {
value: z.string(),
}),
)
.min(2),
.min(1),
currency: z.string().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -27,8 +28,9 @@ export async function POST(request: Request) {
const {
websiteId,
model,
steps,
window,
currency,
dateRange: { startDate, endDate },
} = body;
@ -36,11 +38,12 @@ export async function POST(request: Request) {
return unauthorized();
}
const data = await getFunnel(websiteId, {
const data = await getAttribution(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
model: model,
steps,
windowMinutes: +window,
currency,
});
return json(data);