mirror of
https://github.com/umami-software/umami.git
synced 2026-02-06 13:47:15 +01:00
Added comparison tables.
This commit is contained in:
parent
626fe14fc2
commit
b7a7d4de4d
18 changed files with 220 additions and 168 deletions
|
|
@ -70,7 +70,7 @@ export function WebsiteMetricsBar({
|
|||
|
||||
const items = [
|
||||
{ label: formatMessage(labels.previousPeriod), value: 'prev' },
|
||||
{ label: formatMessage(labels.yearOverYear), value: 'yoy' },
|
||||
{ label: formatMessage(labels.previousYear), value: 'yoy' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,4 +4,11 @@
|
|||
|
||||
.nav {
|
||||
width: 200px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--base800);
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import SideNav from 'components/layout/SideNav';
|
||||
import { useMessages, useNavigation } from 'components/hooks';
|
||||
import { useDateRange, useMessages, useNavigation } from 'components/hooks';
|
||||
import PagesTable from 'components/metrics/PagesTable';
|
||||
import ReferrersTable from 'components/metrics/ReferrersTable';
|
||||
import BrowsersTable from 'components/metrics/BrowsersTable';
|
||||
|
|
@ -13,11 +14,12 @@ import LanguagesTable from 'components/metrics/LanguagesTable';
|
|||
import EventsTable from 'components/metrics/EventsTable';
|
||||
import QueryParametersTable from 'components/metrics/QueryParametersTable';
|
||||
import { Grid, GridRow } from 'components/layout/Grid';
|
||||
import styles from './WebsiteCompareTables.module.css';
|
||||
import { useContext, useState } from 'react';
|
||||
import MetricsTable from 'components/metrics/MetricsTable';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import { WebsiteContext } from '../WebsiteProvider';
|
||||
import useStore from 'store/websites';
|
||||
import { getCompareDate } from 'lib/date';
|
||||
import { formatNumber } from 'lib/format';
|
||||
import ChangeLabel from 'components/metrics/ChangeLabel';
|
||||
import styles from './WebsiteCompareTables.module.css';
|
||||
|
||||
const views = {
|
||||
url: PagesTable,
|
||||
|
|
@ -36,14 +38,15 @@ const views = {
|
|||
};
|
||||
|
||||
export function WebsiteCompareTables({ websiteId }: { websiteId: string }) {
|
||||
const { domain } = useContext(WebsiteContext);
|
||||
const [data, setData] = useState([]);
|
||||
const { dateRange } = useDateRange(websiteId);
|
||||
const dateCompare = useStore(state => state[websiteId]?.dateCompare);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const {
|
||||
renderUrl,
|
||||
query: { view },
|
||||
} = useNavigation();
|
||||
const Component: typeof MetricsTable = views[view] || (() => null);
|
||||
const Component: typeof MetricsTable = views[view || 'url'] || (() => null);
|
||||
|
||||
const items = [
|
||||
{
|
||||
|
|
@ -108,27 +111,48 @@ export function WebsiteCompareTables({ websiteId }: { websiteId: string }) {
|
|||
},
|
||||
];
|
||||
|
||||
const renderLabel = ({ x, y }, index) => {
|
||||
return (
|
||||
<FilterLink
|
||||
id={view}
|
||||
value={x}
|
||||
label={!x && formatMessage(labels.none)}
|
||||
externalUrl={
|
||||
view === 'url' ? `${domain.startsWith('http') ? domain : `https://${domain}`}${x}` : null
|
||||
}
|
||||
>
|
||||
{y} : {data[index]?.y} !
|
||||
</FilterLink>
|
||||
);
|
||||
const renderChange = ({ x, y }) => {
|
||||
const prev = data.find(d => d.x === x)?.y;
|
||||
const value = y - prev;
|
||||
const change = Math.abs(((y - prev) / prev) * 100);
|
||||
|
||||
return !isNaN(change) && <ChangeLabel value={value}>{formatNumber(change)}%</ChangeLabel>;
|
||||
};
|
||||
|
||||
const { startDate, endDate } = getCompareDate(
|
||||
dateCompare,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
);
|
||||
|
||||
const params = {
|
||||
startAt: startDate.getTime(),
|
||||
endAt: endDate.getTime(),
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid className={styles.container}>
|
||||
<GridRow columns="compare">
|
||||
<SideNav className={styles.nav} items={items} selectedKey={view} shallow={true} />
|
||||
<Component websiteId={websiteId} limit={20} showMore={false} onDataLoad={setData} />
|
||||
<Component websiteId={websiteId} limit={20} showMore={false} renderLabel={renderLabel} />
|
||||
<div>
|
||||
<div className={styles.title}>{formatMessage(labels.previous)}</div>
|
||||
<Component
|
||||
websiteId={websiteId}
|
||||
limit={20}
|
||||
showMore={false}
|
||||
onDataLoad={setData}
|
||||
params={params}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.title}> {formatMessage(labels.current)}</div>
|
||||
<Component
|
||||
websiteId={websiteId}
|
||||
limit={20}
|
||||
showMore={false}
|
||||
renderChange={renderChange}
|
||||
/>
|
||||
</div>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useFilterParams } from '../useFilterParams';
|
|||
|
||||
export function useWebsiteMetrics(
|
||||
websiteId: string,
|
||||
query: { type: string; limit: number; search: string },
|
||||
queryParams: { type: string; limit: number; search: string; startAt?: number; endAt?: number },
|
||||
options?: Omit<UseQueryOptions & { onDataLoad?: (data: any) => void }, 'queryKey' | 'queryFn'>,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
|
|
@ -16,17 +16,17 @@ export function useWebsiteMetrics(
|
|||
{
|
||||
websiteId,
|
||||
...params,
|
||||
...query,
|
||||
...queryParams,
|
||||
},
|
||||
],
|
||||
queryFn: async () => {
|
||||
const filters = { ...params };
|
||||
|
||||
filters[query.type] = undefined;
|
||||
filters[queryParams.type] = undefined;
|
||||
|
||||
const data = await get(`/websites/${websiteId}/metrics`, {
|
||||
...filters,
|
||||
...query,
|
||||
...queryParams,
|
||||
});
|
||||
|
||||
options?.onDataLoad?.(data);
|
||||
|
|
|
|||
|
|
@ -253,8 +253,10 @@ export const labels = defineMessages({
|
|||
defaultMessage: 'Understand how users nagivate through your website.',
|
||||
},
|
||||
compare: { id: 'label.compare', defaultMessage: 'Compare' },
|
||||
current: { id: 'label.current', defaultMessage: 'Current' },
|
||||
previous: { id: 'label.previous', defaultMessage: 'Previous' },
|
||||
previousPeriod: { id: 'label.previous-period', defaultMessage: 'Previous period' },
|
||||
yearOverYear: { id: 'label.year-over-year', defaultMessage: 'Year over year' },
|
||||
previousYear: { id: 'label.previous-year', defaultMessage: 'Previous year' },
|
||||
});
|
||||
|
||||
export const messages = defineMessages({
|
||||
|
|
|
|||
31
src/components/metrics/ChangeLabel.module.css
Normal file
31
src/components/metrics/ChangeLabel.module.css
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 5px;
|
||||
color: var(--base500);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: var(--green700);
|
||||
background: var(--green100);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--red700);
|
||||
background: var(--red100);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: var(--base700);
|
||||
background: var(--base100);
|
||||
}
|
||||
|
||||
.new {
|
||||
color: var(--blue900);
|
||||
background: var(--blue100);
|
||||
}
|
||||
44
src/components/metrics/ChangeLabel.tsx
Normal file
44
src/components/metrics/ChangeLabel.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import classNames from 'classnames';
|
||||
import { Icon, Icons } from 'react-basics';
|
||||
import { ReactNode } from 'react';
|
||||
import styles from './ChangeLabel.module.css';
|
||||
|
||||
export function ChangeLabel({
|
||||
value,
|
||||
size,
|
||||
reverseColors,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
value: number;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
reverseColors?: boolean;
|
||||
showPercentage?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const positive = value * (reverseColors ? -1 : 1) >= 0;
|
||||
const negative = value * (reverseColors ? -1 : 1) < 0;
|
||||
const isNew = isNaN(value);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.label, className, {
|
||||
[styles.positive]: positive,
|
||||
[styles.negative]: negative,
|
||||
[styles.neutral]: value === 0,
|
||||
[styles.new]: isNew,
|
||||
})}
|
||||
title={value.toString()}
|
||||
>
|
||||
{!isNew && (
|
||||
<Icon rotate={value === 0 ? 0 : positive || reverseColors ? -45 : 45} size={size}>
|
||||
<Icons.ArrowRight />
|
||||
</Icon>
|
||||
)}
|
||||
{children || value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangeLabel;
|
||||
|
|
@ -72,9 +72,11 @@
|
|||
}
|
||||
|
||||
.value {
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: end;
|
||||
margin-inline-end: 10px;
|
||||
margin-inline-end: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface ListTableProps {
|
|||
metric?: string;
|
||||
className?: string;
|
||||
renderLabel?: (row: any, index: number) => ReactNode;
|
||||
renderChange?: (row: any, index: number) => ReactNode;
|
||||
animate?: boolean;
|
||||
virtualize?: boolean;
|
||||
showPercentage?: boolean;
|
||||
|
|
@ -27,6 +28,7 @@ export function ListTable({
|
|||
metric,
|
||||
className,
|
||||
renderLabel,
|
||||
renderChange,
|
||||
animate = true,
|
||||
virtualize = false,
|
||||
showPercentage = true,
|
||||
|
|
@ -45,6 +47,7 @@ export function ListTable({
|
|||
percent={percent}
|
||||
animate={animate && !virtualize}
|
||||
showPercentage={showPercentage}
|
||||
change={renderChange ? renderChange(row, index) : null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -78,7 +81,7 @@ export function ListTable({
|
|||
);
|
||||
}
|
||||
|
||||
const AnimatedRow = ({ label, value = 0, percent, animate, showPercentage = true }) => {
|
||||
const AnimatedRow = ({ label, value = 0, percent, change, animate, showPercentage = true }) => {
|
||||
const props = useSpring({
|
||||
width: percent,
|
||||
y: value,
|
||||
|
|
@ -90,6 +93,7 @@ const AnimatedRow = ({ label, value = 0, percent, animate, showPercentage = true
|
|||
<div className={styles.row}>
|
||||
<div className={styles.label}>{label}</div>
|
||||
<div className={styles.value}>
|
||||
{change}
|
||||
<animated.div className={styles.value} title={props?.y as any}>
|
||||
{props.y?.to(formatLongNumber)}
|
||||
</animated.div>
|
||||
|
|
|
|||
|
|
@ -35,29 +35,3 @@
|
|||
white-space: nowrap;
|
||||
color: var(--base800);
|
||||
}
|
||||
|
||||
.change {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 5px;
|
||||
color: var(--base500);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.change.positive {
|
||||
color: var(--green700);
|
||||
background: var(--green100);
|
||||
}
|
||||
|
||||
.change.negative {
|
||||
color: var(--red700);
|
||||
background: var(--red100);
|
||||
}
|
||||
|
||||
.hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import classNames from 'classnames';
|
||||
import { Icon, Icons } from 'react-basics';
|
||||
import { useSpring, animated } from '@react-spring/web';
|
||||
import { formatNumber } from 'lib/format';
|
||||
import ChangeLabel from 'components/metrics/ChangeLabel';
|
||||
import styles from './MetricCard.module.css';
|
||||
|
||||
export interface MetricCardProps {
|
||||
|
|
@ -31,30 +31,19 @@ export const MetricCard = ({
|
|||
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
|
||||
const changeProps = useSpring({ x: Number(change) || 0, from: { x: 0 } });
|
||||
const prevProps = useSpring({ x: Number(value - change) || 0, from: { x: 0 } });
|
||||
const positive = change * (reverseColors ? -1 : 1) >= 0;
|
||||
const negative = change * (reverseColors ? -1 : 1) < 0;
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.card, className, showPrevious && styles.compare)}>
|
||||
{showLabel && <div className={styles.label}>{label}</div>}
|
||||
<animated.div className={styles.value} title={props?.x as any}>
|
||||
<animated.div className={styles.value} title={value.toString()}>
|
||||
{props?.x?.to(x => format(x))}
|
||||
</animated.div>
|
||||
{showChange && (
|
||||
<div
|
||||
className={classNames(styles.change, {
|
||||
[styles.positive]: positive,
|
||||
[styles.negative]: negative,
|
||||
[styles.hide]: ~~change === 0,
|
||||
})}
|
||||
>
|
||||
<Icon rotate={positive || reverseColors ? -45 : 45} size={showPrevious ? 'md' : 'xs'}>
|
||||
<Icons.ArrowRight />
|
||||
</Icon>
|
||||
<animated.span title={changeProps?.x as any}>
|
||||
<ChangeLabel className={styles.change} value={change} reverseColors={reverseColors}>
|
||||
<animated.span title={change.toString()}>
|
||||
{changeProps?.x?.to(x => format(Math.abs(x)))}
|
||||
</animated.span>
|
||||
</div>
|
||||
</ChangeLabel>
|
||||
)}
|
||||
{showPrevious && (
|
||||
<animated.div className={classNames(styles.value, styles.prev)} title={prevProps?.x as any}>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface MetricsTableProps extends ListTableProps {
|
|||
onSearch?: (search: string) => void;
|
||||
allowSearch?: boolean;
|
||||
showMore?: boolean;
|
||||
params?: { [key: string]: any };
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ export function MetricsTable({
|
|||
delay = null,
|
||||
allowSearch = false,
|
||||
showMore = true,
|
||||
params,
|
||||
children,
|
||||
...props
|
||||
}: MetricsTableProps) {
|
||||
|
|
@ -51,7 +53,7 @@ export function MetricsTable({
|
|||
|
||||
const { data, isLoading, isFetched, error } = useWebsiteMetrics(
|
||||
websiteId,
|
||||
{ type, limit, search },
|
||||
{ type, limit, search, ...params },
|
||||
{
|
||||
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
|
||||
onDataLoad,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function PagesTable({ allowFilter, ...props }: PagesTableProps) {
|
|||
type={view}
|
||||
metric={formatMessage(labels.views)}
|
||||
dataFilter={emptyFilter}
|
||||
renderLabel={props.renderLabel || renderLink}
|
||||
renderLabel={renderLink}
|
||||
>
|
||||
{allowFilter && <FilterButtons items={buttons} selectedKey={view} onSelect={handleSelect} />}
|
||||
</MetricsTable>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function PageviewsChart({ data, unit, isLoading, ...props }: PageviewsCha
|
|||
? [
|
||||
{
|
||||
type: 'line',
|
||||
label: `${formatMessage(labels.visits)} (VS)`,
|
||||
label: `${formatMessage(labels.visits)} (${formatMessage(labels.previous)})`,
|
||||
data: data.compare.pageviews,
|
||||
borderWidth: 2,
|
||||
backgroundColor: '#8601B0',
|
||||
|
|
@ -55,7 +55,7 @@ export function PageviewsChart({ data, unit, isLoading, ...props }: PageviewsCha
|
|||
},
|
||||
{
|
||||
type: 'line',
|
||||
label: `${formatMessage(labels.visitors)} (VS)`,
|
||||
label: `${formatMessage(labels.visitors)} (${formatMessage(labels.previous)})`,
|
||||
data: data.compare.sessions,
|
||||
borderWidth: 2,
|
||||
backgroundColor: '#f15bb5',
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
|
|||
|
||||
if (req.method === 'POST') {
|
||||
if (!process.env.DISABLE_BOT_CHECK && isbot(req.headers['user-agent'])) {
|
||||
return ok(res);
|
||||
return ok(res, { beep: 'boop' });
|
||||
}
|
||||
|
||||
await useValidate(schema, req, res);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue