From d788baaa703f9ed0b71dd4d5ef3b847bf55340b9 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 29 Feb 2024 12:08:57 +0100 Subject: [PATCH] feat: granular date filtering in insights and explorer --- packages/web/app/package.json | 1 + .../[projectId]/[targetId]/explorer.tsx | 18 +- .../[targetId]/explorer/[typename].tsx | 7 +- .../[targetId]/explorer/unused.tsx | 48 +-- .../[projectId]/[targetId]/insights.tsx | 56 +-- .../[operationName]/[operationHash].tsx | 50 +-- .../[targetId]/insights/client/[name].tsx | 70 ++-- .../schema-coordinate/[coordinate].tsx | 78 ++-- .../src/components/target/explorer/filter.tsx | 40 +- .../components/target/explorer/provider.tsx | 125 ++---- .../src/components/target/insights/List.tsx | 11 +- .../src/components/target/insights/Stats.tsx | 2 +- .../web/app/src/components/ui/calendar.tsx | 64 +++ .../src/components/ui/date-range-picker.tsx | 384 ++++++++++++++++++ packages/web/app/src/lib/date-math.spec.ts | 17 + packages/web/app/src/lib/date-math.ts | 192 +++++++++ .../lib/hooks/use-date-range-controller.ts | 299 ++++++++------ .../app/src/lib/hooks/use-local-storage.ts | 6 +- pnpm-lock.yaml | 13 + 19 files changed, 1052 insertions(+), 429 deletions(-) create mode 100644 packages/web/app/src/components/ui/calendar.tsx create mode 100644 packages/web/app/src/components/ui/date-range-picker.tsx create mode 100644 packages/web/app/src/lib/date-math.spec.ts create mode 100644 packages/web/app/src/lib/date-math.ts diff --git a/packages/web/app/package.json b/packages/web/app/package.json index 3f639bd4fbf..d9a480adcff 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -78,6 +78,7 @@ "pino": "8.19.0", "pino-http": "9.0.0", "react": "18.2.0", + "react-day-picker": "8.10.0", "react-dom": "18.2.0", "react-highlight-words": "0.20.0", "react-hook-form": "7.50.1", diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer.tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer.tsx index 687e4df79eb..b831b26ab5f 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/explorer.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect } from 'react'; +import { ReactElement, useEffect, useRef } from 'react'; import Link from 'next/link'; import { AlertCircleIcon } from 'lucide-react'; import { useQuery } from 'urql'; @@ -140,14 +140,15 @@ const TargetExplorerPageQuery = graphql(` function ExplorerPageContent() { const router = useRouteSelector(); - const { period, dataRetentionInDays, setDataRetentionInDays } = useSchemaExplorerContext(); + const { resolvedPeriod, dataRetentionInDays, setDataRetentionInDays } = + useSchemaExplorerContext(); const [query] = useQuery({ query: TargetExplorerPageQuery, variables: { organizationId: router.organizationId, projectId: router.projectId, targetId: router.targetId, - period, + period: resolvedPeriod, }, }); @@ -172,6 +173,13 @@ function ExplorerPageContent() { const latestSchemaVersion = currentTarget?.latestSchemaVersion; const latestValidSchemaVersion = currentTarget?.latestValidSchemaVersion; + /* to avoid janky behaviour we keep track if the version has a successful explorer once, and in that case always show the filter bar. */ + const isFilterVisible = useRef(false); + + if (latestValidSchemaVersion?.explorer) { + isFilterVisible.current = true; + } + return ( Explore Insights from the latest version. - {!query.fetching && latestValidSchemaVersion?.explorer && ( + {isFilterVisible.current && ( ); diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash].tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash].tsx index 54655dcf43d..8694765496f 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash].tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/[operationName]/[operationHash].tsx @@ -1,5 +1,6 @@ import { ReactElement, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; +import { RefreshCw } from 'lucide-react'; import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; import { Section } from '@/components/common'; @@ -7,15 +8,10 @@ import { GraphQLHighlight } from '@/components/common/GraphQLSDLBlock'; import { Page, TargetLayout } from '@/components/layouts/target'; import { ClientsFilterTrigger } from '@/components/target/insights/Filters'; import { OperationsStats } from '@/components/target/insights/Stats'; +import { Button } from '@/components/ui/button'; +import { DateRangePicker, presetLast1Day } from '@/components/ui/date-range-picker'; import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { EmptyList, MetaTitle } from '@/components/v2'; import { graphql } from '@/gql'; import { useRouteSelector } from '@/lib/hooks'; @@ -76,15 +72,9 @@ function OperationView({ operationHash: string; operationName: string; }): ReactElement { - const { - updateDateRangeByKey, - dateRangeKey, - displayDateRangeLabel, - availableDateRangeOptions, - dateRange, - resolution, - } = useDateRangeController({ + const dateRangeController = useDateRangeController({ dataRetentionInDays, + defaultPreset: presetLast1Day, }); const [selectedClients, setSelectedClients] = useState([]); const operationsList = useMemo(() => [operationHash], [operationHash]); @@ -98,34 +88,32 @@ function OperationView({
- + dateRangeController.setSelectedPreset(args.preset)} + /> +
Operation body diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/client/[name].tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/client/[name].tsx index 5947621e680..e096d3741ba 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/client/[name].tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/client/[name].tsx @@ -3,21 +3,16 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { differenceInMilliseconds } from 'date-fns'; import ReactECharts from 'echarts-for-react'; -import { ActivityIcon, BookIcon, GlobeIcon, HistoryIcon } from 'lucide-react'; +import { ActivityIcon, BookIcon, GlobeIcon, HistoryIcon, RefreshCw } from 'lucide-react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; import { Page, TargetLayout } from '@/components/layouts/target'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { DateRangePicker, presetLast7Days } from '@/components/ui/date-range-picker'; import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { EmptyList, MetaTitle } from '@/components/v2'; import { CHART_PRIMARY_COLOR } from '@/constants'; import { graphql } from '@/gql'; @@ -59,16 +54,9 @@ function ClientView(props: { targetCleanId: string; }) { const styles = useChartStyles(); - const { - updateDateRangeByKey, - dateRangeKey, - displayDateRangeLabel, - availableDateRangeOptions, - dateRange, - resolution, - } = useDateRangeController({ + const dateRangeController = useDateRangeController({ dataRetentionInDays: props.dataRetentionInDays, - minKey: '7d', + defaultPreset: presetLast7Days, }); const [query] = useQuery({ @@ -79,9 +67,9 @@ function ClientView(props: { project: props.projectCleanId, target: props.targetCleanId, client: props.clientName, - period: dateRange, + period: dateRangeController.resolvedRange, }, - resolution, + resolution: 30, }, }); @@ -111,22 +99,16 @@ function ClientView(props: { GraphQL API consumer insights
- + dateRangeController.setSelectedPreset(args.preset)} + /> +
@@ -143,7 +125,7 @@ function ClientView(props: { {isLoading ? '-' : formatNumber(totalRequests)}

- Requests in {displayDateRangeLabel(dateRangeKey).toLowerCase()} + Requests in {dateRangeController.selectedPreset.label.toLowerCase()}

@@ -159,13 +141,13 @@ function ClientView(props: { : formatThroughput( totalRequests, differenceInMilliseconds( - new Date(dateRange.to), - new Date(dateRange.from), + new Date(dateRangeController.resolvedRange.to), + new Date(dateRangeController.resolvedRange.from), ), )}

- RPM in {displayDateRangeLabel(dateRangeKey).toLowerCase()} + RPM in {dateRangeController.selectedPreset.label.toLowerCase()}

@@ -189,7 +171,7 @@ function ClientView(props: {
{isLoading ? '-' : totalVersions}

- Versions in {displayDateRangeLabel(dateRangeKey).toLowerCase()} + Versions in {dateRangeController.selectedPreset.label.toLowerCase()}

@@ -227,8 +209,8 @@ function ClientView(props: { { type: 'time', boundaryGap: false, - min: dateRange.from, - max: dateRange.to, + min: dateRangeController.resolvedRange.from, + max: dateRangeController.resolvedRange.to, }, ], yAxis: [ @@ -276,7 +258,7 @@ function ClientView(props: { {props.clientName} requested {isLoading ? '-' : totalOperations}{' '} {totalOperations > 1 ? 'operations' : 'operation'} in{' '} - {displayDateRangeLabel(dateRangeKey).toLowerCase()} + {dateRangeController.selectedPreset.label.toLowerCase()} @@ -321,7 +303,7 @@ function ClientView(props: { {props.clientName} had {isLoading ? '-' : totalVersions}{' '} {totalVersions > 1 ? 'versions' : 'version'} in{' '} - {displayDateRangeLabel(dateRangeKey).toLowerCase()}. + {dateRangeController.selectedPreset.label.toLowerCase()}. {!isLoading && totalVersions > 25 ? 'Displaying only 25 most popular versions' : null} diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/schema-coordinate/[coordinate].tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/schema-coordinate/[coordinate].tsx index 43a3780c053..4b00e10918c 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/schema-coordinate/[coordinate].tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/insights/schema-coordinate/[coordinate].tsx @@ -3,21 +3,16 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { differenceInMilliseconds } from 'date-fns'; import ReactECharts from 'echarts-for-react'; -import { ActivityIcon, BookIcon, GlobeIcon, TabletSmartphoneIcon } from 'lucide-react'; +import { ActivityIcon, BookIcon, GlobeIcon, RefreshCw, TabletSmartphoneIcon } from 'lucide-react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; import { Page, TargetLayout } from '@/components/layouts/target'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { DateRangePicker, presetLast7Days } from '@/components/ui/date-range-picker'; import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { EmptyList, MetaTitle } from '@/components/v2'; import { CHART_PRIMARY_COLOR } from '@/constants'; import { graphql } from '@/gql'; @@ -63,16 +58,9 @@ function SchemaCoordinateView(props: { targetCleanId: string; }) { const styles = useChartStyles(); - const { - updateDateRangeByKey, - dateRangeKey, - displayDateRangeLabel, - availableDateRangeOptions, - dateRange, - resolution, - } = useDateRangeController({ + const dateRangeController = useDateRangeController({ dataRetentionInDays: props.dataRetentionInDays, - minKey: '7d', + defaultPreset: presetLast7Days, }); const [query] = useQuery({ @@ -83,16 +71,12 @@ function SchemaCoordinateView(props: { project: props.projectCleanId, target: props.targetCleanId, schemaCoordinate: props.coordinate, - period: dateRange, + period: dateRangeController.resolvedRange, }, - resolution, + resolution: 60, }, }); - if (query.error) { - return ; - } - const isLoading = query.fetching; const points = query.data?.schemaCoordinateStats?.requestsOverTime; const requestsOverTime = useMemo(() => { @@ -106,6 +90,10 @@ function SchemaCoordinateView(props: { const totalOperations = query.data?.schemaCoordinateStats?.operations.nodes.length ?? 0; const totalClients = query.data?.schemaCoordinateStats?.clients.nodes.length ?? 0; + if (query.error) { + return ; + } + return ( <>
@@ -114,22 +102,16 @@ function SchemaCoordinateView(props: { Schema coordinate insights
- + dateRangeController.setSelectedPreset(args.preset)} + /> +
@@ -146,7 +128,7 @@ function SchemaCoordinateView(props: { {isLoading ? '-' : formatNumber(totalRequests)}

- Requests in {displayDateRangeLabel(dateRangeKey).toLowerCase()} + Requests in {dateRangeController.selectedPreset.label.toLowerCase()}

@@ -162,13 +144,13 @@ function SchemaCoordinateView(props: { : formatThroughput( totalRequests, differenceInMilliseconds( - new Date(dateRange.to), - new Date(dateRange.from), + new Date(dateRangeController.resolvedRange.to), + new Date(dateRangeController.resolvedRange.from), ), )}

- RPM in {displayDateRangeLabel(dateRangeKey).toLowerCase()} + RPM in {dateRangeController.selectedPreset.label.toLowerCase()}

@@ -192,7 +174,7 @@ function SchemaCoordinateView(props: {
{isLoading ? '-' : totalClients}

- GraphQL clients in {displayDateRangeLabel(dateRangeKey).toLowerCase()} + GraphQL clients in {dateRangeController.selectedPreset.label.toLowerCase()}

@@ -230,8 +212,8 @@ function SchemaCoordinateView(props: { { type: 'time', boundaryGap: false, - min: dateRange.from, - max: dateRange.to, + min: dateRangeController.resolvedRange.from, + max: dateRangeController.resolvedRange.to, }, ], yAxis: [ @@ -279,7 +261,7 @@ function SchemaCoordinateView(props: { {props.coordinate} was used by {isLoading ? '-' : totalOperations}{' '} {totalOperations > 1 ? 'operations' : 'operation'} in{' '} - {displayDateRangeLabel(dateRangeKey).toLowerCase()} + {dateRangeController.selectedPreset.label.toLowerCase()} @@ -324,7 +306,7 @@ function SchemaCoordinateView(props: { {props.coordinate} was used by {isLoading ? '-' : totalClients}{' '} {totalClients > 1 ? 'clients' : 'client'} in{' '} - {displayDateRangeLabel(dateRangeKey).toLowerCase()}. + {dateRangeController.selectedPreset.label.toLowerCase()}. diff --git a/packages/web/app/src/components/target/explorer/filter.tsx b/packages/web/app/src/components/target/explorer/filter.tsx index ddf9ffb13fd..a3467da9313 100644 --- a/packages/web/app/src/components/target/explorer/filter.tsx +++ b/packages/web/app/src/components/target/explorer/filter.tsx @@ -1,14 +1,10 @@ import { ReactNode, useMemo } from 'react'; import { useRouter } from 'next/router'; +import { RefreshCw } from 'lucide-react'; import { useQuery } from 'urql'; +import { Button } from '@/components/ui/button'; +import { DateRangePicker } from '@/components/ui/date-range-picker'; import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Autocomplete } from '@/components/v2'; @@ -113,24 +109,18 @@ export function SchemaExplorerFilter({ }} loading={query.fetching} /> -
- -
+ { + periodSelector.setPeriod(value.preset.range); + }} + selectedRange={periodSelector.period} + startDate={periodSelector.startDate} + align="end" + /> + diff --git a/packages/web/app/src/components/target/explorer/provider.tsx b/packages/web/app/src/components/target/explorer/provider.tsx index 1cb58c9e175..1f5ee794bf7 100644 --- a/packages/web/app/src/components/target/explorer/provider.tsx +++ b/packages/web/app/src/components/target/explorer/provider.tsx @@ -4,93 +4,64 @@ import { ReactNode, useCallback, useContext, - useEffect, useMemo, useState, } from 'react'; -import { formatISO } from 'date-fns'; +import { startOfDay } from 'date-fns'; +import { resolveRange, type Period } from '@/lib/date-math'; import { subDays } from '@/lib/date-time'; import { useLocalStorage } from '@/lib/hooks'; -type PeriodOption = '365d' | '180d' | '90d' | '30d' | '14d' | '7d'; - -type Period = { - from: string; - to: string; -}; - -function toStartOfMinute(): Date { - const today = new Date(); - today.setSeconds(0, 0); - return today; -} - -function createPeriod(option: PeriodOption): Period { - const now = toStartOfMinute(); - const value = parseInt(option.replace('d', ''), 10); - - return { - from: formatISO(subDays(now, value)), - to: formatISO(now), - }; -} - type SchemaExplorerContextType = { isArgumentListCollapsed: boolean; setArgumentListCollapsed(isCollapsed: boolean): void; - setPeriodOption(option: PeriodOption): void; setDataRetentionInDays(days: number): void; - periodOption: PeriodOption; - availablePeriodOptions: PeriodOption[]; - period: Period; dataRetentionInDays: number; + startDate: Date; + period: Period; + setPeriod(period: { from: string; to: string }): void; + /** the actual date. */ + resolvedPeriod: { from: string; to: string }; + /** refresh the resolved period (aka trigger refetch) */ + refreshResolvedPeriod(): void; +}; + +const defaultPeriod = { + from: 'now-7d', + to: 'now', }; const SchemaExplorerContext = createContext({ isArgumentListCollapsed: true, setArgumentListCollapsed: () => {}, - periodOption: '7d', - period: createPeriod('7d'), - availablePeriodOptions: ['7d'], - setPeriodOption: () => {}, dataRetentionInDays: 7, + startDate: startOfDay(subDays(new Date(), 7)), + period: defaultPeriod, + resolvedPeriod: resolveRange(defaultPeriod), + setPeriod: () => {}, setDataRetentionInDays: () => {}, + refreshResolvedPeriod: () => {}, }); export function SchemaExplorerProvider({ children }: { children: ReactNode }): ReactElement { const [dataRetentionInDays, setDataRetentionInDays] = useState( 7 /* Minimum possible data retention period - Free plan */, ); + + const startDate = useMemo( + () => startOfDay(subDays(new Date(), dataRetentionInDays)), + [dataRetentionInDays], + ); + const [isArgumentListCollapsed, setArgumentListCollapsed] = useLocalStorage( 'hive:schema-explorer:collapsed', true, ); - const [periodOption, setPeriodOption] = useLocalStorage( - 'hive:schema-explorer:period', - '30d', - ); - const [period, setPeriod] = useState(createPeriod(periodOption)); - - const updatePeriod = useCallback( - option => { - setPeriodOption(option); - setPeriod(createPeriod(option)); - }, - [setPeriodOption, setPeriod], + const [period, setPeriod] = useLocalStorage( + 'hive:schema-explorer:period-1', + defaultPeriod, ); - - useEffect(() => { - const inDays = parseInt(periodOption.replace('d', ''), 10); - if (dataRetentionInDays < inDays) { - updatePeriod(dataRetentionInDays > 7 ? '30d' : '7d'); - } - }, [periodOption, setPeriodOption, updatePeriod, dataRetentionInDays]); - - const availablePeriodOptions = useMemo(() => { - const options = Object.keys(periodLabelMap) as PeriodOption[]; - - return options.filter(option => parseInt(option.replace('d', ''), 10) <= dataRetentionInDays); - }, [periodOption, dataRetentionInDays]); + const [resolvedPeriod, setResolvedPeriod] = useState(() => resolveRange(period)); return ( {children} @@ -123,28 +100,12 @@ export function useArgumentListToggle() { return [isArgumentListCollapsed, toggle] as const; } -const periodLabelMap: { - [key in PeriodOption]: string; -} = { - '365d': 'Last year', - '180d': 'Last 6 months', - '90d': 'Last 3 months', - '30d': 'Last 30 days', - '14d': 'Last 14 days', - '7d': 'Last 7 days', -}; - export function usePeriodSelector() { - const { availablePeriodOptions, setPeriodOption, periodOption } = useSchemaExplorerContext(); + const { period, setPeriod, startDate, refreshResolvedPeriod } = useSchemaExplorerContext(); return { - options: availablePeriodOptions.map(option => ({ - label: periodLabelMap[option], - value: option, - })), - onChange: setPeriodOption, - value: periodOption, - displayLabel(key: PeriodOption) { - return periodLabelMap[key]; - }, + setPeriod, + period, + startDate, + refreshResolvedPeriod, }; } diff --git a/packages/web/app/src/components/target/insights/List.tsx b/packages/web/app/src/components/target/insights/List.tsx index b0de4fd1d6a..60219770b69 100644 --- a/packages/web/app/src/components/target/insights/List.tsx +++ b/packages/web/app/src/components/target/insights/List.tsx @@ -58,7 +58,7 @@ function OperationRow({ organization: string; project: string; target: string; - selectedPeriod: string; + selectedPeriod: null | { to: string; from: string }; }): ReactElement { const count = useFormattedNumber(operation.requests); const percentage = useDecimal(operation.percentage); @@ -82,7 +82,8 @@ function OperationRow({ targetId: target, operationName: operation.name, operationHash: operation.hash, - period: selectedPeriod, + from: selectedPeriod?.from ? encodeURIComponent(selectedPeriod.from) : undefined, + to: selectedPeriod?.to ? encodeURIComponent(selectedPeriod.to) : undefined, }, }} passHref @@ -202,7 +203,7 @@ function OperationsTable({ clients: readonly { name: string }[] | null; clientFilter: string | null; setClientFilter: (filter: string) => void; - selectedPeriod: string; + selectedPeriod: { from: string; to: string } | null; }): ReactElement { const tableInstance = useTableInstance(table, { columns, @@ -367,7 +368,7 @@ function OperationsTableContainer({ organization: string; project: string; target: string; - selectedPeriod: string; + selectedPeriod: { from: string; to: string } | null; clientFilter: string | null; setClientFilter: (client: string) => void; className?: string; @@ -482,7 +483,7 @@ export function OperationsList({ period: DateRangeInput; operationsFilter: readonly string[]; clientNamesFilter: string[]; - selectedPeriod: string; + selectedPeriod: null | { to: string; from: string }; }): ReactElement { const [clientFilter, setClientFilter] = useState(null); const [query, refetch] = useQuery({ diff --git a/packages/web/app/src/components/target/insights/Stats.tsx b/packages/web/app/src/components/target/insights/Stats.tsx index cf17b19d723..c88fb303393 100644 --- a/packages/web/app/src/components/target/insights/Stats.tsx +++ b/packages/web/app/src/components/target/insights/Stats.tsx @@ -1055,7 +1055,7 @@ export function OperationsStats({ target, period, }, - resolution, + resolution: 30, }, }); diff --git a/packages/web/app/src/components/ui/calendar.tsx b/packages/web/app/src/components/ui/calendar.tsx new file mode 100644 index 00000000000..03cc461ad84 --- /dev/null +++ b/packages/web/app/src/components/ui/calendar.tsx @@ -0,0 +1,64 @@ +'use client'; + +import * as React from 'react'; +import { DayPicker } from 'react-day-picker'; +import { cn } from '@/lib/utils'; +import { ChevronLeftIcon, ChevronRightIcon } from '@radix-ui/react-icons'; +import { buttonVariants } from './button'; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md' + : '[&:has([aria-selected])]:rounded-md', + ), + day: cn( + buttonVariants({ variant: 'ghost' }), + 'h-8 w-8 p-0 font-normal aria-selected:opacity-100', + ), + day_range_start: 'day-range-start', + day_range_end: 'day-range-end', + day_selected: + 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', + day_today: 'bg-accent text-accent-foreground', + day_outside: + 'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30', + day_disabled: 'text-muted-foreground opacity-50', + day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground', + day_hidden: 'invisible', + ...classNames, + }} + components={{ + IconLeft: ({ ...props }) => , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +export { Calendar }; diff --git a/packages/web/app/src/components/ui/date-range-picker.tsx b/packages/web/app/src/components/ui/date-range-picker.tsx new file mode 100644 index 00000000000..5fc0a1e1aa3 --- /dev/null +++ b/packages/web/app/src/components/ui/date-range-picker.tsx @@ -0,0 +1,384 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { endOfDay, endOfToday, subMonths } from 'date-fns'; +import { CalendarDays } from 'lucide-react'; +import { DateRange, Matcher } from 'react-day-picker'; +import { DurationUnit, formatDateToString, parse, units } from '@/lib/date-math'; +import { useResetState } from '@/lib/hooks/use-reset-state'; +import { ChevronDownIcon, ChevronUpIcon, Cross1Icon } from '@radix-ui/react-icons'; +import { Button } from './button'; +import { Calendar } from './calendar'; +import { Input } from './input'; +import { Label } from './label'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +export interface DateRangePickerProps { + presets?: Preset[]; + /** the active selected/custom preset */ + selectedRange?: { from: string; to: string } | null; + /** Click handler for applying the updates from DateRangePicker. */ + onUpdate?: (values: { preset: Preset }) => void; + /** Alignment of popover */ + align?: 'start' | 'center' | 'end'; + /** Option for locale */ + locale?: string; + /** Date after which a range can be picked. */ + startDate?: Date; + /** valid units allowed */ + validUnits?: DurationUnit[]; +} + +const formatDate = (date: Date, locale = 'en-us'): string => { + return date.toLocaleDateString(locale, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +}; + +interface ResolvedDateRange { + from: Date; + to: Date; +} + +export type Preset = { + name: string; + label: string; + range: { from: string; to: string }; +}; + +export function buildDateRangeString(range: ResolvedDateRange, locale = 'en-us'): string { + return `${formatDate(range.from, locale)} - ${formatDate(range.to, locale)}`; +} + +function resolveRange(rawFrom: string, rawTo: string): ResolvedDateRange | null { + const from = parse(rawFrom); + const to = parse(rawTo); + + if (from && to) { + return { from, to }; + } + return null; +} + +export const presetLast7Days: Preset = { + name: 'last7d', + label: 'Last 7 days', + range: { from: 'now-7d', to: 'now' }, +}; + +export const presetLast1Day: Preset = { + name: 'last24h', + label: 'Last 24 hours', + range: { from: 'now-1d', to: 'now' }, +}; + +// Define presets +export const availablePresets: Preset[] = [ + { name: 'last5min', label: 'Last 5 minutes', range: { from: 'now-5m', to: 'now' } }, + { name: 'last10min', label: 'Last 10 minutes', range: { from: 'now-10m', to: 'now' } }, + { name: 'last15min', label: 'Last 15 minutes', range: { from: 'now-15m', to: 'now' } }, + { name: 'last30min', label: 'Last 30 minutes', range: { from: 'now-30m', to: 'now' } }, + { name: 'last1h', label: 'Last 1 hour', range: { from: 'now-1h', to: 'now' } }, + { name: 'last3h', label: 'Last 3 hours', range: { from: 'now-3h', to: 'now' } }, + { name: 'last6h', label: 'Last 6 hours', range: { from: 'now-6h', to: 'now' } }, + { name: 'last12h', label: 'Last 12 hours', range: { from: 'now-12h', to: 'now' } }, + presetLast1Day, + { name: 'last2d', label: 'Last 2 days', range: { from: 'now-2d', to: 'now' } }, + { name: 'last3d', label: 'Last 3 days', range: { from: 'now-3d', to: 'now' } }, + presetLast7Days, + { name: 'last14d', label: 'Last 14 days', range: { from: 'now-14d', to: 'now' } }, + { name: 'last30d', label: 'Last 30 days', range: { from: 'now-30d', to: 'now' } }, + { name: 'last60d', label: 'Last 60 days', range: { from: 'now-60d', to: 'now' } }, + { name: 'last90d', label: 'Last 90 days', range: { from: 'now-90d', to: 'now' } }, + { name: 'last6M', label: 'Last 6 months', range: { from: 'now-6M', to: 'now' } }, + { name: 'last1y', label: 'Last 1 year', range: { from: 'now-1y', to: 'now' } }, +]; + +function findMatchingPreset(range: Preset['range']): Preset | undefined { + return availablePresets.find(preset => { + return preset.range.from === range.from && preset.range.to === range.to; + }); +} + +/** The DateRangePicker component allows a user to select a range of dates */ +export function DateRangePicker(props: DateRangePickerProps): JSX.Element { + const validUnits = props.validUnits ?? units; + const disallowedUnits = units.filter(unit => !validUnits.includes(unit)); + const hasInvalidUnitRegex = disallowedUnits?.length + ? new RegExp(`[0-9]+(${disallowedUnits.join('|')})`) + : null; + + let presets = props.presets ?? availablePresets; + + if (hasInvalidUnitRegex) { + presets = presets.filter( + preset => + !hasInvalidUnitRegex.test(preset.range.from) && !hasInvalidUnitRegex.test(preset.range.to), + ); + } + + const disabledDays: Matcher[] = [ + { + after: endOfToday(), + }, + ]; + + if (props.startDate) { + disabledDays.push({ + before: props.startDate, + }); + } + + const [isOpen, setIsOpen] = useState(false); + const [showCalendar, setShowCalendar] = useState(false); + + function getInitialPreset() { + let preset: Preset | undefined; + if ( + props.selectedRange && + !hasInvalidUnitRegex?.test(props.selectedRange.from) && + !hasInvalidUnitRegex?.test(props.selectedRange.to) + ) { + preset = findMatchingPreset(props.selectedRange); + + if (preset) { + return preset; + } + + const resolvedRange = resolveRange(props.selectedRange.from, props.selectedRange.to); + if (resolvedRange) { + return { + name: `${props.selectedRange.from}_${props.selectedRange.to}`, + label: buildDateRangeString(resolvedRange), + range: props.selectedRange, + }; + } + } + + return presets.at(0) ?? null; + } + + const [activePreset, setActivePreset] = useResetState(getInitialPreset, [ + props.selectedRange, + ]); + + const [fromValue, setFromValue] = useState(activePreset?.range.from ?? ''); + const [toValue, setToValue] = useState(activePreset?.range.to ?? ''); + + const [range, setRange] = useState(undefined); + + const fromParsed = parse(fromValue); + const toParsed = parse(toValue); + + const lastPreset = useRef(activePreset); + + useEffect(() => { + if (!activePreset) { + return; + } + + const fromParsed = parse(activePreset.range.from); + const toParsed = parse(activePreset.range.to); + + if (fromParsed && toParsed) { + const resolvedRange = resolveRange(fromValue, toValue); + if (resolvedRange) { + if (props.onUpdate && lastPreset.current?.name !== activePreset.name) { + props.onUpdate({ + preset: activePreset, + }); + } + } + } + }, [activePreset]); + + useEffect(() => { + lastPreset.current = activePreset; + }, [activePreset]); + + const resetValues = (): void => { + setActivePreset(getInitialPreset()); + }; + + const PresetButton = ({ preset }: { preset: Preset }): JSX.Element => { + let isDisabled = false; + + if (props.startDate) { + const from = parse(preset.range.from); + if (from && from.getTime() < props.startDate.getTime()) { + isDisabled = true; + } + } + + return ( + + ); + }; + + return ( + { + if (!open) { + resetValues(); + } + setIsOpen(open); + }} + > + + + + +
+
+
+
+
+
+
Absolute time range
+
+
+ +
+ { + setFromValue(ev.target.value); + }} + /> + +
+
+ {hasInvalidUnitRegex?.test(fromValue) ? ( + <>Only allowed units are {validUnits.join(', ')} + ) : !fromParsed ? ( + <>Invalid date string + ) : null} +
+
+
+ +
+ { + setToValue(ev.target.value); + }} + /> + +
+
+ {hasInvalidUnitRegex?.test(toValue) ? ( + <>Only allowed units are {validUnits.join(', ')} + ) : !toParsed ? ( + <>Invalid date string + ) : fromParsed && toParsed && fromParsed.getTime() > toParsed.getTime() ? ( +
To cannot be before from.
+ ) : null} +
+
+ + +
+
+
+
+
+
+
+
Presets
+ {presets.map(preset => ( + + ))} +
+
+
+ {showCalendar && ( +
+
+ + { + if (range?.from && range.to) { + setFromValue(formatDateToString(range.from)); + setToValue(formatDateToString(endOfDay(range.to))); + } + setRange(range); + }} + disabled={disabledDays} + /> +
+
+ )} +
+
+ ); +} diff --git a/packages/web/app/src/lib/date-math.spec.ts b/packages/web/app/src/lib/date-math.spec.ts new file mode 100644 index 00000000000..04078c6f80c --- /dev/null +++ b/packages/web/app/src/lib/date-math.spec.ts @@ -0,0 +1,17 @@ +import { parse } from './date-math'; + +describe('parse', () => { + const now = new Date('1996-06-25'); + it('should parse date', () => { + expect(parse('now-10m', now)?.toISOString()).toEqual(`1996-06-24T23:50:00.000Z`); + }); + it('can parse now', () => { + expect(parse('now', now)?.toISOString()).toEqual(`1996-06-25T00:00:00.000Z`); + }); + it('should not parse invalid parse date', () => { + expect(parse('10m', now)?.toISOString()).toBeUndefined(); + }); + it('should return undefined for invalid date', () => { + expect(parse('invalid', now)?.toISOString()).toBeUndefined(); + }); +}); diff --git a/packages/web/app/src/lib/date-math.ts b/packages/web/app/src/lib/date-math.ts new file mode 100644 index 00000000000..427991c8520 --- /dev/null +++ b/packages/web/app/src/lib/date-math.ts @@ -0,0 +1,192 @@ +/** + * The original source code was taken from Grafana's date-math.ts file and adjusted for Hive needs. + * @source https://github.com/grafana/grafana/blob/411c89012febe13323e4b8aafc8d692f4460e680/packages/grafana-data/src/datetime/datemath.ts#L1C1-L208C2 + */ +import { add, format, formatISO, parse as parseDate, sub, type Duration } from 'date-fns'; + +export type Period = { + from: string; + to: string; +}; + +export type DurationUnit = 'y' | 'M' | 'w' | 'd' | 'h' | 'm'; +export const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm']; + +function unitToDurationKey(unit: DurationUnit): keyof Duration { + switch (unit) { + case 'y': + return 'years'; + case 'M': + return 'months'; + case 'w': + return 'weeks'; + case 'd': + return 'days'; + case 'h': + return 'hours'; + case 'm': + return 'minutes'; + } +} + +/** + * Determine if a string contains a relative date time. + * @param text + */ +export function isMathString(text: string): boolean { + if (!text) { + return false; + } + + if (text.substring(0, 3) === 'now') { + return true; + } else { + return false; + } +} + +const dateStringFormat = 'yyyy-MM-dd HH:mm:ss'; + +function parseDateString(input: string) { + try { + return parseDate(input, dateStringFormat, new Date()); + } catch (error) { + return undefined; + } +} + +export function formatDateToString(date: Date) { + return format(date, dateStringFormat); +} + +function isValidDateString(input: string) { + return parseDateString(input) !== undefined; +} + +/** + * Parses different types input to a moment instance. There is a specific formatting language that can be used + * if text arg is string. See unit tests for examples. + * @param text + * @param roundUp See parseDateMath function. + * @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used. + */ +export function parse(text: string, now = new Date()): Date | undefined { + if (!text) { + return undefined; + } + + let mathString = ''; + + if (text.substring(0, 3) === 'now') { + // time = dateTimeForTimeZone(timezone); + mathString = text.substring('now'.length); + } else if (isValidDateString(text)) { + return parseDateString(text); + } else { + return undefined; + } + + if (!mathString.length) { + return now; + } + + return parseDateMath(mathString, now); +} + +/** + * Checks if the input is a valid date string. + * @param text + */ +export function isValid(text: string): boolean { + const date = parse(text); + if (date === undefined) { + return false; + } + + return false; +} + +/** + * Parses math part of the time string and shifts supplied time according to that math. See unit tests for examples. + * @param mathString + * @param time + * @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit. + */ +export function parseDateMath(mathString: string, now: Date): Date | undefined { + const strippedMathString = mathString.replace(/\s/g, ''); + let result = now; + let i = 0; + const len = strippedMathString.length; + + while (i < len) { + const c = strippedMathString.charAt(i++); + let type; + let num; + let unitString: string; + + if (c === '+') { + type = 1; + } else if (c === '-') { + type = 2; + } else { + return undefined; + } + + if (isNaN(parseInt(strippedMathString.charAt(i), 10))) { + num = 1; + } else if (strippedMathString.length === 2) { + num = parseInt(strippedMathString.charAt(i), 10); + } else { + const numFrom = i; + while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) { + i++; + if (i > 10) { + return undefined; + } + } + num = parseInt(strippedMathString.substring(numFrom, i), 10); + } + + if (type === 0) { + // rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M) + if (num !== 1) { + return undefined; + } + } + + unitString = strippedMathString.charAt(i++); + + if (unitString === 'f') { + unitString = strippedMathString.charAt(i++); + } + + const unit = unitString as DurationUnit; + + if (!units.includes(unit)) { + return undefined; + } else { + if (type === 1) { + result = add(result, { + [unitToDurationKey(unit)]: num, + }); + } else if (type === 2) { + result = sub(result, { + [unitToDurationKey(unit)]: num, + }); + } + } + } + return result; +} + +export function resolveRange(period: Period) { + const from = parse(period.from); + const to = parse(period.to); + if (!from || !to) { + throw new Error('Could not parse date strings.' + JSON.stringify(period)); + } + return { + from: formatISO(from), + to: formatISO(to), + }; +} diff --git a/packages/web/app/src/lib/hooks/use-date-range-controller.ts b/packages/web/app/src/lib/hooks/use-date-range-controller.ts index bf83b5924d9..823b4bff32b 100644 --- a/packages/web/app/src/lib/hooks/use-date-range-controller.ts +++ b/packages/web/app/src/lib/hooks/use-date-range-controller.ts @@ -1,128 +1,193 @@ -import { useCallback, useMemo } from 'react'; +import { useState } from 'react'; import { useRouter } from 'next/router'; -import { formatISO, subHours, subMinutes } from 'date-fns'; +import { availablePresets, buildDateRangeString, Preset } from '@/components/ui/date-range-picker'; +import { parse, resolveRange } from '@/lib/date-math'; import { subDays } from '@/lib/date-time'; +import { useResetState } from './use-reset-state'; + +// function floorDate(date: Date): Date { +// const time = 1000 * 60; +// return new Date(Math.floor(date.getTime() / time) * time); +// } + +// const DateRange = { +// '90d': { +// resolution: 90, +// label: 'Last 90 days', +// }, +// '60d': { +// resolution: 60, +// label: 'Last 60 days', +// }, +// '30d': { +// resolution: 60, +// label: 'Last 30 days', +// }, +// '14d': { +// resolution: 60, +// label: 'Last 14 days', +// }, +// '7d': { +// resolution: 60, +// label: 'Last 7 days', +// }, +// '1d': { +// resolution: 60, +// label: 'Last 24 hours', +// }, +// '1h': { +// resolution: 60, +// label: 'Last hour', +// }, +// }; + +// type DateRangeKey = keyof typeof DateRange; + +// function isDayBasedPeriodKey( +// periodKey: T, +// ): periodKey is Extract { +// return periodKey.endsWith('d'); +// } + +// function keyToHours(key: DateRangeKey): number { +// if (isDayBasedPeriodKey(key)) { +// return parseInt(key.replace('d', ''), 10) * 24; +// } + +// return parseInt(key.replace('h', ''), 10); +// } + +// export function useDateRangeController(options: { +// dataRetentionInDays: number; +// minKey?: DateRangeKey; +// }) { +// const router = useRouter(); +// const [href, periodParam] = router.asPath.split('?'); +// let selectedDateRangeKey: DateRangeKey = +// (new URLSearchParams(periodParam).get('period') as DateRangeKey) ?? '1d'; +// const availableDateRangeOptions = useMemo(() => { +// return Object.keys(DateRange).filter(key => { +// const dateRangeKey = key as DateRangeKey; + +// if (options.minKey && keyToHours(dateRangeKey) < keyToHours(options.minKey)) { +// return false; +// } + +// if (isDayBasedPeriodKey(dateRangeKey)) { +// // Only show day based periods that are within the data retention period +// const daysBack = parseInt(dateRangeKey.replace('d', ''), 10); +// return daysBack <= options.dataRetentionInDays; +// } + +// return true; +// }) as DateRangeKey[]; +// }, [options.dataRetentionInDays]); + +// if (!availableDateRangeOptions.includes(selectedDateRangeKey)) { +// selectedDateRangeKey = options.minKey ?? '1d'; +// } + +// const dateRange = useMemo(() => { +// const now = floorDate(new Date()); +// const sub = selectedDateRangeKey.endsWith('h') +// ? 'h' +// : selectedDateRangeKey.endsWith('m') +// ? 'm' +// : 'd'; + +// const value = parseInt(selectedDateRangeKey.replace(sub, '')); +// const from = formatISO( +// sub === 'h' +// ? subHours(now, value) +// : sub === 'm' +// ? subMinutes(now, value) +// : subDays(now, value), +// ); +// const to = formatISO(now); + +// return { from, to }; +// }, [selectedDateRangeKey, availableDateRangeOptions]); + +// const updateDateRangeByKey = useCallback( +// (value: string) => { +// void router.push(`${href}?period=${value}`); +// }, +// [href, router], +// ); + +// const displayDateRangeLabel = useCallback((key: DateRangeKey) => { +// return DateRange[key].label; +// }, []); + +// return { +// dateRange, +// resolution: DateRange[selectedDateRangeKey].resolution, +// dateRangeKey: selectedDateRangeKey, +// availableDateRangeOptions, +// updateDateRangeByKey, +// displayDateRangeLabel, +// }; +// } + +export function useDateRangeController(args: { + dataRetentionInDays: number; + defaultPreset: Preset; +}) { + const router = useRouter(); + const [href, urlParameter] = router.asPath.split('?'); -function floorDate(date: Date): Date { - const time = 1000 * 60; - return new Date(Math.floor(date.getTime() / time) * time); -} + const [startDate] = useResetState( + () => subDays(new Date(), args.dataRetentionInDays), + [args.dataRetentionInDays], + ); -const DateRange = { - '90d': { - resolution: 90, - label: 'Last 90 days', - }, - '60d': { - resolution: 60, - label: 'Last 60 days', - }, - '30d': { - resolution: 60, - label: 'Last 30 days', - }, - '14d': { - resolution: 60, - label: 'Last 14 days', - }, - '7d': { - resolution: 60, - label: 'Last 7 days', - }, - '1d': { - resolution: 60, - label: 'Last 24 hours', - }, - '1h': { - resolution: 60, - label: 'Last hour', - }, -}; - -type DateRangeKey = keyof typeof DateRange; - -function isDayBasedPeriodKey( - periodKey: T, -): periodKey is Extract { - return periodKey.endsWith('d'); -} + const params = new URLSearchParams(urlParameter); + const fromRaw = params.get('from') ?? ''; + const toRaw = params.get('to') ?? 'now'; -function keyToHours(key: DateRangeKey): number { - if (isDayBasedPeriodKey(key)) { - return parseInt(key.replace('d', ''), 10) * 24; - } + const [selectedPreset] = useResetState(() => { + const preset = availablePresets.find(p => p.range.from === fromRaw && p.range.to === toRaw); - return parseInt(key.replace('h', ''), 10); -} + if (preset) { + return preset; + } -export function useDateRangeController(options: { - dataRetentionInDays: number; - minKey?: DateRangeKey; -}) { - const router = useRouter(); - const [href, periodParam] = router.asPath.split('?'); - let selectedDateRangeKey: DateRangeKey = - (new URLSearchParams(periodParam).get('period') as DateRangeKey) ?? '1d'; - const availableDateRangeOptions = useMemo(() => { - return Object.keys(DateRange).filter(key => { - const dateRangeKey = key as DateRangeKey; - - if (options.minKey && keyToHours(dateRangeKey) < keyToHours(options.minKey)) { - return false; - } - - if (isDayBasedPeriodKey(dateRangeKey)) { - // Only show day based periods that are within the data retention period - const daysBack = parseInt(dateRangeKey.replace('d', ''), 10); - return daysBack <= options.dataRetentionInDays; - } - - return true; - }) as DateRangeKey[]; - }, [options.dataRetentionInDays]); - - if (!availableDateRangeOptions.includes(selectedDateRangeKey)) { - selectedDateRangeKey = options.minKey ?? '1d'; - } - - const dateRange = useMemo(() => { - const now = floorDate(new Date()); - const sub = selectedDateRangeKey.endsWith('h') - ? 'h' - : selectedDateRangeKey.endsWith('m') - ? 'm' - : 'd'; - - const value = parseInt(selectedDateRangeKey.replace(sub, '')); - const from = formatISO( - sub === 'h' - ? subHours(now, value) - : sub === 'm' - ? subMinutes(now, value) - : subDays(now, value), - ); - const to = formatISO(now); - - return { from, to }; - }, [selectedDateRangeKey, availableDateRangeOptions]); - - const updateDateRangeByKey = useCallback( - (value: string) => { - void router.push(`${href}?period=${value}`); - }, - [href, router], - ); + const from = parse(fromRaw); + const to = parse(toRaw); + + if (!from || !to) { + return args.defaultPreset; + } - const displayDateRangeLabel = useCallback((key: DateRangeKey) => { - return DateRange[key].label; - }, []); + return { + name: `${fromRaw}_${toRaw}`, + label: buildDateRangeString({ from, to }), + range: { from: fromRaw, to: toRaw }, + }; + }, [fromRaw, toRaw]); + + const [triggerRefreshCounter, setTriggerRefreshCounter] = useState(0); + const [resolvedRange] = useResetState( + () => resolveRange(selectedPreset.range), + [selectedPreset.range, triggerRefreshCounter], + ); return { - dateRange, - resolution: DateRange[selectedDateRangeKey].resolution, - dateRangeKey: selectedDateRangeKey, - availableDateRangeOptions, - updateDateRangeByKey, - displayDateRangeLabel, - }; + startDate, + selectedPreset, + setSelectedPreset(preset: Preset) { + router.push( + `${href}?from=${encodeURIComponent(preset.range.from)}&to=${encodeURIComponent(preset.range.to)}`, + undefined, + { + scroll: false, + shallow: true, + }, + ); + }, + resolvedRange, + refreshResolvedRange() { + setTriggerRefreshCounter(c => c + 1); + }, + } as const; } diff --git a/packages/web/app/src/lib/hooks/use-local-storage.ts b/packages/web/app/src/lib/hooks/use-local-storage.ts index 939b8f23f8f..a2e0d380327 100644 --- a/packages/web/app/src/lib/hooks/use-local-storage.ts +++ b/packages/web/app/src/lib/hooks/use-local-storage.ts @@ -3,7 +3,11 @@ import { useCallback, useState } from 'react'; export function useLocalStorage(key: string, defaultValue: T) { const [value, setValue] = useState(() => { const json = localStorage.getItem(key); - return json ? JSON.parse(json) : defaultValue; + try { + return json ? JSON.parse(json) : defaultValue; + } catch (_) { + return defaultValue; + } }); const set = useCallback( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74793691ae3..2aa89327ba1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1670,6 +1670,9 @@ importers: react: specifier: 18.2.0 version: 18.2.0 + react-day-picker: + specifier: 8.10.0 + version: 8.10.0(date-fns@3.3.1)(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) @@ -28311,6 +28314,16 @@ packages: react: 18.2.0 dev: false + /react-day-picker@8.10.0(date-fns@3.3.1)(react@18.2.0): + resolution: {integrity: sha512-mz+qeyrOM7++1NCb1ARXmkjMkzWVh2GL9YiPbRjKe0zHccvekk4HE+0MPOZOrosn8r8zTHIIeOUXTmXRqmkRmg==} + peerDependencies: + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + date-fns: 3.3.1 + react: 18.2.0 + dev: false + /react-docgen-typescript@2.2.2(typescript@5.3.3): resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==} peerDependencies: