mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
feat: Add time-of-day filtering with quick hour filters and time range picker
This commit is contained in:
parent
860e6390f1
commit
c5cb032e05
3 changed files with 152 additions and 0 deletions
|
|
@ -4,6 +4,7 @@ import { Fragment, type Key, useState } from 'react';
|
||||||
import { DateDisplay } from '@/components/common/DateDisplay';
|
import { DateDisplay } from '@/components/common/DateDisplay';
|
||||||
import { useMessages, useMobile } from '@/components/hooks';
|
import { useMessages, useMobile } from '@/components/hooks';
|
||||||
import { DatePickerForm } from '@/components/metrics/DatePickerForm';
|
import { DatePickerForm } from '@/components/metrics/DatePickerForm';
|
||||||
|
import { TimeRangePickerForm } from '@/components/metrics/TimeRangePickerForm';
|
||||||
import { parseDateRange } from '@/lib/date';
|
import { parseDateRange } from '@/lib/date';
|
||||||
|
|
||||||
export interface DateFilterProps extends SelectProps {
|
export interface DateFilterProps extends SelectProps {
|
||||||
|
|
@ -24,6 +25,7 @@ export function DateFilter({
|
||||||
}: DateFilterProps) {
|
}: DateFilterProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||||
const { startDate, endDate } = parseDateRange(value) || {};
|
const { startDate, endDate } = parseDateRange(value) || {};
|
||||||
const { isMobile } = useMobile();
|
const { isMobile } = useMobile();
|
||||||
|
|
||||||
|
|
@ -33,6 +35,27 @@ export function DateFilter({
|
||||||
label: formatMessage(labels.lastHours, { x: '24' }),
|
label: formatMessage(labels.lastHours, { x: '24' }),
|
||||||
value: '24hour',
|
value: '24hour',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.lastHours, { x: '12' }),
|
||||||
|
value: '12hour',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.lastHours, { x: '6' }),
|
||||||
|
value: '6hour',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.lastHours, { x: '4' }),
|
||||||
|
value: '4hour',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.lastHours, { x: '2' }),
|
||||||
|
value: '2hour',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.lastHours, { x: '1' }),
|
||||||
|
value: '1hour',
|
||||||
|
divider: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(labels.thisWeek),
|
label: formatMessage(labels.thisWeek),
|
||||||
value: '0week',
|
value: '0week',
|
||||||
|
|
@ -75,6 +98,10 @@ export function DateFilter({
|
||||||
value: 'custom',
|
value: 'custom',
|
||||||
divider: true,
|
divider: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.timeRange),
|
||||||
|
value: 'timeRange',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
.filter(n => n)
|
.filter(n => n)
|
||||||
.map((a, id) => ({ ...a, id }));
|
.map((a, id) => ({ ...a, id }));
|
||||||
|
|
@ -84,6 +111,10 @@ export function DateFilter({
|
||||||
setShowPicker(true);
|
setShowPicker(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (value === 'timeRange') {
|
||||||
|
setShowTimePicker(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onChange(value.toString());
|
onChange(value.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,6 +123,11 @@ export function DateFilter({
|
||||||
onChange(value.toString());
|
onChange(value.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTimePickerChange = (value: string) => {
|
||||||
|
setShowTimePicker(false);
|
||||||
|
onChange(value.toString());
|
||||||
|
};
|
||||||
|
|
||||||
const renderValue = ({ defaultChildren }) => {
|
const renderValue = ({ defaultChildren }) => {
|
||||||
return value?.startsWith('range') || renderDate ? (
|
return value?.startsWith('range') || renderDate ? (
|
||||||
<DateDisplay startDate={startDate} endDate={endDate} />
|
<DateDisplay startDate={startDate} endDate={endDate} />
|
||||||
|
|
@ -136,6 +172,20 @@ export function DateFilter({
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
{showTimePicker && (
|
||||||
|
<Modal isOpen={true}>
|
||||||
|
<Dialog>
|
||||||
|
<TimeRangePickerForm
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
minDate={new Date(2000, 0, 1)}
|
||||||
|
maxDate={endOfYear(new Date())}
|
||||||
|
onChange={handleTimePickerChange}
|
||||||
|
onClose={() => setShowTimePicker(false)}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,9 @@ export const labels = defineMessages({
|
||||||
thisYear: { id: 'label.this-year', defaultMessage: 'This year' },
|
thisYear: { id: 'label.this-year', defaultMessage: 'This year' },
|
||||||
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
|
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
|
||||||
customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' },
|
customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' },
|
||||||
|
timeRange: { id: 'label.time-range', defaultMessage: 'Time range' },
|
||||||
|
startTime: { id: 'label.start-time', defaultMessage: 'Start time' },
|
||||||
|
endTime: { id: 'label.end-time', defaultMessage: 'End time' },
|
||||||
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
|
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
|
||||||
selectRole: { id: 'label.select-role', defaultMessage: 'Select role' },
|
selectRole: { id: 'label.select-role', defaultMessage: 'Select role' },
|
||||||
selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
|
selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
|
||||||
|
|
|
||||||
99
src/components/metrics/TimeRangePickerForm.tsx
Normal file
99
src/components/metrics/TimeRangePickerForm.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { Button, Calendar, Column, ListItem, Row, Select } from '@umami/react-zen';
|
||||||
|
import { endOfDay, isAfter, setHours, setMinutes, startOfDay } from 'date-fns';
|
||||||
|
import { type Key, useState } from 'react';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
|
// Generate hour options (00:00 to 23:00)
|
||||||
|
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
id: i.toString(),
|
||||||
|
label: `${i.toString().padStart(2, '0')}:00`,
|
||||||
|
value: i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface TimeRangePickerFormProps {
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
minDate?: Date;
|
||||||
|
maxDate?: Date;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeRangePickerForm({
|
||||||
|
startDate: defaultStartDate,
|
||||||
|
endDate: defaultEndDate,
|
||||||
|
minDate,
|
||||||
|
maxDate,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
}: TimeRangePickerFormProps) {
|
||||||
|
const [date, setDate] = useState(defaultStartDate || new Date());
|
||||||
|
const [startHour, setStartHour] = useState(0);
|
||||||
|
const [endHour, setEndHour] = useState(23);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
const disabled = startHour > endHour;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const start = setMinutes(setHours(startOfDay(date), startHour), 0);
|
||||||
|
const end = setMinutes(setHours(startOfDay(date), endHour), 59);
|
||||||
|
onChange(`range:${start.getTime()}:${end.getTime()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartHourChange = (value: Key) => {
|
||||||
|
setStartHour(Number(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndHourChange = (value: Key) => {
|
||||||
|
setEndHour(Number(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column gap>
|
||||||
|
<Row justifyContent="center">
|
||||||
|
<Calendar value={date} minValue={minDate} maxValue={maxDate} onChange={setDate} />
|
||||||
|
</Row>
|
||||||
|
<Row gap justifyContent="center" alignItems="center">
|
||||||
|
<Column>
|
||||||
|
<label style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
|
||||||
|
{formatMessage(labels.startTime)}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={startHour.toString()}
|
||||||
|
onChange={handleStartHourChange}
|
||||||
|
style={{ minWidth: '100px' }}
|
||||||
|
>
|
||||||
|
{HOUR_OPTIONS.map(({ id, label }) => (
|
||||||
|
<ListItem key={id} id={id}>
|
||||||
|
{label}
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Column>
|
||||||
|
<span style={{ marginTop: '1.5rem' }}>—</span>
|
||||||
|
<Column>
|
||||||
|
<label style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
|
||||||
|
{formatMessage(labels.endTime)}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={endHour.toString()}
|
||||||
|
onChange={handleEndHourChange}
|
||||||
|
style={{ minWidth: '100px' }}
|
||||||
|
>
|
||||||
|
{HOUR_OPTIONS.map(({ id, label }) => (
|
||||||
|
<ListItem key={id} id={id}>
|
||||||
|
{label}
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
<Row justifyContent="end" gap>
|
||||||
|
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||||
|
<Button variant="primary" onPress={handleSave} isDisabled={disabled}>
|
||||||
|
{formatMessage(labels.apply)}
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue