mirror of
https://github.com/umami-software/umami.git
synced 2026-02-11 08:07:12 +01:00
Update Retention report.
This commit is contained in:
parent
184a387ecd
commit
ee8750d9df
22 changed files with 214 additions and 280 deletions
|
|
@ -1,17 +0,0 @@
|
||||||
.container {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-self: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
line-height: 60px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
.container {
|
|
||||||
margin-bottom: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
width: 200px;
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
color: var(--base800);
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
@ -2,23 +2,27 @@ import { Column, Row } from '@umami/react-zen';
|
||||||
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
|
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
|
||||||
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
||||||
import { FilterBar } from '@/components/input/FilterBar';
|
import { FilterBar } from '@/components/input/FilterBar';
|
||||||
|
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
|
||||||
|
|
||||||
export function WebsiteControls({
|
export function WebsiteControls({
|
||||||
websiteId,
|
websiteId,
|
||||||
allowFilter = true,
|
allowFilter = true,
|
||||||
|
allowDateFilter = true,
|
||||||
|
allowMonthFilter,
|
||||||
allowCompare,
|
allowCompare,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
allowFilter?: boolean;
|
allowFilter?: boolean;
|
||||||
allowCompare?: boolean;
|
allowCompare?: boolean;
|
||||||
|
allowDateFilter?: boolean;
|
||||||
|
allowMonthFilter?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<Row alignItems="center" justifyContent="space-between" gap="3">
|
<Row alignItems="center" justifyContent="space-between" gap="3">
|
||||||
{allowFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
{allowFilter && <WebsiteFilterButton websiteId={websiteId} />}
|
||||||
<Row alignItems="center" gap="3">
|
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />}
|
||||||
<WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />
|
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
|
||||||
</Row>
|
|
||||||
</Row>
|
</Row>
|
||||||
<FilterBar />
|
<FilterBar />
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,17 @@ export function WebsiteHeader() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} showBorder={false}>
|
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} showBorder={false}>
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap="6">
|
||||||
<ActiveUsers websiteId={website.id} />
|
<ActiveUsers websiteId={website.id} />
|
||||||
<ShareButton websiteId={website.id} shareId={website.shareId} />
|
<Row alignItems="center" gap>
|
||||||
<LinkButton href={renderUrl(`/settings/websites/${website.id}`)}>
|
<ShareButton websiteId={website.id} shareId={website.shareId} />
|
||||||
<Icon>
|
<LinkButton href={renderUrl(`/settings/websites/${website.id}`)}>
|
||||||
<Edit />
|
<Icon>
|
||||||
</Icon>
|
<Edit />
|
||||||
<Text>Edit</Text>
|
</Icon>
|
||||||
</LinkButton>
|
<Text>Edit</Text>
|
||||||
|
</LinkButton>
|
||||||
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
.container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--base50);
|
|
||||||
z-index: var(--z-index-above);
|
|
||||||
min-height: 120px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex-basis: 100%;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown {
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 1200px) {
|
|
||||||
.container {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 992px) {
|
|
||||||
.sticky {
|
|
||||||
position: sticky;
|
|
||||||
top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.isSticky {
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid var(--base300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,28 +11,30 @@ export function EventsMetricsBar({ websiteId }: { websiteId: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
||||||
<MetricsBar>
|
{data && (
|
||||||
<MetricCard
|
<MetricsBar>
|
||||||
value={data?.visitors?.value}
|
<MetricCard
|
||||||
label={formatMessage(labels.visitors)}
|
value={data?.visitors?.value}
|
||||||
formatValue={formatLongNumber}
|
label={formatMessage(labels.visitors)}
|
||||||
/>
|
formatValue={formatLongNumber}
|
||||||
<MetricCard
|
/>
|
||||||
value={data?.visits?.value}
|
<MetricCard
|
||||||
label={formatMessage(labels.visits)}
|
value={data?.visits?.value}
|
||||||
formatValue={formatLongNumber}
|
label={formatMessage(labels.visits)}
|
||||||
/>
|
formatValue={formatLongNumber}
|
||||||
<MetricCard
|
/>
|
||||||
value={data?.pageviews?.value}
|
<MetricCard
|
||||||
label={formatMessage(labels.views)}
|
value={data?.pageviews?.value}
|
||||||
formatValue={formatLongNumber}
|
label={formatMessage(labels.views)}
|
||||||
/>
|
formatValue={formatLongNumber}
|
||||||
<MetricCard
|
/>
|
||||||
value={data?.events?.value}
|
<MetricCard
|
||||||
label={formatMessage(labels.events)}
|
value={data?.events?.value}
|
||||||
formatValue={formatLongNumber}
|
label={formatMessage(labels.events)}
|
||||||
/>
|
formatValue={formatLongNumber}
|
||||||
</MetricsBar>
|
/>
|
||||||
|
</MetricsBar>
|
||||||
|
)}
|
||||||
</LoadingPanel>
|
</LoadingPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
||||||
country: string;
|
country: string;
|
||||||
device: string;
|
device: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { __type, eventName, urlPath: url, browser, os, country, device } = log;
|
const { __type, eventName, urlPath, browser, os, country, device } = log;
|
||||||
|
|
||||||
if (__type === TYPE_EVENT) {
|
if (__type === TYPE_EVENT) {
|
||||||
return formatMessage(messages.eventLog, {
|
return formatMessage(messages.eventLog, {
|
||||||
|
|
@ -75,12 +75,12 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
||||||
url: (
|
url: (
|
||||||
<a
|
<a
|
||||||
key="a"
|
key="a"
|
||||||
href={`//${website?.domain}${url}`}
|
href={`//${website?.domain}${urlPath}`}
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
{url}
|
{urlPath}
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
@ -89,12 +89,12 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
||||||
if (__type === TYPE_PAGEVIEW) {
|
if (__type === TYPE_PAGEVIEW) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={`//${website?.domain}${url}`}
|
href={`//${website?.domain}${urlPath}`}
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
{url}
|
{urlPath}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,13 @@ export function ReportsNav({ websiteId }: { websiteId: string }) {
|
||||||
const isSelected = selected === id;
|
const isSelected = selected === id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={id} href={renderUrl(`/websites/${websiteId}/reports${path}`)}>
|
<Link
|
||||||
|
key={id}
|
||||||
|
href={renderUrl(
|
||||||
|
`/websites/${websiteId}/reports${path}`,
|
||||||
|
path === '/retention' ? false : null,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<NavMenuItem isSelected={isSelected}>
|
<NavMenuItem isSelected={isSelected}>
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap>
|
||||||
<Icon>{icon}</Icon>
|
<Icon>{icon}</Icon>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Grid, Row, Column, Text, Icon } from '@umami/react-zen';
|
import { Grid, Row, Column, Text, Icon } from '@umami/react-zen';
|
||||||
import { Users } from '@/components/icons';
|
import { Users } from '@/components/icons';
|
||||||
import { useMessages, useLocale, useResultQuery } from '@/components/hooks';
|
import { useMessages, useLocale, useResultQuery, useTimezone } from '@/components/hooks';
|
||||||
import { formatDate } from '@/lib/date';
|
import { formatDate } from '@/lib/date';
|
||||||
import { formatLongNumber } from '@/lib/format';
|
import { formatLongNumber } from '@/lib/format';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
|
@ -19,8 +19,10 @@ export interface RetentionProps {
|
||||||
export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
|
export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
|
const { timezone } = useTimezone();
|
||||||
const { data, error, isLoading } = useResultQuery<any>('retention', {
|
const { data, error, isLoading } = useResultQuery<any>('retention', {
|
||||||
websiteId,
|
websiteId,
|
||||||
|
timezone,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
|
@ -51,54 +53,56 @@ export function Retention({ websiteId, days = DAYS, startDate, endDate }: Retent
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||||
<Panel allowFullscreen height="900px">
|
{data && (
|
||||||
<Column gap="1" width="100%" overflow="auto">
|
<Panel allowFullscreen height="900px">
|
||||||
<Grid
|
<Column gap="1" width="100%" overflow="auto">
|
||||||
columns="120px repeat(10, 100px)"
|
<Grid
|
||||||
alignItems="center"
|
columns="120px repeat(10, 100px)"
|
||||||
gap="1"
|
alignItems="center"
|
||||||
height="50px"
|
gap="1"
|
||||||
autoFlow="column"
|
height="50px"
|
||||||
>
|
autoFlow="column"
|
||||||
<Column>
|
>
|
||||||
<Text weight="bold" align="center">
|
<Column>
|
||||||
{formatMessage(labels.cohort)}
|
<Text weight="bold" align="center">
|
||||||
</Text>
|
{formatMessage(labels.cohort)}
|
||||||
</Column>
|
|
||||||
{days.map(n => (
|
|
||||||
<Column key={n}>
|
|
||||||
<Text weight="bold" align="center" wrap="nowrap">
|
|
||||||
{formatMessage(labels.day)} {n}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Column>
|
</Column>
|
||||||
))}
|
{days.map(n => (
|
||||||
</Grid>
|
<Column key={n}>
|
||||||
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
|
<Text weight="bold" align="center" wrap="nowrap">
|
||||||
return (
|
{formatMessage(labels.day)} {n}
|
||||||
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column">
|
</Text>
|
||||||
<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>
|
</Column>
|
||||||
{days.map(day => {
|
))}
|
||||||
if (totalDays - rowIndex < day) {
|
</Grid>
|
||||||
return null;
|
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
|
||||||
}
|
return (
|
||||||
const percentage = records.filter(a => a.day === day)[0]?.percentage;
|
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column">
|
||||||
return (
|
<Column justifyContent="center" gap="1">
|
||||||
<Cell key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</Cell>
|
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
|
||||||
);
|
<Row alignItems="center" gap>
|
||||||
})}
|
<Icon>
|
||||||
</Grid>
|
<Users />
|
||||||
);
|
</Icon>
|
||||||
})}
|
<Text>{formatLongNumber(visitors)}</Text>
|
||||||
</Column>
|
</Row>
|
||||||
</Panel>
|
</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>
|
</LoadingPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,20 @@ import { Column } from '@umami/react-zen';
|
||||||
import { Retention } from './Retention';
|
import { Retention } from './Retention';
|
||||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
import { useDateRange } from '@/components/hooks';
|
import { useDateRange } from '@/components/hooks';
|
||||||
|
import { endOfMonth, startOfMonth } from 'date-fns';
|
||||||
|
|
||||||
export function RetentionPage({ websiteId }: { websiteId: string }) {
|
export function RetentionPage({ websiteId }: { websiteId: string }) {
|
||||||
const {
|
const {
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate },
|
||||||
} = useDateRange(websiteId);
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
|
const monthStartDate = startOfMonth(startDate);
|
||||||
|
const monthEndDate = endOfMonth(startDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} allowDateFilter={false} allowMonthFilter />
|
||||||
<Retention websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
<Retention websiteId={websiteId} startDate={monthStartDate} endDate={monthEndDate} />
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,30 @@ export function SessionsMetricsBar({ websiteId }: { websiteId: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
||||||
<MetricsBar>
|
{data && (
|
||||||
<MetricCard
|
<MetricsBar>
|
||||||
value={data?.visitors?.value}
|
<MetricCard
|
||||||
label={formatMessage(labels.visitors)}
|
value={data?.visitors?.value}
|
||||||
formatValue={formatLongNumber}
|
label={formatMessage(labels.visitors)}
|
||||||
/>
|
formatValue={formatLongNumber}
|
||||||
<MetricCard
|
/>
|
||||||
value={data?.visits?.value}
|
<MetricCard
|
||||||
label={formatMessage(labels.visits)}
|
value={data?.visits?.value}
|
||||||
formatValue={formatLongNumber}
|
label={formatMessage(labels.visits)}
|
||||||
/>
|
formatValue={formatLongNumber}
|
||||||
<MetricCard
|
/>
|
||||||
value={data?.pageviews?.value}
|
<MetricCard
|
||||||
label={formatMessage(labels.views)}
|
value={data?.pageviews?.value}
|
||||||
formatValue={formatLongNumber}
|
label={formatMessage(labels.views)}
|
||||||
/>
|
formatValue={formatLongNumber}
|
||||||
<MetricCard
|
/>
|
||||||
value={data?.countries?.value}
|
<MetricCard
|
||||||
label={formatMessage(labels.countries)}
|
value={data?.countries?.value}
|
||||||
formatValue={formatLongNumber}
|
label={formatMessage(labels.countries)}
|
||||||
/>
|
formatValue={formatLongNumber}
|
||||||
</MetricsBar>
|
/>
|
||||||
|
</MetricsBar>
|
||||||
|
)}
|
||||||
</LoadingPanel>
|
</LoadingPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export async function POST(request: Request) {
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
|
timezone,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
|
@ -23,6 +24,7 @@ export async function POST(request: Request) {
|
||||||
const data = await getRetention(websiteId, {
|
const data = await getRetention(websiteId, {
|
||||||
startDate: new Date(startDate),
|
startDate: new Date(startDate),
|
||||||
endDate: new Date(endDate),
|
endDate: new Date(endDate),
|
||||||
|
timezone,
|
||||||
});
|
});
|
||||||
|
|
||||||
return json(data);
|
return json(data);
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export function Panel({
|
||||||
<Button variant="quiet" onPress={handleFullscreen}>
|
<Button variant="quiet" onPress={handleFullscreen}>
|
||||||
<Icon>{isFullscreen ? <Close /> : <Maximize />}</Icon>
|
<Icon>{isFullscreen ? <Close /> : <Maximize />}</Icon>
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip>{formatMessage(labels.expand)}</Tooltip>
|
<Tooltip>{formatMessage(labels.maximize)}</Tooltip>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function useDateRange(websiteId?: string) {
|
||||||
);
|
);
|
||||||
const dateRange = useMemo(
|
const dateRange = useMemo(
|
||||||
() => (offset ? getOffsetDateRange(dateRangeObject, +offset) : dateRangeObject),
|
() => (offset ? getOffsetDateRange(dateRangeObject, +offset) : dateRangeObject),
|
||||||
[date, offset],
|
[date, offset, websiteConfig],
|
||||||
);
|
);
|
||||||
const dateCompare = useWebsites(state => state[websiteId]?.dateCompare || DEFAULT_DATE_COMPARE);
|
const dateCompare = useWebsites(state => state[websiteId]?.dateCompare || DEFAULT_DATE_COMPARE);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export function useFilterParams(websiteId: string) {
|
||||||
const { timezone, toUtc } = useTimezone();
|
const { timezone, toUtc } = useTimezone();
|
||||||
const {
|
const {
|
||||||
query: {
|
query: {
|
||||||
url,
|
path,
|
||||||
referrer,
|
referrer,
|
||||||
title,
|
title,
|
||||||
query,
|
query,
|
||||||
|
|
@ -29,7 +29,7 @@ export function useFilterParams(websiteId: string) {
|
||||||
endAt: +toUtc(endDate),
|
endAt: +toUtc(endDate),
|
||||||
unit,
|
unit,
|
||||||
timezone,
|
timezone,
|
||||||
url,
|
path,
|
||||||
referrer,
|
referrer,
|
||||||
title,
|
title,
|
||||||
query,
|
query,
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ export function DateFilter({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row width="280px">
|
<Row minWidth="200px">
|
||||||
<Select
|
<Select
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={formatMessage(labels.selectDate)}
|
placeholder={formatMessage(labels.selectDate)}
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,47 @@
|
||||||
import { Row, Text, Icon, Button, MenuTrigger, Popover, Menu, MenuItem } from '@umami/react-zen';
|
import { Row, Select, ListItem } from '@umami/react-zen';
|
||||||
import { startOfMonth, endOfMonth, startOfYear, addMonths, subYears } from 'date-fns';
|
|
||||||
import { Chevron } from '@/components/icons';
|
|
||||||
import { useLocale } from '@/components/hooks';
|
import { useLocale } from '@/components/hooks';
|
||||||
import { formatDate } from '@/lib/date';
|
import { formatDate } from '@/lib/date';
|
||||||
|
|
||||||
export function MonthSelect({ date = new Date(), onChange }) {
|
export function MonthSelect({ date = new Date(), onChange }) {
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const month = formatDate(date, 'MMMM', locale);
|
const month = date.getMonth();
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
// eslint-disable-next-line
|
const months = [...Array(12)].map((_, i) => i);
|
||||||
const handleChange = (close: () => void, date: Date) => {
|
const years = [...Array(10)].map((_, i) => currentYear - i);
|
||||||
onChange(`range:${startOfMonth(date).getTime()}:${endOfMonth(date).getTime()}`);
|
|
||||||
close();
|
const handleMonthChange = (month: number) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setMonth(month);
|
||||||
|
onChange?.(d);
|
||||||
|
};
|
||||||
|
const handleYearChange = (year: number) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setFullYear(year);
|
||||||
|
onChange?.(d);
|
||||||
};
|
};
|
||||||
|
|
||||||
const start = startOfYear(date);
|
|
||||||
const months: Date[] = [];
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
months.push(addMonths(start, i));
|
|
||||||
}
|
|
||||||
const years: number[] = [];
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
years.push(subYears(start, 10 - i).getFullYear());
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row>
|
<Row gap>
|
||||||
<MenuTrigger>
|
<Select value={month} onChange={handleMonthChange}>
|
||||||
<Button variant="quiet">
|
{months.map(m => {
|
||||||
<Text>{month}</Text>
|
return (
|
||||||
<Icon size="sm">
|
<ListItem id={m} key={m}>
|
||||||
<Chevron />
|
{formatDate(new Date(year, m, 1), 'MMMM', locale)}
|
||||||
</Icon>
|
</ListItem>
|
||||||
</Button>
|
);
|
||||||
<Popover>
|
})}
|
||||||
<Menu>
|
</Select>
|
||||||
{months.map(month => {
|
<Select value={year} onChange={handleYearChange}>
|
||||||
return (
|
{years.map(y => {
|
||||||
<MenuItem key={month.toString()} id={month.toString()}>
|
return (
|
||||||
{month.getDay()}
|
<ListItem id={y} key={y}>
|
||||||
</MenuItem>
|
{y}
|
||||||
);
|
</ListItem>
|
||||||
})}
|
);
|
||||||
</Menu>
|
})}
|
||||||
</Popover>
|
</Select>
|
||||||
</MenuTrigger>
|
|
||||||
<MenuTrigger>
|
|
||||||
<Button variant="quiet">
|
|
||||||
<Text>{year}</Text>
|
|
||||||
<Icon size="sm">
|
|
||||||
<Chevron />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
<Popover>
|
|
||||||
<Menu>
|
|
||||||
{years.map(year => {
|
|
||||||
return (
|
|
||||||
<MenuItem key={year} id={year}>
|
|
||||||
{year}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Menu>
|
|
||||||
</Popover>
|
|
||||||
</MenuTrigger>
|
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function WebsiteDateFilter({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gap="3">
|
<Row gap>
|
||||||
{showButtons && !isAllTime && !isCustomRange && (
|
{showButtons && !isAllTime && !isCustomRange && (
|
||||||
<Row gap="1">
|
<Row gap="1">
|
||||||
<Button onPress={() => handleIncrement(-1)} variant="outline">
|
<Button onPress={() => handleIncrement(-1)} variant="outline">
|
||||||
|
|
|
||||||
17
src/components/input/WebsiteMonthSelect.tsx
Normal file
17
src/components/input/WebsiteMonthSelect.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { useDateRange } from '@/components/hooks';
|
||||||
|
import { dateToRangeValue } from '@/lib/date';
|
||||||
|
import { MonthSelect } from './MonthSelect';
|
||||||
|
|
||||||
|
export function WebsiteMonthSelect({ websiteId }: { websiteId: string }) {
|
||||||
|
const {
|
||||||
|
dateRange: { startDate },
|
||||||
|
saveDateRange,
|
||||||
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
|
const handleMonthSelect = (date: Date) => {
|
||||||
|
const range = dateToRangeValue(date);
|
||||||
|
saveDateRange(range);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <MonthSelect date={startDate} onChange={handleMonthSelect} />;
|
||||||
|
}
|
||||||
|
|
@ -271,7 +271,7 @@ export const labels = defineMessages({
|
||||||
defaultMessage: 'Track your campaigns through UTM parameters.',
|
defaultMessage: 'Track your campaigns through UTM parameters.',
|
||||||
},
|
},
|
||||||
conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion step' },
|
conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion step' },
|
||||||
conversionRate: { id: 'label.conversion-ratep', defaultMessage: 'Conversion rate' },
|
conversionRate: { id: 'label.conversion-rate', defaultMessage: 'Conversion rate' },
|
||||||
steps: { id: 'label.steps', defaultMessage: 'Steps' },
|
steps: { id: 'label.steps', defaultMessage: 'Steps' },
|
||||||
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
|
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
|
||||||
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
|
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
|
||||||
|
|
@ -325,7 +325,7 @@ export const labels = defineMessages({
|
||||||
pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
|
pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
|
||||||
addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
|
addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
|
||||||
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
|
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
|
||||||
expand: { id: 'label.expand', defaultMessage: 'Expand' },
|
maximize: { id: 'label.maximize', defaultMessage: 'Maximize' },
|
||||||
remaining: { id: 'label.remaining', defaultMessage: 'Remaining' },
|
remaining: { id: 'label.remaining', defaultMessage: 'Remaining' },
|
||||||
conversion: { id: 'label.conversion', defaultMessage: 'Conversion' },
|
conversion: { id: 'label.conversion', defaultMessage: 'Conversion' },
|
||||||
firstClick: { id: 'label.first-click', defaultMessage: 'First click' },
|
firstClick: { id: 'label.first-click', defaultMessage: 'First click' },
|
||||||
|
|
|
||||||
|
|
@ -349,3 +349,7 @@ export function generateTimeSeries(
|
||||||
return { x: t, d: x, y: y ?? null };
|
return { x: t, d: x, y: y ?? null };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function dateToRangeValue(date: Date) {
|
||||||
|
return `range:${startOfMonth(date).getTime()}:${endOfMonth(date).getTime()}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,15 @@ import prisma from '@/lib/prisma';
|
||||||
export interface RetentionCriteria {
|
export interface RetentionCriteria {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetentionResult {
|
||||||
|
date: string;
|
||||||
|
day: number;
|
||||||
|
visitors: number;
|
||||||
|
returnVisitors: number;
|
||||||
|
percentage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRetention(...args: [websiteId: string, criteria: RetentionCriteria]) {
|
export async function getRetention(...args: [websiteId: string, criteria: RetentionCriteria]) {
|
||||||
|
|
@ -17,16 +26,8 @@ export async function getRetention(...args: [websiteId: string, criteria: Retent
|
||||||
async function relationalQuery(
|
async function relationalQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
criteria: RetentionCriteria,
|
criteria: RetentionCriteria,
|
||||||
): Promise<
|
): Promise<RetentionResult[]> {
|
||||||
{
|
const { startDate, endDate, timezone } = criteria;
|
||||||
date: string;
|
|
||||||
day: number;
|
|
||||||
visitors: number;
|
|
||||||
returnVisitors: number;
|
|
||||||
percentage: number;
|
|
||||||
}[]
|
|
||||||
> {
|
|
||||||
const { startDate, endDate } = criteria;
|
|
||||||
const { getDateSQL, getDayDiffQuery, getCastColumnQuery, rawQuery } = prisma;
|
const { getDateSQL, getDayDiffQuery, getCastColumnQuery, rawQuery } = prisma;
|
||||||
const unit = 'day';
|
const unit = 'day';
|
||||||
|
|
||||||
|
|
@ -42,7 +43,7 @@ async function relationalQuery(
|
||||||
user_activities AS (
|
user_activities AS (
|
||||||
select distinct
|
select distinct
|
||||||
w.session_id,
|
w.session_id,
|
||||||
${getDayDiffQuery(getDateSQL('created_at', unit), 'c.cohort_date')} as day_number
|
${getDayDiffQuery(getDateSQL('created_at', unit, timezone), 'c.cohort_date')} as day_number
|
||||||
from website_event w
|
from website_event w
|
||||||
join cohort_items c
|
join cohort_items c
|
||||||
on w.session_id = c.session_id
|
on w.session_id = c.session_id
|
||||||
|
|
@ -88,16 +89,8 @@ async function relationalQuery(
|
||||||
async function clickhouseQuery(
|
async function clickhouseQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
criteria: RetentionCriteria,
|
criteria: RetentionCriteria,
|
||||||
): Promise<
|
): Promise<RetentionResult[]> {
|
||||||
{
|
const { startDate, endDate, timezone } = criteria;
|
||||||
date: string;
|
|
||||||
day: number;
|
|
||||||
visitors: number;
|
|
||||||
returnVisitors: number;
|
|
||||||
percentage: number;
|
|
||||||
}[]
|
|
||||||
> {
|
|
||||||
const { startDate, endDate } = criteria;
|
|
||||||
const { getDateSQL, rawQuery } = clickhouse;
|
const { getDateSQL, rawQuery } = clickhouse;
|
||||||
const unit = 'day';
|
const unit = 'day';
|
||||||
|
|
||||||
|
|
@ -105,7 +98,7 @@ async function clickhouseQuery(
|
||||||
`
|
`
|
||||||
WITH cohort_items AS (
|
WITH cohort_items AS (
|
||||||
select
|
select
|
||||||
min(${getDateSQL('created_at', unit)}) as cohort_date,
|
min(${getDateSQL('created_at', unit, timezone)}) as cohort_date,
|
||||||
session_id
|
session_id
|
||||||
from website_event
|
from website_event
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue