diff --git a/app/components/daterange-filter.tsx b/app/components/daterange-filter.tsx index e178eb43..6a01b3d9 100644 --- a/app/components/daterange-filter.tsx +++ b/app/components/daterange-filter.tsx @@ -1,12 +1,12 @@ -import { PopoverClose } from '@radix-ui/react-popover' -import { format } from 'date-fns' -import { Clock } from 'lucide-react' -import { useEffect, useState } from 'react' +import { format, Locale } from 'date-fns' +import { de, enGB } from 'date-fns/locale' +import { CalendarIcon } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { type DateRange } from 'react-day-picker' -import { useLoaderData, useSearchParams, useSubmit } from 'react-router' +import { useLoaderData, useSearchParams } from 'react-router' + import { Badge } from './ui/badge' import { Button } from './ui/button' -import { Calendar } from './ui/calendar' import { Command, CommandEmpty, @@ -15,46 +15,93 @@ import { CommandItem, CommandList, } from './ui/command' +import { Input } from './ui/input' import { Popover, PopoverContent, PopoverTrigger } from './ui/popover' import { Separator } from './ui/separator' -import dateTimeRanges from '~/lib/date-ranges' + +import { getDateTimeRanges } from '~/lib/date-ranges' import { type loader } from '~/routes/explore.$deviceId.$sensorId.$' +import { useTranslation } from 'react-i18next' +import { TFunction } from 'i18next' + +function formatDateRange(t: TFunction, date?: DateRange) { + if (!date?.from) return t('pick_range') + + if (date.to) { + return `${format(date.from, 'LLL dd, y HH:mm')} - ${format( + date.to, + 'LLL dd, y HH:mm', + )}` + } + + return format(date.from, 'LLL dd, y HH:mm') +} + +function toDateTimeLocalValue(date?: Date) { + if (!date) return '' + + return format(date, "yyyy-MM-dd'T'HH:mm") +} + +function fromDateTimeLocalValue(value: string) { + if (!value) return undefined + + const [datePart, timePart] = value.split('T') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes] = timePart.split(':').map(Number) + + return new Date(year, month - 1, day, hours, minutes) +} + +function getInitialDateRange( + loaderData: ReturnType>, +) { + if (loaderData.startDate || loaderData.endDate) { + return { + from: loaderData.startDate ? new Date(loaderData.startDate) : undefined, + to: loaderData.endDate ? new Date(loaderData.endDate) : undefined, + } + } + + const data = loaderData.sensors?.[0]?.data ?? [] + + if (data.length === 0) return undefined + + const timestamps = data + .map((measurement) => new Date(measurement.time!).getTime()) + .filter(Number.isFinite) + + if (timestamps.length === 0) return undefined + + return { + from: new Date(Math.min(...timestamps)), + to: new Date(Math.max(...timestamps)), + } +} export function DateRangeFilter() { - // Get data from the loader const loaderData = useLoaderData() + const [searchParams, setSearchParams] = useSearchParams() + const { t, i18n } = useTranslation('graph') - // Form submission handler - const submit = useSubmit() - const [searchParams] = useSearchParams() + const dateFnsLocale: Locale = i18n.language.startsWith('de') ? de : enGB + + const dateTimeRanges = useMemo(() => { + return getDateTimeRanges(dateFnsLocale) + }, [dateFnsLocale]) const [open, setOpen] = useState(false) - // State for selected date range and aggregation - const [date, setDate] = useState({ - from: loaderData.startDate ? new Date(loaderData.startDate) : undefined, - to: loaderData.endDate ? new Date(loaderData.endDate) : undefined, - }) - - if ( - !date?.from && - !date?.to && - loaderData.sensors && - loaderData.sensors.length > 0 && - loaderData.sensors[0].data && - loaderData.sensors[0].data.length > 0 - ) { - const firstDate = loaderData.sensors[0].data[0]?.time - const lastDate = - loaderData.sensors[0].data[loaderData.sensors[0].data.length - 1]?.time - - setDate({ - from: lastDate ? new Date(lastDate) : undefined, - to: firstDate ? new Date(firstDate) : undefined, - }) - } + const initialDateRange = useMemo(() => { + return getInitialDateRange(loaderData) + }, [loaderData]) + + const [date, setDate] = useState(initialDateRange) + + useEffect(() => { + setDate(initialDateRange) + }, [initialDateRange]) - // Shortcut to open date range selection useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === 'd' && (e.metaKey || e.ctrlKey)) { @@ -70,99 +117,166 @@ export function DateRangeFilter() { } }, []) - // Update search params when date or aggregation changes - useEffect(() => { + function handleFromChange(value: string) { + setDate((previousDate) => ({ + from: fromDateTimeLocalValue(value), + to: previousDate?.to, + })) + } + + function handleToChange(value: string) { + setDate((previousDate) => ({ + from: previousDate?.from, + to: fromDateTimeLocalValue(value), + })) + } + + function applyDateRange() { + const nextSearchParams = new URLSearchParams(searchParams) + if (date?.from) { - searchParams.set('date_from', date?.from?.toISOString() ?? '') + nextSearchParams.set('date_from', date.from.toISOString()) + } else { + nextSearchParams.delete('date_from') } + if (date?.to) { - searchParams.set('date_to', date?.to?.toISOString() ?? '') + nextSearchParams.set('date_to', date.to.toISOString()) + } else { + nextSearchParams.delete('date_to') } - }, [date, searchParams]) + + setSearchParams(nextSearchParams) + setOpen(false) + } + + function clearDateRange() { + setDate(undefined) + } + + const isInvalidRange = Boolean(date?.from && date?.to && date.from > date.to) return ( - + -
-
-
-
- Absolute time range -
- { - setDate(dates) - }} - initialFocus +
+
+
+ + + handleFromChange(event.target.value)} />
- - - - - No range found. - - {dateTimeRanges.map((dateTimeRange) => ( - { - const selectedDateTimeRange = dateTimeRanges.find( - (range) => range.value === value, - ) - const timeRange = selectedDateTimeRange?.convert() +
+ + + handleToChange(event.target.value)} + /> +
+ + {isInvalidRange && ( +

+ {t('start_before_end_error')} +

+ )} +
+ + + + + + + + {t('time_range_not_found')} + + + {dateTimeRanges.map((dateTimeRange) => { + const label = t(dateTimeRange.labelKey) + + return ( + { + const timeRange = dateTimeRange.convert() setDate({ - from: timeRange?.from, - to: timeRange?.to, + from: timeRange.from, + to: timeRange.to, }) }} > - {dateTimeRange.label} + {label} - ))} - - - -
-
- { - void submit(searchParams) - }} + ) + })} + + + + +
+ + +
diff --git a/app/components/device-detail/graph.tsx b/app/components/device-detail/graph.tsx index 21d223ed..2186a98f 100644 --- a/app/components/device-detail/graph.tsx +++ b/app/components/device-detail/graph.tsx @@ -44,6 +44,7 @@ import { TooltipTrigger, } from '../ui/tooltip' import { datesHave48HourRange } from '~/lib/utils' +import { useTranslation } from 'react-i18next' ChartJS.register( LineElement, @@ -89,6 +90,7 @@ export default function Graph({ }: GraphProps) { const { setHoveredPoint } = useContext(HoveredPointContext) const navigation = useNavigation() + const { t, i18n } = useTranslation('graph') const navigate = useNavigate() const [offsetPositionX, setOffsetPositionX] = useState(0) const [offsetPositionY, setOffsetPositionY] = useState(0) @@ -107,6 +109,13 @@ export default function Graph({ const nodeRef = useRef(null) const chartRef = useRef>(null) + const dateTimeFormatter = useMemo(() => { + return new Intl.DateTimeFormat(i18n.language, { + dateStyle: 'medium', + timeStyle: 'medium', + }) + }, [i18n.language]) + useEffect(() => { if (chartRef.current) { const canvas = chartRef.current.canvas @@ -360,12 +369,24 @@ export default function Graph({ mode: 'index', intersect: false, callbacks: { + title: (tooltipItems: any[]) => { + const firstItem = tooltipItems[0] + + if (!firstItem) return '' + + const timestamp = firstItem.raw.x + + return dateTimeFormatter.format(new Date(timestamp)) + }, + label: (context: any) => { const dataIndex = context.dataIndex const datasetIndex = context.datasetIndex const point = chartData.datasets[datasetIndex].data[dataIndex] const locationId = point.locationId + setHoveredPoint(locationId) + return `${context.dataset.label}: ${context.raw.y}` }, }, @@ -431,6 +452,7 @@ export default function Graph({ chartData.datasets, setHoveredPoint, colorPickerState.open, + dateTimeFormatter, ]) function handleColorChange(newColor: string) { @@ -534,7 +556,7 @@ export default function Graph({ >
{navigation.state === 'loading' && (
@@ -562,7 +584,7 @@ export default function Graph({ /> -

Reset zoom

+

{t('reset_zoom')}

@@ -600,7 +622,7 @@ export default function Graph({
{(sensors[0].data.length === 0 && sensors[1] === undefined) || (sensors[0].data.length === 0 && sensors[1].data.length === 0) ? ( -
There is no data for the selected time period.
+
{t('no_data_in_range')}
) : ( }> {() => ( diff --git a/app/components/ui/field.tsx b/app/components/ui/field.tsx new file mode 100644 index 00000000..8a44c571 --- /dev/null +++ b/app/components/ui/field.tsx @@ -0,0 +1,247 @@ +'use client' + +import { useMemo } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' + +function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', + className, + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = 'legend', + ...props +}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
[data-slot=field-group]]:gap-4', + className, + )} + {...props} + /> + ) +} + +const fieldVariants = cva( + 'group/field flex w-full gap-3 data-[invalid=true]:text-red-500 dark:data-[invalid=true]:text-red-900', + { + variants: { + orientation: { + vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'], + horizontal: [ + 'flex-row items-center', + '[&>[data-slot=field-label]]:flex-auto', + 'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + ], + responsive: [ + 'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto', + '@md/field-group:[&>[data-slot=field-label]]:flex-auto', + '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + ], + }, + }, + defaultVariants: { + orientation: 'vertical', + }, + }, +) + +function Field({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +