improved date range selector with hotkeys

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-04-27 09:52:24 +02:00
parent e2d56fb34f
commit 4059dad727
24 changed files with 520 additions and 319 deletions

View File

@@ -22,6 +22,7 @@ import { toast } from 'sonner';
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
timeWindows,
} from '@openpanel/constants';
import type { getReportsByDashboardId } from '@openpanel/db';
@@ -64,7 +65,7 @@ export function ListReports({ reports }: ListReportsProps) {
</StickyBelowHeader>
<div className="mx-auto flex max-w-3xl flex-col gap-8 p-4 md:p-8">
{reports.map((report) => {
const chartRange = report.range; // timeRanges[report.range];
const chartRange = report.range;
return (
<div className="card" key={report.id}>
<Link
@@ -84,7 +85,7 @@ export function ListReports({ reports }: ListReportsProps) {
: ''
}
>
{chartRange}
{timeWindows[chartRange].label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>

View File

@@ -23,7 +23,7 @@ export function EventsPerDayChart({ projectId, filters, events }: Props) {
<div className="card mb-8 p-4">
<ChartSwitchShortcut
projectId={projectId}
range="1m"
range="30d"
chartType="histogram"
events={
events && events.length > 0

View File

@@ -1,37 +1,20 @@
'use client';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportRange } from '@/components/report/ReportRange';
import { endOfDay, startOfDay } from 'date-fns';
import { TimeWindowPicker } from '@/components/time-window-picker';
export function OverviewReportRange() {
const { range, setRange, setEndDate, setStartDate, startDate, endDate } =
const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
useOverviewOptions();
return (
<ReportRange
range={range}
onRangeChange={(value) => {
setRange(value);
setStartDate(null);
setEndDate(null);
}}
dates={{
startDate,
endDate,
}}
onDatesChange={(val) => {
if (!val) return;
if (val.from && val.to) {
setRange(null);
setStartDate(startOfDay(val.from).toISOString());
setEndDate(endOfDay(val.to).toISOString());
} else if (val.from) {
setStartDate(startOfDay(val.from).toISOString());
} else if (val.to) {
setEndDate(endOfDay(val.to).toISOString());
}
}}
return (
<TimeWindowPicker
onChange={setRange}
value={range}
onStartDateChange={(date) => setStartDate(date)}
onEndDateChange={(date) => setEndDate(date)}
endDate={endDate}
startDate={startDate}
/>
);
}

View File

@@ -114,7 +114,7 @@ export default async function Page({
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '1m',
range: '30d',
previous: false,
metric: 'sum',
};

View File

@@ -6,11 +6,9 @@ import { ChartSwitch } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
import { ReportRange } from '@/components/report/ReportRange';
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import {
changeDateRanges,
changeDates,
changeEndDate,
changeStartDate,
ready,
@@ -18,6 +16,7 @@ import {
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { TimeWindowPicker } from '@/components/time-window-picker';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/useAppParams';
@@ -63,32 +62,20 @@ export default function ReportEditor({
</SheetTrigger>
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
<ReportChartType className="min-w-0 flex-1" />
<ReportRange
<TimeWindowPicker
className="min-w-0 flex-1"
range={report.range}
onRangeChange={(value) => {
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
dates={{
startDate: report.startDate,
endDate: report.endDate,
}}
onDatesChange={(val) => {
if (!val) return;
if (val.from && val.to) {
dispatch(
changeDates({
startDate: startOfDay(val.from).toISOString(),
endDate: endOfDay(val.to).toISOString(),
})
);
} else if (val.from) {
dispatch(changeStartDate(startOfDay(val.from).toISOString()));
} else if (val.to) {
dispatch(changeEndDate(endOfDay(val.to).toISOString()));
}
}}
value={report.range}
onStartDateChange={(date) =>
dispatch(changeStartDate(startOfDay(date).toISOString()))
}
onEndDateChange={(date) =>
dispatch(changeEndDate(endOfDay(date).toISOString()))
}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval className="min-w-0 flex-1" />
<ReportLineType className="min-w-0 flex-1" />

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import {
parseAsBoolean,
parseAsInteger,
@@ -9,8 +10,9 @@ import {
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
timeRanges,
timeWindows,
} from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
const nuqsOptions = { history: 'push' } as const;
@@ -30,7 +32,7 @@ export function useOverviewOptions() {
);
const [range, setRange] = useQueryState(
'range',
parseAsStringEnum(mapKeys(timeRanges))
parseAsStringEnum(mapKeys(timeWindows))
.withDefault('7d')
.withOptions(nuqsOptions)
);
@@ -54,7 +56,13 @@ export function useOverviewOptions() {
previous,
setPrevious,
range,
setRange,
setRange: (value: IChartRange | null) => {
if (value !== 'custom') {
setStartDate(null);
setEndDate(null);
}
setRange(value);
},
metric,
setMetric,
startDate,

View File

@@ -5,7 +5,6 @@ import {
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@openpanel/constants';
import type { IInterval } from '@openpanel/validation';
import { Combobox } from '../ui/combobox';
import { changeInterval } from './reportSlice';
@@ -55,10 +54,7 @@ export function ReportInterval({ className }: ReportIntervalProps) {
value: 'month',
label: 'Month',
disabled:
range === 'today' ||
range === '24h' ||
range === '1h' ||
range === '30min',
range === 'today' || range === 'lastHour' || range === '30min',
},
]}
/>

View File

@@ -1,100 +0,0 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { cn } from '@/utils/cn';
import { format } from 'date-fns';
import { CalendarIcon, ChevronsUpDownIcon } from 'lucide-react';
import type { SelectRangeEventHandler } from 'react-day-picker';
import { timeRanges } from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
import type { ExtendedComboboxProps } from '../ui/combobox';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
export function ReportRange({
range,
onRangeChange,
onDatesChange,
dates,
className,
...props
}: {
range: IChartRange;
onRangeChange: (range: IChartRange) => void;
onDatesChange: SelectRangeEventHandler;
dates: { startDate: string | null; endDate: string | null };
} & Omit<ExtendedComboboxProps<string>, 'value' | 'onChange'>) {
const { isBelowSm } = useBreakpoint('sm');
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={'outline'}
className={cn('justify-start text-left font-normal', className)}
icon={CalendarIcon}
{...props}
>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{dates.startDate ? (
dates.endDate ? (
<>
{format(dates.startDate, 'LLL dd')} -{' '}
{format(dates.endDate, 'LLL dd')}
</>
) : (
format(dates.startDate, 'LLL dd, y')
)
) : (
<span>{range}</span>
)}
</span>
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="border-b border-border p-4">
<ToggleGroup
value={range}
onValueChange={(value) => {
if (value) onRangeChange(value as IChartRange);
}}
type="single"
variant="outline"
className="flex-wrap max-sm:max-w-xs"
>
{Object.values(timeRanges).map((key) => (
<ToggleGroupItem value={key} aria-label={key} key={key}>
{key}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={
dates.startDate ? new Date(dates.startDate) : new Date()
}
selected={{
from: dates.startDate ? new Date(dates.startDate) : undefined,
to: dates.endDate ? new Date(dates.endDate) : undefined,
}}
onSelect={onDatesChange}
numberOfMonths={isBelowSm ? 1 : 2}
className="[&_table]:mx-auto [&_table]:w-auto"
/>
</PopoverContent>
</Popover>
</>
);
}

View File

@@ -1,4 +1,3 @@
import { start } from 'repl';
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { isSameDay, isSameMonth } from 'date-fns';
@@ -39,7 +38,7 @@ const initialState: InitialState = {
interval: 'day',
breakdowns: [],
events: [],
range: '1m',
range: '30d',
startDate: null,
endDate: null,
previous: false,
@@ -232,10 +231,11 @@ export const reportSlice = createSlice({
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.dirty = true;
state.range = action.payload;
state.startDate = null;
state.endDate = null;
state.interval = getDefaultIntervalByRange(action.payload);
if (action.payload !== 'custom') {
state.startDate = null;
state.endDate = null;
state.interval = getDefaultIntervalByRange(action.payload);
}
},
// Formula

View File

@@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { pushModal, useOnPushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { bind } from 'bind-event-listener';
import { CalendarIcon } from 'lucide-react';
import { timeWindows } from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
function shouldIgnoreKeypress(event: KeyboardEvent) {
const tagName = (event?.target as HTMLElement)?.tagName;
const modifierPressed =
event.ctrlKey || event.metaKey || event.altKey || event.keyCode == 229;
const isTyping =
event.isComposing || tagName == 'INPUT' || tagName == 'TEXTAREA';
return modifierPressed || isTyping;
}
type Props = {
value: IChartRange;
onChange: (value: IChartRange) => void;
onStartDateChange: (date: string) => void;
onEndDateChange: (date: string) => void;
endDate: string | null;
startDate: string | null;
className?: string;
};
export function TimeWindowPicker({
value,
onChange,
startDate,
onStartDateChange,
endDate,
onEndDateChange,
className,
}: Props) {
const isDateRangerPickerOpen = useRef(false);
useOnPushModal(
'DateRangerPicker',
(open) => (isDateRangerPickerOpen.current = open)
);
const timeWindow = timeWindows[value ?? '30d'];
const handleCustom = useCallback(() => {
pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => {
onStartDateChange(startDate.toISOString());
onEndDateChange(endDate.toISOString());
onChange('custom');
},
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
});
}, [startDate, endDate]);
useEffect(() => {
return bind(document, {
type: 'keydown',
listener(event) {
if (shouldIgnoreKeypress(event)) {
return;
}
if (isDateRangerPickerOpen.current) {
return;
}
const match = Object.values(timeWindows).find(
(tw) => event.key === tw.shortcut.toLowerCase()
);
if (match?.key === 'custom') {
handleCustom();
} else if (match) {
onChange(match.key);
}
},
});
}, [handleCustom]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={CalendarIcon}
className={cn('justify-start', className)}
>
{timeWindow?.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Time window</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onChange(timeWindows['30min'].key)}>
{timeWindows['30min'].label}
<DropdownMenuShortcut>
{timeWindows['30min'].shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows.lastHour.key)}>
{timeWindows.lastHour.label}
<DropdownMenuShortcut>
{timeWindows.lastHour.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows.today.key)}>
{timeWindows.today.label}
<DropdownMenuShortcut>
{timeWindows.today.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onChange(timeWindows['7d'].key)}>
{timeWindows['7d'].label}
<DropdownMenuShortcut>
{timeWindows['7d'].shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows['30d'].key)}>
{timeWindows['30d'].label}
<DropdownMenuShortcut>
{timeWindows['30d'].shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => onChange(timeWindows.monthToDate.key)}
>
{timeWindows.monthToDate.label}
<DropdownMenuShortcut>
{timeWindows.monthToDate.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows.lastMonth.key)}>
{timeWindows.lastMonth.label}
<DropdownMenuShortcut>
{timeWindows.lastMonth.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => onChange(timeWindows.yearToDate.key)}
>
{timeWindows.yearToDate.label}
<DropdownMenuShortcut>
{timeWindows.yearToDate.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onChange(timeWindows.lastYear.key)}>
{timeWindows.lastYear.label}
<DropdownMenuShortcut>
{timeWindows.lastYear.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => handleCustom()}>
{timeWindows.custom.label}
<DropdownMenuShortcut>
{timeWindows.custom.shortcut}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -10,7 +10,7 @@ import { Loader2 } from 'lucide-react';
import Link from 'next/link';
const buttonVariants = cva(
'inline-flex flex-shrink-0 items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {

View File

@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { subMonths } from 'date-fns';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type Props = {
onChange: (payload: { startDate: Date; endDate: Date }) => void;
startDate?: Date;
endDate?: Date;
};
export default function DateRangerPicker({
onChange,
startDate: initialStartDate,
endDate: initialEndDate,
}: Props) {
const { isBelowSm } = useBreakpoint('sm');
const [startDate, setStartDate] = useState(initialStartDate);
const [endDate, setEndDate] = useState(initialEndDate);
return (
<ModalContent className="max-w-[600px]">
<ModalHeader title="Pick a date range" className="mb-0" />
<Calendar
initialFocus
mode="range"
defaultMonth={subMonths(
startDate ? new Date(startDate) : new Date(),
isBelowSm ? 0 : 1
)}
selected={{
from: startDate,
to: endDate,
}}
toDate={new Date()}
onSelect={(range) => {
if (range?.from) {
setStartDate(range.from);
}
if (range?.to) {
setEndDate(range.to);
}
}}
numberOfMonths={isBelowSm ? 1 : 2}
className="min-h-[350px] [&_table]:mx-auto [&_table]:w-auto"
/>
<Button
className="mt-8"
onClick={() => {
popModal();
if (startDate && endDate) {
onChange({
startDate: startDate,
endDate: endDate,
});
}
}}
>
Select
</Button>
</ModalContent>
);
}

View File

@@ -2,6 +2,7 @@
import { Button } from '@/components/ui/button';
import { DialogContent } from '@/components/ui/dialog';
import { cn } from '@/utils/cn';
import type { DialogContentProps } from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
@@ -19,11 +20,17 @@ interface ModalHeaderProps {
title: string | React.ReactNode;
text?: string | React.ReactNode;
onClose?: (() => void) | false;
className?: string;
}
export function ModalHeader({ title, text, onClose }: ModalHeaderProps) {
export function ModalHeader({
title,
text,
onClose,
className,
}: ModalHeaderProps) {
return (
<div className="mb-6 flex justify-between">
<div className={cn('mb-6 flex justify-between', className)}>
<div>
<div className="mt-0.5 font-medium">{title}</div>
{!!text && <div className="text-sm text-muted-foreground">{text}</div>}

View File

@@ -59,11 +59,19 @@ const modals = {
FunnelStepDetails: dynamic(() => import('./FunnelStepDetails'), {
loading: Loading,
}),
DateRangerPicker: dynamic(() => import('./DateRangerPicker'), {
loading: Loading,
}),
};
export const { pushModal, popModal, popAllModals, ModalProvider } =
createPushModal({
modals,
});
export const {
pushModal,
popModal,
popAllModals,
ModalProvider,
useOnPushModal,
} = createPushModal({
modals,
});
export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props);