Compare commits

...

3 commits

Author SHA1 Message Date
Francis Cao
355fa1e9a9 increase minimum unit for day. Last 6 months includes current month
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-23 11:42:12 -08:00
Francis Cao
a2f0066c42 clear unit param on nav, fix more import, fix NaN animation error 2026-01-23 11:19:19 -08:00
Francis Cao
e73432dd26 Add unit select and backend implementation. Fix compare for websitestats. Remove unused params from stats, metrics, weekly 2026-01-23 10:08:47 -08:00
17 changed files with 144 additions and 61 deletions

View file

@ -21,30 +21,28 @@ export function WebsiteChart({
const { pageviews, sessions, compare } = (data || {}) as any; const { pageviews, sessions, compare } = (data || {}) as any;
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (data) { if (!data) {
const result = { return { pageviews: [], sessions: [] };
pageviews, }
sessions,
};
if (compare) { return {
result.compare = { pageviews,
pageviews: result.pageviews.map(({ x }, i) => ({ sessions,
...(compare && {
compare: {
pageviews: pageviews.map(({ x }, i) => ({
x, x,
y: compare.pageviews[i]?.y, y: compare.pageviews[i]?.y,
d: compare.pageviews[i]?.x, d: compare.pageviews[i]?.x,
})), })),
sessions: result.sessions.map(({ x }, i) => ({ sessions: sessions.map(({ x }, i) => ({
x, x,
y: compare.sessions[i]?.y, y: compare.sessions[i]?.y,
d: compare.sessions[i]?.x, d: compare.sessions[i]?.x,
})), })),
}; },
} }),
};
return result;
}
return { pageviews: [], sessions: [] };
}, [data, startDate, endDate, unit]); }, [data, startDate, endDate, unit]);
return ( return (

View file

@ -10,7 +10,7 @@ import {
} from '@umami/react-zen'; } from '@umami/react-zen';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { Edit, More, Share } from '@/components/icons'; import { Edit, MoreHorizontal, Share } from '@/components/icons';
export function WebsiteMenu({ websiteId }: { websiteId: string }) { export function WebsiteMenu({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) {
<MenuTrigger> <MenuTrigger>
<Button variant="quiet"> <Button variant="quiet">
<Icon> <Icon>
<More /> <MoreHorizontal />
</Icon> </Icon>
</Button> </Button>
<Popover placement="bottom"> <Popover placement="bottom">

View file

@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
export function WebsiteMetricsBar({ export function WebsiteMetricsBar({
websiteId, websiteId,
compareMode,
}: { }: {
websiteId: string; websiteId: string;
showChange?: boolean; showChange?: boolean;
compareMode?: boolean; compareMode?: boolean;
}) { }) {
const { isAllTime } = useDateRange(); const { isAllTime, dateCompare } = useDateRange();
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId); const { data, isLoading, isFetching, error } = useWebsiteStatsQuery({
websiteId,
compare: compareMode ? dateCompare?.compare : undefined,
});
const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {}; const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};

View file

@ -29,6 +29,7 @@ export function WebsiteNav({
event: undefined, event: undefined,
compare: undefined, compare: undefined,
view: undefined, view: undefined,
unit: undefined,
}); });
const items = [ const items = [

View file

@ -1,7 +1,8 @@
'use client'; 'use client';
import { Column } from '@umami/react-zen'; import { Column, Row } from '@umami/react-zen';
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { UnitFilter } from '@/components/input/UnitFilter';
import { WebsiteChart } from './WebsiteChart'; import { WebsiteChart } from './WebsiteChart';
import { WebsiteControls } from './WebsiteControls'; import { WebsiteControls } from './WebsiteControls';
import { WebsiteMetricsBar } from './WebsiteMetricsBar'; import { WebsiteMetricsBar } from './WebsiteMetricsBar';
@ -13,6 +14,9 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<Row justifyContent="end">
<UnitFilter />
</Row>
<WebsiteChart websiteId={websiteId} /> <WebsiteChart websiteId={websiteId} />
</Panel> </Panel>
<WebsitePanels websiteId={websiteId} /> <WebsitePanels websiteId={websiteId} />

View file

@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) {
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} allowCompare={true} /> <WebsiteControls websiteId={websiteId} allowCompare={true} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} compareMode={true} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<WebsiteChart websiteId={websiteId} compareMode={true} /> <WebsiteChart websiteId={websiteId} compareMode={true} />
</Panel> </Panel>

View file

@ -31,9 +31,11 @@ export async function GET(
const data = await getWebsiteStats(websiteId, filters); const data = await getWebsiteStats(websiteId, filters);
const compare = filters.compare ?? 'prev'; const { startDate, endDate } = getCompareDate(
filters.compare ?? 'prev',
const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate); filters.startDate,
filters.endDate,
);
const comparison = await getWebsiteStats(websiteId, { const comparison = await getWebsiteStats(websiteId, {
...filters, ...filters,

View file

@ -19,7 +19,7 @@ export function useWebsiteExpandedMetricsQuery(
options?: ReactQueryOptions<WebsiteExpandedMetricsData>, options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteExpandedMetricsData>({ return useQuery<WebsiteExpandedMetricsData>({
@ -29,8 +29,6 @@ export function useWebsiteExpandedMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -39,8 +37,6 @@ export function useWebsiteExpandedMetricsQuery(
get(`/websites/${websiteId}/metrics/expanded`, { get(`/websites/${websiteId}/metrics/expanded`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
options?: ReactQueryOptions<WebsiteMetricsData>, options?: ReactQueryOptions<WebsiteMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteMetricsData>({ return useQuery<WebsiteMetricsData>({
@ -25,8 +25,6 @@ export function useWebsiteMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -35,8 +33,6 @@ export function useWebsiteMetricsQuery(
get(`/websites/${websiteId}/metrics`, { get(`/websites/${websiteId}/metrics`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -1,6 +1,5 @@
import type { UseQueryOptions } from '@tanstack/react-query'; import type { UseQueryOptions } from '@tanstack/react-query';
import { useDateParameters } from '@/components/hooks/useDateParameters'; import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useDateRange } from '@/components/hooks/useDateRange';
import { useApi } from '../useApi'; import { useApi } from '../useApi';
import { useFilterParameters } from '../useFilterParameters'; import { useFilterParameters } from '../useFilterParameters';
@ -20,21 +19,16 @@ export interface WebsiteStatsData {
} }
export function useWebsiteStatsQuery( export function useWebsiteStatsQuery(
websiteId: string, { websiteId, compare }: { websiteId: string; compare?: string },
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>, options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const { compare } = useDateRange();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteStatsData>({ return useQuery<WebsiteStatsData>({
queryKey: [ queryKey: ['websites:stats', { websiteId, compare, startAt, endAt, ...filters }],
'websites:stats', queryFn: () => get(`/websites/${websiteId}/stats`, { compare, startAt, endAt, ...filters }),
{ websiteId, startAt, endAt, unit, timezone, compare, ...filters },
],
queryFn: () =>
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, compare, ...filters }),
enabled: !!websiteId, enabled: !!websiteId,
...options, ...options,
}); });

View file

@ -12,13 +12,12 @@ export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string,
return useQuery({ return useQuery({
queryKey: [ queryKey: [
'sessions', 'sessions',
{ websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, { websiteId, modified, startAt, endAt, timezone, ...params, ...filters },
], ],
queryFn: () => { queryFn: () => {
return get(`/websites/${websiteId}/sessions/weekly`, { return get(`/websites/${websiteId}/sessions/weekly`, {
startAt, startAt,
endAt, endAt,
unit,
timezone, timezone,
...params, ...params,
...filters, ...filters,

View file

@ -7,13 +7,13 @@ import { getItem } from '@/lib/storage';
export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) { export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
const { const {
query: { date = '', offset = 0, compare = 'prev' }, query: { date = '', unit = '', offset = 0, compare = 'prev' },
} = useNavigation(); } = useNavigation();
const { locale } = useLocale(); const { locale } = useLocale();
const dateRange = useMemo(() => { const dateRange = useMemo(() => {
const dateRangeObject = parseDateRange( const dateRangeObject = parseDateRange(
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
unit,
locale, locale,
options.timezone, options.timezone,
); );
@ -21,12 +21,13 @@ export function useDateRange(options: { ignoreOffset?: boolean; timezone?: strin
return !options.ignoreOffset && offset return !options.ignoreOffset && offset
? getOffsetDateRange(dateRangeObject, +offset) ? getOffsetDateRange(dateRangeObject, +offset)
: dateRangeObject; : dateRangeObject;
}, [date, offset, options]); }, [date, unit, offset, options]);
const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate); const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate);
return { return {
date, date,
unit,
offset, offset,
compare, compare,
isAllTime: date.endsWith(`:all`), isAllTime: date.endsWith(`:all`),

View file

@ -0,0 +1,71 @@
import { ListItem, Row, Select } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
import { getItem } from '@/lib/storage';
export function UnitFilter() {
const { formatMessage, labels } = useMessages();
const { router, query, updateParams } = useNavigation();
const DATE_RANGE_UNIT_CONFIG = {
'0week': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'7day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'0month': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'30day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'90day': {
defaultUnit: 'day',
availableUnits: ['day', 'month'],
},
'6month': {
defaultUnit: 'month',
availableUnits: ['month', 'day'],
},
};
const unitConfig =
DATE_RANGE_UNIT_CONFIG[query.date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE];
if (!unitConfig) {
return null;
}
const handleChange = (value: string) => {
router.push(updateParams({ unit: value }));
};
const options = unitConfig.availableUnits.map(unit => ({
id: unit,
label: formatMessage(labels[unit]),
}));
const selectedUnit = query.unit ?? unitConfig.defaultUnit;
return (
<Row>
<Select
value={selectedUnit}
onChange={handleChange}
popoverProps={{ placement: 'bottom right' }}
style={{ width: 100 }}
>
{options.map(({ id, label }) => (
<ListItem key={id} id={id}>
{label}
</ListItem>
))}
</Select>
</Row>
);
}

View file

@ -41,7 +41,7 @@ export function WebsiteDateFilter({
}), }),
); );
} else { } else {
router.push(updateParams({ date, offset: undefined })); router.push(updateParams({ date, offset: undefined, unit: undefined }));
} }
}; };

View file

@ -245,7 +245,10 @@ export const labels = defineMessages({
tag: { id: 'label.tag', defaultMessage: 'Tag' }, tag: { id: 'label.tag', defaultMessage: 'Tag' },
segment: { id: 'label.segment', defaultMessage: 'Segment' }, segment: { id: 'label.segment', defaultMessage: 'Segment' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
minute: { id: 'label.minute', defaultMessage: 'Minute' },
hour: { id: 'label.hour', defaultMessage: 'Hour' },
day: { id: 'label.day', defaultMessage: 'Day' }, day: { id: 'label.day', defaultMessage: 'Day' },
month: { id: 'label.month', defaultMessage: 'Month' },
date: { id: 'label.date', defaultMessage: 'Date' }, date: { id: 'label.date', defaultMessage: 'Date' },
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' }, pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
create: { id: 'label.create', defaultMessage: 'Create' }, create: { id: 'label.create', defaultMessage: 'Create' },

View file

@ -25,7 +25,7 @@ export const MetricCard = ({
showChange = false, showChange = false,
}: MetricCardProps) => { }: MetricCardProps) => {
const diff = value - change; const diff = value - change;
const pct = ((value - diff) / diff) * 100; const pct = diff !== 0 ? ((value - diff) / diff) * 100 : value !== 0 ? 100 : 0;
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } }); const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } }); const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });

View file

@ -9,6 +9,7 @@ import {
differenceInCalendarMonths, differenceInCalendarMonths,
differenceInCalendarWeeks, differenceInCalendarWeeks,
differenceInCalendarYears, differenceInCalendarYears,
differenceInDays,
differenceInHours, differenceInHours,
differenceInMinutes, differenceInMinutes,
endOfDay, endOfDay,
@ -136,7 +137,12 @@ export function parseDateValue(value: string) {
return { num: +num, unit }; return { num: +num, unit };
} }
export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange { export function parseDateRange(
value: string,
unitValue?: string,
locale = 'en-US',
timezone?: string,
): DateRange {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return null; return null;
} }
@ -146,7 +152,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
const startDate = new Date(+startTime); const startDate = new Date(+startTime);
const endDate = new Date(+endTime); const endDate = new Date(+endTime);
const unit = getMinimumUnit(startDate, endDate); const unit = getMinimumUnit(startDate, endDate, true);
return { return {
startDate, startDate,
@ -169,14 +175,14 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
endDate: endOfHour(now), endDate: endOfHour(now),
offset: 0, offset: 0,
num: num || 1, num: num || 1,
unit, unit: unitValue || unit,
value, value,
}; };
case 'day': case 'day':
return { return {
startDate: num ? subDays(startOfDay(now), num) : startOfDay(now), startDate: num ? subDays(startOfDay(now), num) : startOfDay(now),
endDate: endOfDay(now), endDate: endOfDay(now),
unit: num ? 'day' : 'hour', unit: unitValue ? unitValue : num ? 'day' : 'hour',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -187,7 +193,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
? subWeeks(startOfWeek(now, { locale: dateLocale }), num) ? subWeeks(startOfWeek(now, { locale: dateLocale }), num)
: startOfWeek(now, { locale: dateLocale }), : startOfWeek(now, { locale: dateLocale }),
endDate: endOfWeek(now, { locale: dateLocale }), endDate: endOfWeek(now, { locale: dateLocale }),
unit: 'day', unit: unitValue || 'day',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -196,7 +202,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
return { return {
startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now), startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now),
endDate: endOfMonth(now), endDate: endOfMonth(now),
unit: num ? 'month' : 'day', unit: unitValue ? unitValue : num ? 'month' : 'day',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -205,7 +211,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
return { return {
startDate: num ? subYears(startOfYear(now), num) : startOfYear(now), startDate: num ? subYears(startOfYear(now), num) : startOfYear(now),
endDate: endOfYear(now), endDate: endOfYear(now),
unit: 'month', unit: unitValue || 'month',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -273,12 +279,20 @@ export function getAllowedUnits(startDate: Date, endDate: Date) {
return index >= 0 ? units.splice(index) : []; return index >= 0 ? units.splice(index) : [];
} }
export function getMinimumUnit(startDate: number | Date, endDate: number | Date) { export function getMinimumUnit(
startDate: number | Date,
endDate: number | Date,
isDateRange: boolean = false,
) {
if (differenceInMinutes(endDate, startDate) <= 60) { if (differenceInMinutes(endDate, startDate) <= 60) {
return 'minute'; return 'minute';
} else if (differenceInHours(endDate, startDate) <= 48) { } else if (
isDateRange
? differenceInHours(endDate, startDate) <= 48
: differenceInDays(endDate, startDate) <= 30
) {
return 'hour'; return 'hour';
} else if (differenceInCalendarMonths(endDate, startDate) <= 6) { } else if (differenceInCalendarMonths(endDate, startDate) <= 7) {
return 'day'; return 'day';
} else if (differenceInCalendarMonths(endDate, startDate) <= 24) { } else if (differenceInCalendarMonths(endDate, startDate) <= 24) {
return 'month'; return 'month';