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

@@ -78,7 +78,7 @@
"nextjs-toploader": "^1.6.11",
"nuqs": "^1.16.1",
"prisma-error-enum": "^0.1.3",
"pushmodal": "^1.0.0",
"pushmodal": "^1.0.3",
"ramda": "^0.29.1",
"random-animal-name": "^0.1.1",
"react": "18.2.0",

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);

View File

@@ -2,6 +2,59 @@ import { isSameDay, isSameMonth } from 'date-fns';
export const NOT_SET_VALUE = '(not set)';
export const timeWindows = {
'30min': {
key: '30min',
label: 'Last 30 min',
shortcut: 'R',
},
lastHour: {
key: 'lastHour',
label: 'Last hour',
shortcut: 'H',
},
today: {
key: 'today',
label: 'Today',
shortcut: 'D',
},
'7d': {
key: '7d',
label: 'Last 7 days',
shortcut: 'W',
},
'30d': {
key: '30d',
label: 'Last 30 days',
shortcut: 'T',
},
monthToDate: {
key: 'monthToDate',
label: 'Month to Date',
shortcut: 'M',
},
lastMonth: {
key: 'lastMonth',
label: 'Last Month',
shortcut: 'P',
},
yearToDate: {
key: 'yearToDate',
label: 'Year to Date',
shortcut: 'Y',
},
lastYear: {
key: 'lastYear',
label: 'Last year',
shortcut: 'U',
},
custom: {
key: 'custom',
label: 'Custom range',
shortcut: 'C',
},
} as const;
export const ProjectTypeNames = {
website: 'Website',
app: 'App',
@@ -64,7 +117,7 @@ export const alphabetIds = [
'J',
] as const;
export const timeRanges = {
export const deprecated_timeRanges = {
'30min': '30min',
'1h': '1h',
today: 'today',
@@ -84,26 +137,29 @@ export const metrics = {
max: 'max',
} as const;
export function isMinuteIntervalEnabledByRange(range: keyof typeof timeRanges) {
return range === '30min' || range === '1h';
export function isMinuteIntervalEnabledByRange(
range: keyof typeof timeWindows
) {
return range === '30min' || range === 'lastHour';
}
export function isHourIntervalEnabledByRange(range: keyof typeof timeRanges) {
return (
isMinuteIntervalEnabledByRange(range) ||
range === 'today' ||
range === '24h'
);
export function isHourIntervalEnabledByRange(range: keyof typeof timeWindows) {
return isMinuteIntervalEnabledByRange(range) || range === 'today';
}
export function getDefaultIntervalByRange(
range: keyof typeof timeRanges
range: keyof typeof timeWindows
): keyof typeof intervals {
if (range === '30min' || range === '1h') {
if (range === '30min' || range === 'lastHour') {
return 'minute';
} else if (range === 'today' || range === '24h') {
} else if (range === 'today') {
return 'hour';
} else if (range === '7d' || range === '14d' || range === '1m') {
} else if (
range === '7d' ||
range === '30d' ||
range === 'lastMonth' ||
range === 'monthToDate'
) {
return 'day';
}
return 'month';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "reports" ALTER COLUMN "range" SET DEFAULT '30d';

View File

@@ -163,7 +163,7 @@ model Report {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
interval Interval
range String @default("1m")
range String @default("30d")
chartType ChartType
lineType String @default("monotone")
breakdowns Json

View File

@@ -1,4 +1,8 @@
import { alphabetIds, lineTypes, timeRanges } from '@openpanel/constants';
import {
alphabetIds,
deprecated_timeRanges,
lineTypes,
} from '@openpanel/constants';
import type {
IChartBreakdown,
IChartEvent,
@@ -52,7 +56,10 @@ export function transformReport(
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,
interval: report.interval,
name: report.name || 'Untitled',
range: (report.range as IChartRange) ?? timeRanges['1m'],
range:
report.range in deprecated_timeRanges
? '30d'
: (report.range as IChartRange),
previous: report.previous ?? false,
formula: report.formula ?? undefined,
metric: report.metric ?? 'sum',

View File

@@ -1,4 +1,14 @@
import { subDays } from 'date-fns';
import {
endOfDay,
endOfYear,
startOfDay,
startOfMonth,
startOfYear,
subDays,
subMinutes,
subMonths,
subYears,
} from 'date-fns';
import * as mathjs from 'mathjs';
import { repeat, reverse, sort } from 'ramda';
import { escape } from 'sqlstring';
@@ -298,22 +308,9 @@ export async function getChartData(payload: IGetChartDataInput) {
}
export function getDatesFromRange(range: IChartRange) {
if (range === 'today') {
const startDate = new Date();
const endDate = new Date();
startDate.setUTCHours(0, 0, 0, 0);
endDate.setUTCHours(23, 59, 59, 999);
return {
startDate: startDate.toUTCString(),
endDate: endDate.toUTCString(),
};
}
if (range === '30min' || range === '1h') {
const startDate = new Date(
Date.now() - 1000 * 60 * (range === '30min' ? 30 : 60)
).toUTCString();
if (range === '30min' || range === 'lastHour') {
const minutes = range === '30min' ? 30 : 60;
const startDate = subMinutes(new Date(), minutes).toUTCString();
const endDate = new Date().toUTCString();
return {
@@ -322,36 +319,92 @@ export function getDatesFromRange(range: IChartRange) {
};
}
let days = 1;
if (range === 'today') {
const startDate = startOfDay(new Date());
const endDate = endOfDay(new Date());
if (range === '24h') {
const startDate = subDays(new Date(), days);
const endDate = new Date();
return {
startDate: startDate.toUTCString(),
endDate: endDate.toUTCString(),
};
} else if (range === '7d') {
days = 7;
} else if (range === '14d') {
days = 14;
} else if (range === '1m') {
days = 30;
} else if (range === '3m') {
days = 90;
} else if (range === '6m') {
days = 180;
} else if (range === '1y') {
days = 365;
}
const startDate = subDays(new Date(), days);
startDate.setUTCHours(0, 0, 0, 0);
const endDate = new Date();
endDate.setUTCHours(23, 59, 59, 999);
if (range === '7d') {
const startDate = subDays(new Date(), 7).toUTCString();
const endDate = new Date().toUTCString();
return {
startDate,
endDate,
};
}
if (range === '30d') {
const startDate = subDays(new Date(), 30).toUTCString();
const endDate = new Date().toUTCString();
return {
startDate,
endDate,
};
}
if (range === 'monthToDate') {
const startDate = startOfMonth(new Date()).toUTCString();
const endDate = new Date().toUTCString();
return {
startDate,
endDate,
};
}
if (range === 'lastMonth') {
const month = subMonths(new Date(), 1);
const startDate = startOfMonth(month).toUTCString();
const endDate = endOfDay(month).toUTCString();
return {
startDate,
endDate,
};
}
if (range === 'yearToDate') {
const startDate = startOfYear(new Date()).toUTCString();
const endDate = new Date().toUTCString();
return {
startDate,
endDate,
};
}
if (range === 'lastYear') {
const year = subYears(new Date(), 1);
const startDate = startOfYear(year).toUTCString();
const endDate = endOfYear(year).toUTCString();
return {
startDate,
endDate,
};
}
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
console.log('-------------------------------');
return {
startDate: startDate.toUTCString(),
endDate: endDate.toUTCString(),
startDate: subDays(new Date(), 30).toISOString(),
endDate: new Date().toISOString(),
};
}
@@ -374,44 +427,7 @@ export function getChartPrevStartEndDate({
endDate: string;
range: IChartRange;
}) {
let diff = 0;
switch (range) {
case '30min': {
diff = 1000 * 60 * 30;
break;
}
case '1h': {
diff = 1000 * 60 * 60;
break;
}
case '24h':
case 'today': {
diff = 1000 * 60 * 60 * 24;
break;
}
case '7d': {
diff = 1000 * 60 * 60 * 24 * 7;
break;
}
case '14d': {
diff = 1000 * 60 * 60 * 24 * 14;
break;
}
case '1m': {
diff = 1000 * 60 * 60 * 24 * 30;
break;
}
case '3m': {
diff = 1000 * 60 * 60 * 24 * 90;
break;
}
case '6m': {
diff = 1000 * 60 * 60 * 24 * 180;
break;
}
}
const diff = new Date(endDate).getTime() - new Date(startDate).getTime();
return {
startDate: new Date(new Date(startDate).getTime() - diff).toISOString(),
endDate: new Date(new Date(endDate).getTime() - diff).toISOString(),

View File

@@ -29,7 +29,7 @@ export const reportRouter = createTRPCRouter({
breakdowns: report.breakdowns,
chartType: report.chartType,
lineType: report.lineType,
range: report.range,
range: report.range === 'custom' ? '30d' : report.range,
formula: report.formula,
},
});
@@ -53,7 +53,7 @@ export const reportRouter = createTRPCRouter({
breakdowns: report.breakdowns,
chartType: report.chartType,
lineType: report.lineType,
range: report.range,
range: report.range === 'custom' ? '30d' : report.range,
formula: report.formula,
},
});

View File

@@ -6,7 +6,7 @@ import {
lineTypes,
metrics,
operators,
timeRanges,
timeWindows,
} from '@openpanel/constants';
export function objectToZodEnums<K extends string>(
@@ -57,7 +57,7 @@ export const zTimeInterval = z.enum(objectToZodEnums(intervals));
export const zMetric = z.enum(objectToZodEnums(metrics));
export const zRange = z.enum(objectToZodEnums(timeRanges));
export const zRange = z.enum(objectToZodEnums(timeWindows));
export const zChartInput = z.object({
name: z.string().default(''),
@@ -66,7 +66,7 @@ export const zChartInput = z.object({
interval: zTimeInterval.default('day'),
events: zChartEvents,
breakdowns: zChartBreakdowns.default([]),
range: zRange.default('1m'),
range: zRange.default('30d'),
previous: z.boolean().default(false),
formula: z.string().optional(),
metric: zMetric.default('sum'),

View File

@@ -1,7 +1,5 @@
import type { z } from 'zod';
import type { timeRanges } from '@openpanel/constants';
import type {
zChartBreakdown,
zChartEvent,
@@ -9,6 +7,7 @@ import type {
zChartType,
zLineType,
zMetric,
zRange,
zTimeInterval,
} from './index';
@@ -24,7 +23,7 @@ export type IInterval = z.infer<typeof zTimeInterval>;
export type IChartType = z.infer<typeof zChartType>;
export type IChartMetric = z.infer<typeof zMetric>;
export type IChartLineType = z.infer<typeof zLineType>;
export type IChartRange = keyof typeof timeRanges;
export type IChartRange = z.infer<typeof zRange>;
export type IGetChartDataInput = {
event: IChartEvent;
projectId: string;

50
pnpm-lock.yaml generated
View File

@@ -334,8 +334,8 @@ importers:
specifier: ^0.1.3
version: 0.1.3
pushmodal:
specifier: ^1.0.0
version: 1.0.0(@radix-ui/react-dialog@1.0.5)(react-dom@18.2.0)(react@18.2.0)
specifier: ^1.0.3
version: 1.0.3(@radix-ui/react-dialog@1.0.5)(react-dom@18.2.0)(react@18.2.0)
ramda:
specifier: ^0.29.1
version: 0.29.1
@@ -10085,7 +10085,7 @@ packages:
eslint: 8.56.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 2.7.1(eslint-plugin-import@2.29.1)(eslint@8.56.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@8.56.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.56.0)
eslint-plugin-react: 7.33.2(eslint@8.56.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.56.0)
@@ -10166,7 +10166,7 @@ packages:
dependencies:
debug: 4.3.4
eslint: 8.56.0
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@8.56.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)
glob: 7.2.3
is-glob: 4.0.3
resolve: 1.22.8
@@ -10198,36 +10198,6 @@ packages:
- supports-color
dev: false
/eslint-module-utils@2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@2.7.1)(eslint@8.56.0):
resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: '*'
eslint-import-resolver-node: '*'
eslint-import-resolver-typescript: '*'
eslint-import-resolver-webpack: '*'
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
eslint:
optional: true
eslint-import-resolver-node:
optional: true
eslint-import-resolver-typescript:
optional: true
eslint-import-resolver-webpack:
optional: true
dependencies:
'@typescript-eslint/parser': 5.62.0(eslint@8.56.0)(typescript@5.3.3)
debug: 3.2.7
eslint: 8.56.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 2.7.1(eslint-plugin-import@2.29.1)(eslint@8.56.0)
transitivePeerDependencies:
- supports-color
dev: false
/eslint-module-utils@2.8.0(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0):
resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==}
engines: {node: '>=4'}
@@ -10258,7 +10228,7 @@ packages:
- supports-color
dev: false
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@8.56.0):
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
peerDependencies:
@@ -10268,7 +10238,7 @@ packages:
'@typescript-eslint/parser':
optional: true
dependencies:
'@typescript-eslint/parser': 5.62.0(eslint@8.56.0)(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3)
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.4
array.prototype.flat: 1.3.2
@@ -10277,7 +10247,7 @@ packages:
doctrine: 2.1.0
eslint: 8.56.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@2.7.1)(eslint@8.56.0)
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0)
hasown: 2.0.1
is-core-module: 2.13.1
is-glob: 4.0.3
@@ -10293,7 +10263,7 @@ packages:
- supports-color
dev: false
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0):
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0)(eslint@8.56.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
peerDependencies:
@@ -15374,8 +15344,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
/pushmodal@1.0.0(@radix-ui/react-dialog@1.0.5)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-34JSZHJHGTcLqBgYk9Fyiw5vBYJZrcgoDE7GfHehKKzxBt/Ro2bSLTIGRnzQ+NRv389GxH6WXCBUH+6VJ1wvTg==}
/pushmodal@1.0.3(@radix-ui/react-dialog@1.0.5)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-p8HxdCoXfwDU7V3uUxGpbZzK0/nHWey/A8wgfVrUDMSJC3XXEC+Hx8c+UsrTHRXw60ZJSjSoAgldyEh35UPsog==}
peerDependencies:
'@radix-ui/react-dialog': ^1.0.0
react: ^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0