mirror of
https://github.com/umami-software/umami.git
synced 2026-02-06 05:37:20 +01:00
Breakdown report.
This commit is contained in:
parent
79ea9974b7
commit
e3cc19638c
21 changed files with 495 additions and 456 deletions
|
|
@ -7,6 +7,7 @@ import {
|
|||
Logo,
|
||||
Grid2X2,
|
||||
Settings,
|
||||
LockKeyhole,
|
||||
} from '@/components/icons';
|
||||
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
|
||||
|
||||
|
|
@ -41,6 +42,11 @@ export function SideNav(props: any) {
|
|||
href: renderTeamUrl('/settings'),
|
||||
icon: <Settings />,
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.admin),
|
||||
href: renderTeamUrl('/admin'),
|
||||
icon: <LockKeyhole />,
|
||||
},
|
||||
].filter(n => n);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ReportsNav } from './ReportsNav';
|
|||
|
||||
export function ReportsLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
||||
return (
|
||||
<Grid columns="200px 1fr" gap="6">
|
||||
<Grid columns="180px 1fr" gap="6">
|
||||
<Column>
|
||||
<ReportsNav websiteId={websiteId} />
|
||||
</Column>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Row, NavMenu, NavMenuItem, Icon, Text } from '@umami/react-zen';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { Funnel, Lightbulb, Magnet, Money, Network, Path, Tag, Target } from '@/components/icons';
|
||||
import { Funnel, Sheet, Magnet, Money, Network, Path, Tag, Target } from '@/components/icons';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function ReportsNav({ websiteId }: { websiteId: string }) {
|
||||
|
|
@ -32,6 +32,12 @@ export function ReportsNav({ websiteId }: { websiteId: string }) {
|
|||
icon: <Magnet />,
|
||||
path: '/retention',
|
||||
},
|
||||
{
|
||||
id: 'breakdown',
|
||||
label: formatMessage(labels.breakdown),
|
||||
icon: <Sheet />,
|
||||
path: '/breakdown',
|
||||
},
|
||||
{
|
||||
id: 'utm',
|
||||
label: formatMessage(labels.utm),
|
||||
|
|
@ -50,12 +56,6 @@ export function ReportsNav({ websiteId }: { websiteId: string }) {
|
|||
icon: <Network />,
|
||||
path: '/attribution',
|
||||
},
|
||||
{
|
||||
id: 'insights',
|
||||
label: formatMessage(labels.insights),
|
||||
icon: <Lightbulb />,
|
||||
path: '/insights',
|
||||
},
|
||||
];
|
||||
|
||||
const selected = links.find(({ path }) => path && pathname.endsWith(path))?.id || 'goals';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Grid, Column, Heading } from '@umami/react-zen';
|
||||
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';
|
||||
|
|
@ -125,8 +125,7 @@ export function Attribution({
|
|||
};
|
||||
|
||||
return (
|
||||
<Panel key={value}>
|
||||
<Heading>{label}</Heading>
|
||||
<Panel key={value} title={label}>
|
||||
<Grid columns="1fr 1fr" gap>
|
||||
<ListTable
|
||||
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
|
||||
|
|
@ -142,25 +141,15 @@ export function Attribution({
|
|||
</Panel>
|
||||
);
|
||||
})}
|
||||
<Grid gap>
|
||||
<Panel title="UTM">
|
||||
<Grid columns="1fr 1fr" gap>
|
||||
<Panel>
|
||||
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
|
||||
</Panel>
|
||||
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
|
||||
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
|
||||
<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'} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Column>
|
||||
</LoadingPanel>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useDateRange, useMessages } from '@/components/hooks';
|
|||
export function AttributionPage({ websiteId }: { websiteId: string }) {
|
||||
const [model, setModel] = useState('first-click');
|
||||
const [type, setType] = useState('page');
|
||||
const [step, setStep] = useState('');
|
||||
const [step, setStep] = useState('/');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
|
|
@ -44,6 +44,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
|
|||
<SearchField
|
||||
label={formatMessage(labels.conversionStep)}
|
||||
value={step}
|
||||
defaultValue={step}
|
||||
onSearch={setStep}
|
||||
/>
|
||||
</Column>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import { Text, DataTable, DataColumn } from '@umami/react-zen';
|
||||
import { useMessages, useResultQuery, useFormat, useFields } from '@/components/hooks';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { formatShortTime } from '@/lib/format';
|
||||
|
||||
export interface BreakdownProps {
|
||||
websiteId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
parameters: {
|
||||
fields: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function Breakdown({ websiteId, parameters, startDate, endDate }: BreakdownProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatValue } = useFormat();
|
||||
const { fields } = useFields();
|
||||
const { data, error, isLoading } = useResultQuery<any>(
|
||||
'breakdown',
|
||||
{
|
||||
websiteId,
|
||||
dateRange: {
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
parameters,
|
||||
},
|
||||
{ enabled: !!parameters.fields.length },
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingPanel isEmpty={!data?.length} isLoading={isLoading} error={error}>
|
||||
<DataTable data={data}>
|
||||
{parameters?.fields.map(field => {
|
||||
return (
|
||||
<DataColumn key={field} id={field} label={fields.find(f => f.name === field)?.label}>
|
||||
{row => {
|
||||
const value = formatValue(row[field], field);
|
||||
return (
|
||||
<Text truncate title={value}>
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
</DataColumn>
|
||||
);
|
||||
})}
|
||||
<DataColumn id="views" label={formatMessage(labels.views)} align="end">
|
||||
{row => row?.['views']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
|
||||
{row => row?.['visits']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
|
||||
{row => row?.['visitors']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end">
|
||||
{row => {
|
||||
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
|
||||
return Math.round(+n) + '%';
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="visitDuration" label={formatMessage(labels.visitDuration)} align="end">
|
||||
{row => {
|
||||
const n = (row?.['totaltime'] / row?.['visits']) * 100;
|
||||
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
|
||||
}}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
'use client';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
Button,
|
||||
Column,
|
||||
Box,
|
||||
Grid,
|
||||
Text,
|
||||
Icon,
|
||||
Popover,
|
||||
DialogTrigger,
|
||||
} from '@umami/react-zen';
|
||||
import { useDateRange, useMessages, useFields } from '@/components/hooks';
|
||||
import { SquarePlus, Chevron } from '@/components/icons';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { Breakdown } from './Breakdown';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
|
||||
export function BreakdownPage({ websiteId }: { websiteId: string }) {
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange(websiteId);
|
||||
const [fields, setFields] = useState([]);
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<Box>
|
||||
<FieldsButton value={fields} onChange={setFields} />
|
||||
</Box>
|
||||
<Panel height="900px" overflow="auto" allowFullscreen>
|
||||
<Breakdown
|
||||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
parameters={{ fields }}
|
||||
/>
|
||||
</Panel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const FieldsButton = ({ value, onChange }) => {
|
||||
const [selected, setSelected] = useState(value);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { fields } = useFields();
|
||||
|
||||
const handleChange = value => {
|
||||
setSelected(value);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
setIsOpen(false);
|
||||
onChange?.(selected);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setSelected(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button variant="quiet" onPress={() => setIsOpen(!isOpen)}>
|
||||
<Icon>
|
||||
<SquarePlus />
|
||||
</Icon>
|
||||
<Text>Fields</Text>
|
||||
<Icon rotate={90}>
|
||||
<Chevron />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popover placement="bottom start" isOpen={isOpen}>
|
||||
<Column width="300px" padding="2" border borderRadius shadow="3" backgroundColor gap>
|
||||
<List value={selected} onChange={handleChange} selectionMode="multiple">
|
||||
{fields.map(({ name, label }) => {
|
||||
return (
|
||||
<ListItem key={name} id={name}>
|
||||
{label}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
<Grid columns="1fr 1fr" gap>
|
||||
<Button onPress={handleClose}>{formatMessage(labels.cancel)}</Button>
|
||||
<Button onPress={handleApply} variant="primary">
|
||||
{formatMessage(labels.apply)}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Column>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { Metadata } from 'next';
|
||||
import { InsightsPage } from './InsightsPage';
|
||||
import { BreakdownPage } from './BreakdownPage';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||
const { websiteId } = await params;
|
||||
|
||||
return <InsightsPage websiteId={websiteId} />;
|
||||
return <BreakdownPage websiteId={websiteId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -24,7 +24,7 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
|
|||
<Grid columns="1fr 1fr" gap>
|
||||
{result?.data?.map((report: any) => (
|
||||
<Panel key={report.id}>
|
||||
<Goal {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
|
||||
<Goal {...report} startDate={startDate} endDate={endDate} />
|
||||
</Panel>
|
||||
))}
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Grid, Row, Column, Text, Loading, Icon } from '@umami/react-zen';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { Users } from '@/components/icons';
|
||||
import { useMessages, useLocale, useResultQuery } from '@/components/hooks';
|
||||
import { formatDate } from '@/lib/date';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
|
||||
const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28];
|
||||
|
||||
export interface AttributionProps {
|
||||
websiteId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
days?: number[];
|
||||
}
|
||||
|
||||
export function Insights({ websiteId, days = DAYS, startDate, endDate }: AttributionProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const { data, error, isLoading } = useResultQuery<any>('insights', {
|
||||
websiteId,
|
||||
dateRange: {
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
parameters: {
|
||||
days,
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading position="page" />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
const rows = data.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => {
|
||||
const { date, visitors, day } = row;
|
||||
if (day === 0) {
|
||||
return arr.concat({
|
||||
date,
|
||||
visitors,
|
||||
records: days
|
||||
.reduce((arr, day) => {
|
||||
arr[day] = data.find(x => x.date === date && x.day === day);
|
||||
return arr;
|
||||
}, [])
|
||||
.filter(n => n),
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}, []);
|
||||
|
||||
const totalDays = rows.length;
|
||||
|
||||
return (
|
||||
<LoadingPanel isEmpty={!data?.length} isLoading={isLoading} error={error}>
|
||||
<Panel allowFullscreen height="900px">
|
||||
<Column gap="1" width="100%" overflow="auto">
|
||||
<Grid
|
||||
columns="120px repeat(10, 100px)"
|
||||
alignItems="center"
|
||||
gap="1"
|
||||
height="50px"
|
||||
autoFlow="column"
|
||||
>
|
||||
<Column>
|
||||
<Text weight="bold">{formatMessage(labels.cohort)}</Text>
|
||||
</Column>
|
||||
{days.map(n => (
|
||||
<Column key={n}>
|
||||
<Text weight="bold" align="center" wrap="nowrap">
|
||||
{formatMessage(labels.day)} {n}
|
||||
</Text>
|
||||
</Column>
|
||||
))}
|
||||
</Grid>
|
||||
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
|
||||
return (
|
||||
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column">
|
||||
<Column justifyContent="center" gap="1">
|
||||
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Users />
|
||||
</Icon>
|
||||
<Text>{formatLongNumber(visitors)}</Text>
|
||||
</Row>
|
||||
</Column>
|
||||
{days.map(day => {
|
||||
if (totalDays - rowIndex < day) {
|
||||
return null;
|
||||
}
|
||||
const percentage = records.filter(a => a.day === day)[0]?.percentage;
|
||||
return (
|
||||
<Cell key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</Cell>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Column>
|
||||
</Panel>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const Cell = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<Column
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
width="100px"
|
||||
height="100px"
|
||||
backgroundColor="2"
|
||||
borderRadius
|
||||
>
|
||||
{children}
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { Insights } from './Insights';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { useDateRange } from '@/components/hooks';
|
||||
|
||||
export function InsightsPage({ websiteId }: { websiteId: string }) {
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange(websiteId);
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<Insights websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { canViewWebsite } from '@/lib/auth';
|
||||
import { unauthorized, json } from '@/lib/response';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { getInsights } from '@/queries';
|
||||
import { getBreakdown } from '@/queries';
|
||||
import { reportResultSchema } from '@/lib/schema';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
|
@ -14,27 +14,17 @@ export async function POST(request: Request) {
|
|||
const {
|
||||
websiteId,
|
||||
dateRange: { startDate, endDate },
|
||||
fields,
|
||||
filters,
|
||||
parameters: { fields },
|
||||
} = body;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const data = await getInsights(websiteId, fields, {
|
||||
...convertFilters(filters),
|
||||
const data = await getBreakdown(websiteId, fields, {
|
||||
startDate: new Date(startDate),
|
||||
endDate: new Date(endDate),
|
||||
});
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
||||
function convertFilters(filters: any[]) {
|
||||
return filters.reduce((obj, filter) => {
|
||||
obj[filter.name] = filter;
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue