diff --git a/app/components/compare-devices/compare-device-box.tsx b/app/components/compare-devices/compare-device-box.tsx new file mode 100644 index 00000000..d0fb26ab --- /dev/null +++ b/app/components/compare-devices/compare-device-box.tsx @@ -0,0 +1,50 @@ +import { useNavigate, useNavigation, useSearchParams } from 'react-router' +import { X } from 'lucide-react' +import Spinner from '../spinner' +import { DeviceComparison } from './device-comparison' + +export default function CompareDevices({ + devicesWithSensors, +}: { + devicesWithSensors: any[] +}) { + const navigate = useNavigate() + const navigation = useNavigation() + const [searchParams] = useSearchParams() + + const allSensors = devicesWithSensors.flatMap((device) => device.sensors) + // const uniqueSensorTitles = [ + // ...new Set(allSensors.map((sensor) => sensor.title)), + // ] + + return ( + <> +
+
+ {navigation.state === 'loading' && ( +
+ +
+ )} + {/* this is the header */} +
+ { + void navigate({ + pathname: '/explore', + search: searchParams.toString(), + }) + }} + /> +
+
+
+ +
+
+
+
+ + ) +} diff --git a/app/components/compare-devices/device-comparison.tsx b/app/components/compare-devices/device-comparison.tsx new file mode 100644 index 00000000..7e25803f --- /dev/null +++ b/app/components/compare-devices/device-comparison.tsx @@ -0,0 +1,30 @@ +import DeviceInfo from './device-info' +import { SensorComparison } from './sensor-comparison' +import { Separator } from '../ui/separator' + +interface DeviceComparisonProps { + devices: any[] +} + +export function DeviceComparison({ devices }: DeviceComparisonProps) { + const allSensors = devices.flatMap((device) => device.sensors) + const uniqueSensorTitles = [ + ...new Set(allSensors.map((sensor) => sensor.title)), + ] + + return ( +
+
+ {devices.map((device) => ( + d.id !== device.id)?.id} + /> + ))} + +
+ +
+ ) +} diff --git a/app/components/compare-devices/device-info.tsx b/app/components/compare-devices/device-info.tsx new file mode 100644 index 00000000..aa43f129 --- /dev/null +++ b/app/components/compare-devices/device-info.tsx @@ -0,0 +1,51 @@ +import { Link } from 'react-router' +import { type Device } from '~/schema' +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card' +import { ExposureBadge } from './exposure-badge' +import { StatusBadge } from './status-badge' +import { Trash } from 'lucide-react' + +export default function DeviceInfo({ + device, + otherDeviceId, +}: { + device: Device + otherDeviceId: string +}) { + return ( + + +
+ {device.name} + + + +
+
+ +
+ Status: + +
+
+ Exposure: + +
+
+ Created: + {new Date(device.createdAt).toLocaleDateString()} +
+
+ Updated: + {new Date(device.updatedAt).toLocaleDateString()} +
+ {device.sensorWikiModel && ( +
+ Model: + {device.sensorWikiModel} +
+ )} +
+
+ ) +} diff --git a/app/components/compare-devices/exposure-badge.tsx b/app/components/compare-devices/exposure-badge.tsx new file mode 100644 index 00000000..d4d787dd --- /dev/null +++ b/app/components/compare-devices/exposure-badge.tsx @@ -0,0 +1,14 @@ +import { Badge } from "../ui/badge" + +export type DeviceExposureEnum = "indoor" | "outdoor" | "mobile" | "unknown" + +export function ExposureBadge({ exposure }: { exposure: DeviceExposureEnum }) { + const colorMap: Record = { + indoor: "bg-blue-500", + outdoor: "bg-green-500", + mobile: "bg-purple-500", + unknown: "bg-gray-500", + } + + return {exposure} +} diff --git a/app/components/compare-devices/sensor-comparison.tsx b/app/components/compare-devices/sensor-comparison.tsx new file mode 100644 index 00000000..03a76b15 --- /dev/null +++ b/app/components/compare-devices/sensor-comparison.tsx @@ -0,0 +1,142 @@ +import { + Link, + useLocation, + useMatches, + useParams, + useSearchParams, +} from 'react-router' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '../ui/table' +import { toast } from '../ui/use-toast' +import { Minus } from 'lucide-react' + +interface SensorComparisonProps { + devices: any[] + sensorTitles: string[] +} + +export function SensorComparison({ + devices, + sensorTitles, +}: SensorComparisonProps) { + const matches = useMatches() + const [searchParams] = useSearchParams() + const { deviceId, deviceId2 } = useParams() + + const sensorIds = new Set() + + const createSensorLink = (sensorIdToBeSelected: string) => { + const lastSegment = matches[matches.length - 1]?.params?.['*'] + if (lastSegment) { + const secondLastSegment = matches[matches.length - 2]?.params?.sensorId + sensorIds.add(secondLastSegment) + sensorIds.add(lastSegment) + } else { + const lastSegment = matches[matches.length - 1]?.params?.sensorId + if (lastSegment) { + sensorIds.add(lastSegment) + } + } + + // If sensorIdToBeSelected is second selected sensor + if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 2) { + const clonedSet = new Set(sensorIds) + clonedSet.delete(sensorIdToBeSelected) + return `/explore/${deviceId}/compare/${deviceId2}/${Array.from(clonedSet).join('/')}?${searchParams.toString()}` + } else if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 1) { + return `/explore/${deviceId}/compare/${deviceId2}?${searchParams.toString()}` + } else if (sensorIds.size === 0) { + return `/explore/${deviceId}/compare/${deviceId2}/${sensorIdToBeSelected}?${searchParams.toString()}` + } else if (sensorIds.size === 1) { + return `/explore/${deviceId}/compare/${deviceId2}/${Array.from(sensorIds).join('/')}/${sensorIdToBeSelected}?${searchParams.toString()}` + } + + return '' + } + + const isSensorActive = (sensorId: string) => { + if (sensorIds.has(sensorId)) { + return 'bg-green-100 dark:bg-dark-green' + } + return 'hover:bg-muted' + } + + return ( + + + + Sensor + {devices.map((device) => ( + {device.name} + ))} + + + + {sensorTitles.map((title) => ( + + {title} + {devices.map((device) => { + const sensor = device.sensors.find((s: any) => s.title === title) + + // If sensor is undefined, return a placeholder cell + if (!sensor) { + return ( + + + + ) + } + + const sensorLink = createSensorLink(sensor.id) + if (sensorLink === '') { + return ( + + toast({ + title: "Can't select more than 2 sensors", + description: 'Deselect one sensor to select another', + variant: 'destructive', + }) + } + > +
+
+ {sensor.value} {sensor.unit} +
+
+ Last updated: {new Date(sensor.time).toLocaleString()} +
+
+
+ ) + } + + return ( + + +
+ {sensor.value} {sensor.unit} +
+
+ Last updated: {new Date(sensor.time).toLocaleString()} +
+ +
+ ) + })} +
+ ))} +
+
+ ) +} diff --git a/app/components/compare-devices/status-badge.tsx b/app/components/compare-devices/status-badge.tsx new file mode 100644 index 00000000..dc0eda32 --- /dev/null +++ b/app/components/compare-devices/status-badge.tsx @@ -0,0 +1,15 @@ +import { Badge } from '@/components/ui/badge' + +export type StatusBadgeProps = { + status: 'active' | 'inactive' | 'old' +} + +export function StatusBadge({ status }: StatusBadgeProps) { + const colorMap = { + active: 'bg-green-500', + inactive: 'bg-red-500', + old: 'bg-slate-500', + } + + return {status} +} diff --git a/app/components/device-detail/device-detail-box.tsx b/app/components/device-detail/device-detail-box.tsx deleted file mode 100644 index 92c52484..00000000 --- a/app/components/device-detail/device-detail-box.tsx +++ /dev/null @@ -1,684 +0,0 @@ -import clsx from 'clsx' -import { format, formatDistanceToNow } from 'date-fns' -import { - ChevronUp, - Minus, - Share2, - XSquare, - EllipsisVertical, - X, - ExternalLink, - Scale, - Archive, - Cpu, - Rss, - CalendarPlus, - Hash, - LandPlot, - Image as ImageIcon, -} from 'lucide-react' -import { Fragment, useEffect, useRef, useState } from 'react' -import { isTablet, isBrowser } from 'react-device-detect' -import Draggable, { type DraggableData } from 'react-draggable' -import { - useLoaderData, - useMatches, - useNavigate, - useNavigation, - useParams, - useSearchParams, - Link, -} from 'react-router' -import { useBetween } from 'use-between' -import SensorIcon from '../sensor-icon' -import Spinner from '../spinner' -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '../ui/accordion' -import { Alert, AlertDescription, AlertTitle } from '../ui/alert' -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '../ui/alert-dialog' -import { Badge } from '../ui/badge' -import { Button } from '../ui/button' -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from '../ui/card' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '../ui/dropdown-menu' -import { Separator } from '../ui/separator' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '../ui/tooltip' -import { useToast } from '../ui/use-toast' -import EntryLogs from './entry-logs' -import ShareLink from './share-link' -import { type loader } from '~/routes/explore.$deviceId' -import { type SensorWithLatestMeasurement } from '~/schema' -import { getArchiveLink } from '~/utils/device' - -export interface MeasurementProps { - sensorId: string - time: Date - value: string - min_value: string - max_value: string -} - -const useCompareMode = () => { - const [compareMode, setCompareMode] = useState(false) - return { compareMode, setCompareMode } -} - -export const useSharedCompareMode = () => useBetween(useCompareMode) - -export default function DeviceDetailBox() { - const navigation = useNavigation() - const navigate = useNavigate() - const matches = useMatches() - const { toast } = useToast() - - const sensorIds = new Set() - - const data = useLoaderData() - const nodeRef = useRef(null) - // state variables - const [open, setOpen] = useState(true) - const [offsetPositionX, setOffsetPositionX] = useState(0) - const [offsetPositionY, setOffsetPositionY] = useState(0) - const { compareMode, setCompareMode } = useSharedCompareMode() - const [refreshOn] = useState(false) - const [refreshSecond, setRefreshSecond] = useState(59) - - const [sensors, setSensors] = useState() - useEffect(() => { - const sortedSensors = [...data.sensors].sort( - (a, b) => (a.id as unknown as number) - (b.id as unknown as number), - ) - setSensors(sortedSensors) - }, [data.sensors]) - - const [searchParams] = useSearchParams() - - const { deviceId } = useParams() // Get the deviceId from the URL params - - const createSensorLink = (sensorIdToBeSelected: string) => { - const lastSegment = matches[matches.length - 1]?.params?.['*'] - if (lastSegment) { - const secondLastSegment = matches[matches.length - 2]?.params?.sensorId - sensorIds.add(secondLastSegment) - sensorIds.add(lastSegment) - } else { - const lastSegment = matches[matches.length - 1]?.params?.sensorId - if (lastSegment) { - sensorIds.add(lastSegment) - } - } - - // If sensorIdToBeSelected is second selected sensor - if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 2) { - const clonedSet = new Set(sensorIds) - clonedSet.delete(sensorIdToBeSelected) - return `/explore/${deviceId}/${Array.from(clonedSet).join('/')}?${searchParams.toString()}` - } else if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 1) { - return `/explore/${deviceId}?${searchParams.toString()}` - } else if (sensorIds.size === 0) { - return `/explore/${deviceId}/${sensorIdToBeSelected}?${searchParams.toString()}` - } else if (sensorIds.size === 1) { - return `/explore/${deviceId}/${Array.from(sensorIds).join('/')}/${sensorIdToBeSelected}?${searchParams.toString()}` - } - - return '' - } - - const isSensorActive = (sensorId: string) => { - if (sensorIds.has(sensorId)) { - return 'bg-green-100 dark:bg-dark-green' - } - - return 'hover:bg-muted' - } - - function handleDrag(_e: any, data: DraggableData) { - setOffsetPositionX(data.x) - setOffsetPositionY(data.y) - } - - const addLineBreaks = (text: string) => - text.split('\\n').map((text, index) => ( - - {text} -
-
- )) - - useEffect(() => { - let interval: any = null - if (refreshOn) { - if (refreshSecond == 0) { - setRefreshSecond(59) - } - interval = setInterval(() => { - setRefreshSecond((refreshSecond) => refreshSecond - 1) - }, 1000) - } else if (!refreshOn) { - clearInterval(interval) - } - return () => clearInterval(interval) - }, [refreshOn, refreshSecond]) - - if (!data.device) return null - - return ( - <> - {open && ( - -
-
- {navigation.state === 'loading' && ( -
- -
- )} -
-
-
- {data.device.name} -
- - - - - - - Share this link - - - - Close - - - - - - - - - Actions - - - - Compare - - - - - - Archive - - - - - - - - External Link - - - - - - - setOpen(false)} - /> - { - void navigate({ - pathname: '/explore', - search: searchParams.toString(), - }) - }} - /> -
-
-
-
- {data.device.image ? ( - device_image - ) : ( -
- -
- )} -
-
- - - - - - - {data.device.expiresAt && ( - <> - - - - )} -
-
- {data.device.tags && data.device.tags.length > 0 && ( -
-
-
- Tags -
-
- -
- {data.device.tags.map((tag: string) => ( - { - event.stopPropagation() - - const currentParams = new URLSearchParams( - searchParams.toString(), - ) - - // Safely retrieve and parse the current tags - const currentTags = - currentParams.get('tags')?.split(',') || [] - - // Toggle the tag in the list - const updatedTags = currentTags.includes(tag) - ? currentTags.filter((t) => t !== tag) // Remove if already present - : [...currentTags, tag] // Add if not present - - // Update the tags parameter or remove it if empty - if (updatedTags.length > 0) { - currentParams.set( - 'tags', - updatedTags.join(','), - ) - } else { - currentParams.delete('tags') - } - - // Update the URL with the new search params - void navigate({ - search: currentParams.toString(), - }) - }} - > - {tag} - - ))} -
-
-
-
- )} - - {data.device.logEntries.length > 0 && ( - <> - - - - )} - {data.device.description && ( - - - - Description - - - {addLineBreaks(data.device.description)} - - - - )} - - - - Sensors - - -
-
- {sensors && - sensors.map( - (sensor: SensorWithLatestMeasurement) => { - const sensorLink = createSensorLink(sensor.id) - if (sensorLink === '') { - return ( - - toast({ - title: - 'Cant select more than 2 sensors', - description: - 'Deselect one sensor to select another', - variant: 'destructive', - }) - } - > - - - ) - } - return ( - - - - - - ) - }, - )} -
-
-
-
-
-
-
-
-
- )} - {compareMode && ( - - { - setCompareMode(!compareMode) - setOpen(true) - }} - /> - Compare devices - - Choose a device from the map to compare with. - - - )} - {!open && ( -
{ - setOpen(true) - }} - className="absolute bottom-[10px] left-4 flex cursor-pointer rounded-xl border border-gray-100 bg-white shadow-lg transition-colors duration-300 ease-in-out hover:brightness-90 dark:bg-zinc-800 dark:text-zinc-200 dark:opacity-90 sm:bottom-[30px] sm:left-[10px]" - > - - - -
- -
-
- -

Open device details

-
-
-
-
- )} - - ) -} - -const InfoItem = ({ - icon: Icon, - title, - text, -}: { - icon: React.ElementType - title: string - text?: string -}) => - text && ( -
-
{title}
-
- - {text} -
-
- ) diff --git a/app/components/device-overview/device-detail-box.tsx b/app/components/device-overview/device-detail-box.tsx new file mode 100644 index 00000000..1df0ad66 --- /dev/null +++ b/app/components/device-overview/device-detail-box.tsx @@ -0,0 +1,256 @@ +import { ChevronUp, Minus, Share2, X } from 'lucide-react' +import { Fragment, useEffect, useRef, useState } from 'react' +import { isTablet, isBrowser } from 'react-device-detect' +import Draggable, { type DraggableData } from 'react-draggable' +import { + useLoaderData, + useNavigate, + useNavigation, + useSearchParams, +} from 'react-router' +import DeviceMetadataInfo from './device-metadata-info' +import DeviceImage from './device-image' +import DeviceOptions from './device-options' +import DeviceTags from './device-tags' +import SensorCards from './sensor-cards' +import Spinner from '../spinner' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '../ui/accordion' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '../ui/alert-dialog' +import { Separator } from '../ui/separator' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../ui/tooltip' +import EntryLogs from './entry-logs' +import ShareLink from './share-link' +import { type loader } from '~/routes/explore.$deviceId' +import { type SensorWithLatestMeasurement } from '~/schema' + +export interface MeasurementProps { + sensorId: string + time: Date + value: string + min_value: string + max_value: string +} + +export default function DeviceDetailBox() { + const navigation = useNavigation() + const navigate = useNavigate() + const data = useLoaderData() + const nodeRef = useRef(null) + const [searchParams] = useSearchParams() + + // state variables + const [open, setOpen] = useState(true) + const [offsetPositionX, setOffsetPositionX] = useState(0) + const [offsetPositionY, setOffsetPositionY] = useState(0) + const [refreshOn] = useState(false) + const [refreshSecond, setRefreshSecond] = useState(59) + const [sensors, setSensors] = useState() + + useEffect(() => { + const sortedSensors = [...data.sensors].sort( + (a, b) => (a.id as unknown as number) - (b.id as unknown as number), + ) + setSensors(sortedSensors) + }, [data.sensors]) + + useEffect(() => { + let interval: any = null + if (refreshOn) { + if (refreshSecond == 0) { + setRefreshSecond(59) + } + interval = setInterval(() => { + setRefreshSecond((refreshSecond) => refreshSecond - 1) + }, 1000) + } else if (!refreshOn) { + clearInterval(interval) + } + return () => clearInterval(interval) + }, [refreshOn, refreshSecond]) + + function handleDrag(_e: any, data: DraggableData) { + setOffsetPositionX(data.x) + setOffsetPositionY(data.y) + } + + const addLineBreaks = (text: string) => + text.split('\\n').map((text, index) => ( + + {text} +
+
+ )) + + if (!data.device) return null + + return ( + <> + {open && ( + +
+
+ {navigation.state === 'loading' && ( +
+ +
+ )} + {/* this is the header */} +
+
+
+ {data.device.name} +
+ + + + + + + Share this link + + + + Close + + + + + setOpen(false)} + /> + { + void navigate({ + pathname: '/explore', + search: searchParams.toString(), + }) + }} + /> +
+
+ {/* this is the metadata */} +
+ + +
+ {/* here are the tags */} + {data.device.tags && } + + {/* log entry component */} + {data.device.logEntries.length > 0 && ( + <> + + + + )} + {/* description component */} + {data.device.description && ( + + + + Description + + + {addLineBreaks(data.device.description)} + + + + )} + {/* sensors component */} + + + + Sensors + + + {sensors && } + + + +
+
+
+
+ )} + {!open && ( +
{ + setOpen(true) + }} + className="absolute bottom-[10px] left-4 flex cursor-pointer rounded-xl border border-gray-100 bg-white shadow-lg transition-colors duration-300 ease-in-out hover:brightness-90 dark:bg-zinc-800 dark:text-zinc-200 dark:opacity-90 sm:bottom-[30px] sm:left-[10px]" + > + + + +
+ +
+
+ +

Open device details

+
+
+
+
+ )} + + ) +} diff --git a/app/components/device-overview/device-image.tsx b/app/components/device-overview/device-image.tsx new file mode 100644 index 00000000..ca471ac6 --- /dev/null +++ b/app/components/device-overview/device-image.tsx @@ -0,0 +1,19 @@ +import { Image as ImageIcon } from 'lucide-react' + +export default function DeviceImage({ image }: { image: string | null }) { + return ( +
+ {image ? ( + device_image + ) : ( +
+ +
+ )} +
+ ) +} diff --git a/app/components/device-overview/device-metadata-info.tsx b/app/components/device-overview/device-metadata-info.tsx new file mode 100644 index 00000000..8a66d0bb --- /dev/null +++ b/app/components/device-overview/device-metadata-info.tsx @@ -0,0 +1,51 @@ +import { Separator } from '../ui/separator' +import { format } from 'date-fns' +import { CalendarPlus, Cpu, LandPlot, Rss } from 'lucide-react' +import InfoItem from './info-item' + +export default function DeviceMetadataInfo({ + exposure, + sensorWikiModel, + updatedAt, + createdAt, + expiresAt, +}: { + exposure: string | null + sensorWikiModel: string | null + updatedAt: Date + createdAt: Date + expiresAt?: Date | null +}) { + return ( +
+ + + + + + + {expiresAt && ( + <> + + + + )} +
+ ) +} diff --git a/app/components/device-overview/device-options.tsx b/app/components/device-overview/device-options.tsx new file mode 100644 index 00000000..10550a1b --- /dev/null +++ b/app/components/device-overview/device-options.tsx @@ -0,0 +1,77 @@ +import { Link } from 'react-router' +import { Archive, EllipsisVertical, ExternalLink, Scale } from 'lucide-react' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown-menu' +import { getArchiveLink } from '~/utils/device' + +export default function DeviceOptions({ + id, + name, + link, +}: { + id: string + name: string + link: string | null +}) { + return ( + + + + + + Actions + + + + + Compare + + + + + + + Archive + + + + + + + + External Link + + + + + + ) +} diff --git a/app/components/device-overview/device-tags.tsx b/app/components/device-overview/device-tags.tsx new file mode 100644 index 00000000..86b5a6bb --- /dev/null +++ b/app/components/device-overview/device-tags.tsx @@ -0,0 +1,70 @@ +import { useNavigate, useSearchParams } from 'react-router' +import { Hash } from 'lucide-react' +import { Badge } from '../ui/badge' +import clsx from 'clsx' + +export default function DeviceTags({ tags }: { tags: string[] }) { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + + return ( + <> + {tags && tags.length > 0 && ( +
+
+
+ Tags +
+
+ +
+ {tags.map((tag: string) => ( + { + event.stopPropagation() + + const currentParams = new URLSearchParams( + searchParams.toString(), + ) + + // Safely retrieve and parse the current tags + const currentTags = + currentParams.get('tags')?.split(',') || [] + + // Toggle the tag in the list + const updatedTags = currentTags.includes(tag) + ? currentTags.filter((t) => t !== tag) // Remove if already present + : [...currentTags, tag] // Add if not present + + // Update the tags parameter or remove it if empty + if (updatedTags.length > 0) { + currentParams.set('tags', updatedTags.join(',')) + } else { + currentParams.delete('tags') + } + + // Update the URL with the new search params + void navigate({ + search: currentParams.toString(), + }) + }} + > + {tag} + + ))} +
+
+
+
+ )} + + ) +} diff --git a/app/components/device-detail/entry-logs.tsx b/app/components/device-overview/entry-logs.tsx similarity index 100% rename from app/components/device-detail/entry-logs.tsx rename to app/components/device-overview/entry-logs.tsx diff --git a/app/components/device-detail/graph.tsx b/app/components/device-overview/graph.tsx similarity index 100% rename from app/components/device-detail/graph.tsx rename to app/components/device-overview/graph.tsx diff --git a/app/components/device-overview/info-item.tsx b/app/components/device-overview/info-item.tsx new file mode 100644 index 00000000..31368493 --- /dev/null +++ b/app/components/device-overview/info-item.tsx @@ -0,0 +1,20 @@ +const InfoItem = ({ + icon: Icon, + title, + text, +}: { + icon: React.ElementType + title: string + text?: string +}) => + text && ( +
+
{title}
+
+ + {text} +
+
+ ) + +export default InfoItem diff --git a/app/components/device-detail/profile-box-selection.tsx b/app/components/device-overview/profile-box-selection.tsx similarity index 100% rename from app/components/device-detail/profile-box-selection.tsx rename to app/components/device-overview/profile-box-selection.tsx diff --git a/app/components/device-overview/sensor-cards.tsx b/app/components/device-overview/sensor-cards.tsx new file mode 100644 index 00000000..5adfbce0 --- /dev/null +++ b/app/components/device-overview/sensor-cards.tsx @@ -0,0 +1,196 @@ +import { formatDistanceToNow } from 'date-fns' +import { + Link, + useMatches, + useNavigation, + useParams, + useSearchParams, +} from 'react-router' +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '../ui/card' +import { toast } from '../ui/use-toast' +import SensorIcon from '../sensor-icon' +import { type SensorWithLatestMeasurement } from '~/schema' +import { Separator } from '../ui/separator' + +export default function SensorCards({ + sensors, +}: { + sensors: SensorWithLatestMeasurement[] +}) { + const navigation = useNavigation() + const matches = useMatches() + const [searchParams] = useSearchParams() + const { deviceId } = useParams() + + const sensorIds = new Set() + + const createSensorLink = (sensorIdToBeSelected: string) => { + const lastSegment = matches[matches.length - 1]?.params?.['*'] + if (lastSegment) { + const secondLastSegment = matches[matches.length - 2]?.params?.sensorId + sensorIds.add(secondLastSegment) + sensorIds.add(lastSegment) + } else { + const lastSegment = matches[matches.length - 1]?.params?.sensorId + if (lastSegment) { + sensorIds.add(lastSegment) + } + } + + // If sensorIdToBeSelected is second selected sensor + if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 2) { + const clonedSet = new Set(sensorIds) + clonedSet.delete(sensorIdToBeSelected) + return `/explore/${deviceId}/${Array.from(clonedSet).join('/')}?${searchParams.toString()}` + } else if (sensorIds.has(sensorIdToBeSelected) && sensorIds.size === 1) { + return `/explore/${deviceId}?${searchParams.toString()}` + } else if (sensorIds.size === 0) { + return `/explore/${deviceId}/${sensorIdToBeSelected}?${searchParams.toString()}` + } else if (sensorIds.size === 1) { + return `/explore/${deviceId}/${Array.from(sensorIds).join('/')}/${sensorIdToBeSelected}?${searchParams.toString()}` + } + + return '' + } + + const isSensorActive = (sensorId: string) => { + if (sensorIds.has(sensorId)) { + return 'bg-green-100 dark:bg-dark-green' + } + return 'hover:bg-muted' + } + + return ( +
+
+ {sensors && + sensors.map((sensor) => { + const sensorLink = createSensorLink(sensor.id) + if (sensorLink === '') { + return ( + + toast({ + title: 'Cant select more than 2 sensors', + description: 'Deselect one sensor to select another', + variant: 'destructive', + }) + } + > + + + ) + } + return ( + + + + + + ) + })} +
+
+ ) +} diff --git a/app/components/device-detail/share-link.tsx b/app/components/device-overview/share-link.tsx similarity index 100% rename from app/components/device-detail/share-link.tsx rename to app/components/device-overview/share-link.tsx diff --git a/app/components/map/layers/cluster/box-marker.tsx b/app/components/map/layers/cluster/box-marker.tsx index 0d62048e..7d079856 100644 --- a/app/components/map/layers/cluster/box-marker.tsx +++ b/app/components/map/layers/cluster/box-marker.tsx @@ -1,121 +1,126 @@ -import { AnimatePresence, motion } from "framer-motion"; -import { Box, Rocket } from "lucide-react"; -import { useState } from "react"; -import { type MarkerProps, Marker, useMap } from "react-map-gl"; -import { useMatches, useNavigate, useSearchParams } from "react-router"; -import { useSharedCompareMode } from "~/components/device-detail/device-detail-box"; -import { cn } from "~/lib/utils"; -import { type Device } from "~/schema"; +import { AnimatePresence, motion } from 'framer-motion' +import { Box, Rocket } from 'lucide-react' +import { useState } from 'react' +import { type MarkerProps, Marker, useMap } from 'react-map-gl' +import { + useLocation, + useMatches, + useNavigate, + useSearchParams, +} from 'react-router' +import { cn } from '~/lib/utils' +import { type Device } from '~/schema' interface BoxMarkerProps extends MarkerProps { - device: Device; + device: Device } const getStatusColor = (device: Device) => { - if (device.status === "active") { - if (device.exposure === "mobile") { - return "bg-blue-100"; - } - return "bg-green-300"; - } else if (device.status === "inactive") { - return "bg-gray-100"; - } else { - return "bg-gray-100 opacity-50"; - } -}; + if (device.status === 'active') { + if (device.exposure === 'mobile') { + return 'bg-blue-100' + } + return 'bg-green-300' + } else if (device.status === 'inactive') { + return 'bg-gray-100' + } else { + return 'bg-gray-100 opacity-50' + } +} export default function BoxMarker({ device, ...props }: BoxMarkerProps) { - const navigate = useNavigate(); - const matches = useMatches(); - const { osem } = useMap(); - const { compareMode, setCompareMode } = useSharedCompareMode(); + const navigate = useNavigate() + const location = useLocation() + const matches = useMatches() + const { osem } = useMap() + + const compareMode = location.pathname.endsWith('/compare') - const isFullZoom = osem && osem?.getZoom() >= 14; + const isFullZoom = osem && osem?.getZoom() >= 14 - const [isHovered, setIsHovered] = useState(false); - const [searchParams] = useSearchParams(); + const [isHovered, setIsHovered] = useState(false) + const [searchParams] = useSearchParams() - // calculate zIndex based on device status and hover - const getZIndex = () => { - if (isHovered) { - return 30; - } - // priority to active devices - if (device.status === "active") { - return 20; - } - if (device.status === "inactive") { - return 10; - } + // calculate zIndex based on device status and hover + const getZIndex = () => { + if (isHovered) { + return 30 + } + // priority to active devices + if (device.status === 'active') { + return 20 + } + if (device.status === 'inactive') { + return 10 + } - return 0; - }; + return 0 + } - return ( - - - { - if (searchParams.has("sensor")) { - searchParams.delete("sensor"); - } - if (compareMode) { - void navigate( - `/explore/${matches[2].params.deviceId}/compare/${device.id}`, - ); - setCompareMode(false); - return; - } - void navigate({ - pathname: `${device.id}`, - search: searchParams.toString(), - }); - }} - onHoverStart={() => setIsHovered(true)} - onHoverEnd={() => setIsHovered(false)} - > - - {device.exposure === "mobile" ? ( - - ) : ( - - )} - {device.status === "active" ? ( -
- ) : null} - - {isFullZoom ? ( - - {device.name} - - ) : null} - - - - ); + return ( + + + { + if (searchParams.has('sensor')) { + searchParams.delete('sensor') + } + if (compareMode) { + void navigate( + `/explore/${matches[2].params.deviceId}/compare/${device.id}`, + ) + return + } + void navigate({ + pathname: `${device.id}`, + search: searchParams.toString(), + }) + }} + onHoverStart={() => setIsHovered(true)} + onHoverEnd={() => setIsHovered(false)} + > + + {device.exposure === 'mobile' ? ( + + ) : ( + + )} + {device.status === 'active' ? ( +
+ ) : null} + + {isFullZoom ? ( + + {device.name} + + ) : null} + + + + ) } diff --git a/app/components/search/search-list.tsx b/app/components/search/search-list.tsx index ab460b6a..fbddd7d6 100644 --- a/app/components/search/search-list.tsx +++ b/app/components/search/search-list.tsx @@ -1,170 +1,176 @@ -import { Cpu, Globe, MapPin } from "lucide-react"; -import { useState, useEffect, useCallback, useContext } from "react"; -import { useMap } from "react-map-gl"; -import { useMatches, useNavigate, useSearchParams } from "react-router"; +import { Cpu, Globe, MapPin } from 'lucide-react' +import { useState, useEffect, useCallback, useContext } from 'react' +import { useMap } from 'react-map-gl' +import { + useLocation, + useMatches, + useNavigate, + useSearchParams, +} from 'react-router' -import { useSharedCompareMode } from "../device-detail/device-detail-box"; -import { NavbarContext } from "../header/nav-bar"; -import useKeyboardNav from "../header/nav-bar/use-keyboard-nav"; -import SearchListItem from "./search-list-item"; -import { goTo } from "~/lib/search-map-helper"; +import { NavbarContext } from '../header/nav-bar' +import useKeyboardNav from '../header/nav-bar/use-keyboard-nav' +import SearchListItem from './search-list-item' +import { goTo } from '~/lib/search-map-helper' interface SearchListProps { - searchResultsLocation: any[]; - searchResultsDevice: any[]; + searchResultsLocation: any[] + searchResultsDevice: any[] } export default function SearchList(props: SearchListProps) { - const { osem } = useMap(); - const navigate = useNavigate(); - const { setOpen } = useContext(NavbarContext); - const { compareMode } = useSharedCompareMode(); - const matches = useMatches(); + const { osem } = useMap() + const location = useLocation() + const navigate = useNavigate() + const { setOpen } = useContext(NavbarContext) + const matches = useMatches() - const { cursor, setCursor, enterPress, controlPress } = useKeyboardNav( - 0, - 0, - props.searchResultsDevice.length + props.searchResultsLocation.length, - ); + const compareMode = location.pathname.endsWith('/compare') - const length = - props.searchResultsDevice.length + props.searchResultsLocation.length; - const searchResultsAll = props.searchResultsDevice.concat( - props.searchResultsLocation, - ); - const [selected, setSelected] = useState(searchResultsAll[cursor]); - const [searchParams] = useSearchParams(); - const [navigateTo, setNavigateTo] = useState( - compareMode - ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}` - : selected.type === "device" - ? `/explore/${selected.deviceId + "?" + searchParams.toString()}}` - : `/explore?${searchParams.toString()}`, - ); + const { cursor, setCursor, enterPress, controlPress } = useKeyboardNav( + 0, + 0, + props.searchResultsDevice.length + props.searchResultsLocation.length, + ) - const handleNavigate = useCallback( - (result: any) => { - return compareMode - ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}` - : result.type === "device" - ? `/explore/${result.deviceId + "?" + searchParams.toString()}` - : `/explore?${searchParams.toString()}`; - }, - [searchParams, compareMode, matches, selected], - ); + const length = + props.searchResultsDevice.length + props.searchResultsLocation.length + const searchResultsAll = props.searchResultsDevice.concat( + props.searchResultsLocation, + ) + const [selected, setSelected] = useState(searchResultsAll[cursor]) + const [searchParams] = useSearchParams() + const [navigateTo, setNavigateTo] = useState( + compareMode + ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}` + : selected.type === 'device' + ? `/explore/${selected.deviceId + '?' + searchParams.toString()}}` + : `/explore?${searchParams.toString()}`, + ) - const setShowSearchCallback = useCallback( - (state: boolean) => { - setOpen(state); - }, - [setOpen], - ); + const handleNavigate = useCallback( + (result: any) => { + return compareMode + ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}` + : result.type === 'device' + ? `/explore/${result.deviceId + '?' + searchParams.toString()}` + : `/explore?${searchParams.toString()}` + }, + [searchParams, compareMode, matches, selected], + ) - const handleDigitPress = useCallback( - (event: any) => { - if ( - typeof Number(event.key) === "number" && - Number(event.key) <= length && - controlPress - ) { - event.preventDefault(); - setCursor(Number(event.key) - 1); - goTo(osem, searchResultsAll[Number(event.key) - 1]); - setTimeout(() => { - setShowSearchCallback(false); - void navigate(handleNavigate(searchResultsAll[Number(event.key) - 1])); - }, 500); - } - }, - [ - controlPress, - length, - navigate, - osem, - searchResultsAll, - setCursor, - setShowSearchCallback, - handleNavigate, - ], - ); + const setShowSearchCallback = useCallback( + (state: boolean) => { + setOpen(state) + }, + [setOpen], + ) - useEffect(() => { - setSelected(searchResultsAll[cursor]); - }, [cursor, searchResultsAll]); + const handleDigitPress = useCallback( + (event: any) => { + if ( + typeof Number(event.key) === 'number' && + Number(event.key) <= length && + controlPress + ) { + event.preventDefault() + setCursor(Number(event.key) - 1) + goTo(osem, searchResultsAll[Number(event.key) - 1]) + setTimeout(() => { + setShowSearchCallback(false) + void navigate(handleNavigate(searchResultsAll[Number(event.key) - 1])) + }, 500) + } + }, + [ + controlPress, + length, + navigate, + osem, + searchResultsAll, + setCursor, + setShowSearchCallback, + handleNavigate, + ], + ) - useEffect(() => { - const navigate = handleNavigate(selected); - setNavigateTo(navigate); - }, [selected, handleNavigate]); + useEffect(() => { + setSelected(searchResultsAll[cursor]) + }, [cursor, searchResultsAll]) - useEffect(() => { - if (length !== 0 && enterPress) { - goTo(osem, selected); - setShowSearchCallback(false); - void navigate(navigateTo); - } - }, [ - enterPress, - osem, - navigate, - selected, - setShowSearchCallback, - navigateTo, - length, - ]); + useEffect(() => { + const navigate = handleNavigate(selected) + setNavigateTo(navigate) + }, [selected, handleNavigate]) - useEffect(() => { - // attach the event listener - window.addEventListener("keydown", handleDigitPress); + useEffect(() => { + if (length !== 0 && enterPress) { + goTo(osem, selected) + setShowSearchCallback(false) + void navigate(navigateTo) + } + }, [ + enterPress, + osem, + navigate, + selected, + setShowSearchCallback, + navigateTo, + length, + ]) - // remove the event listener - return () => { - window.removeEventListener("keydown", handleDigitPress); - }; - }); + useEffect(() => { + // attach the event listener + window.addEventListener('keydown', handleDigitPress) - return ( -
- {props.searchResultsDevice.length > 0 && ( -
- )} - {props.searchResultsDevice.map((device: any, i) => ( - setCursor(i)} - onClick={() => { - goTo(osem, device); - setShowSearchCallback(false); - void navigate(navigateTo); - }} - /> - ))} - {props.searchResultsLocation.length > 0 && ( -
- )} - {props.searchResultsLocation.map((location: any, i) => { - return ( - setCursor(i + props.searchResultsDevice.length)} - onClick={() => { - goTo(osem, location); - setShowSearchCallback(false); - void navigate(navigateTo); - }} - /> - ); - })} -
- ); + // remove the event listener + return () => { + window.removeEventListener('keydown', handleDigitPress) + } + }) + + return ( +
+ {props.searchResultsDevice.length > 0 && ( +
+ )} + {props.searchResultsDevice.map((device: any, i) => ( + setCursor(i)} + onClick={() => { + goTo(osem, device) + setShowSearchCallback(false) + void navigate(navigateTo) + }} + /> + ))} + {props.searchResultsLocation.length > 0 && ( +
+ )} + {props.searchResultsLocation.map((location: any, i) => { + return ( + setCursor(i + props.searchResultsDevice.length)} + onClick={() => { + goTo(osem, location) + setShowSearchCallback(false) + void navigate(navigateTo) + }} + /> + ) + })} +
+ ) } diff --git a/app/models/device.server.ts b/app/models/device.server.ts index a030a883..0a090d2e 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -64,14 +64,6 @@ export function getDevice({ id }: Pick) { export function getDeviceWithoutSensors({ id }: Pick) { return drizzleClient.query.device.findFirst({ where: (device, { eq }) => eq(device.id, id), - columns: { - id: true, - name: true, - exposure: true, - updatedAt: true, - latitude: true, - longitude: true, - }, }) } diff --git a/app/routes/explore.$deviceId.$sensorId.$.tsx b/app/routes/explore.$deviceId.$sensorId.$.tsx index 768c13d1..fc597a06 100644 --- a/app/routes/explore.$deviceId.$sensorId.$.tsx +++ b/app/routes/explore.$deviceId.$sensorId.$.tsx @@ -1,6 +1,6 @@ import { addDays } from "date-fns"; import { redirect, type LoaderFunctionArgs, useLoaderData } from "react-router"; -import Graph from "~/components/device-detail/graph"; +import Graph from "~/components/device-overview/graph"; import MobileBoxView from "~/components/map/layers/mobile/mobile-box-view"; import { getDevice } from "~/models/device.server"; import { getMeasurement } from "~/models/measurement.server"; diff --git a/app/routes/explore.$deviceId.compare_.tsx b/app/routes/explore.$deviceId.compare_.tsx new file mode 100644 index 00000000..fb98c4b6 --- /dev/null +++ b/app/routes/explore.$deviceId.compare_.tsx @@ -0,0 +1,29 @@ +import { XSquare } from 'lucide-react' +import { useLocation, useNavigate } from 'react-router' +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert' + +export default function Compare() { + const navigate = useNavigate() + const location = useLocation() + + const compareMode = location.pathname.endsWith('/compare') + + return ( + <> + {compareMode && ( + + { + void navigate('/explore') + }} + /> + Compare devices + + Choose a device from the map to compare with. + + + )} + + ) +} diff --git a/app/routes/explore.$deviceId.tsx b/app/routes/explore.$deviceId.tsx index 31e56343..da2ff3f4 100644 --- a/app/routes/explore.$deviceId.tsx +++ b/app/routes/explore.$deviceId.tsx @@ -1,88 +1,87 @@ -import { useState } from "react"; -import { type LoaderFunctionArgs, Outlet, useLoaderData, useMatches } from "react-router"; +import { useState } from 'react' +import { + type LoaderFunctionArgs, + Outlet, + useLoaderData, + useLocation, + useMatches, +} from 'react-router' -import DeviceDetailBox from "~/components/device-detail/device-detail-box"; -import ErrorMessage from "~/components/error-message"; -import { HoveredPointContext } from "~/components/map/layers/mobile/mobile-box-layer"; -import MobileOverviewLayer from "~/components/map/layers/mobile/mobile-overview-layer"; -import i18next from "~/i18next.server"; -import { type LocationPoint } from "~/lib/mobile-box-helper"; -import { getDevice } from "~/models/device.server"; -import { getSensorsWithLastMeasurement } from "~/models/sensor.server"; +import DeviceDetailBox from '~/components/device-overview/device-detail-box' +import { HoveredPointContext } from '~/components/map/layers/mobile/mobile-box-layer' +import MobileOverviewLayer from '~/components/map/layers/mobile/mobile-overview-layer' +import i18next from '~/i18next.server' +import { type LocationPoint } from '~/lib/mobile-box-helper' +import { getDevice } from '~/models/device.server' +import { getSensorsWithLastMeasurement } from '~/models/sensor.server' export async function loader({ params, request }: LoaderFunctionArgs) { - const locale = await i18next.getLocale(request); - // Extracting the selected sensors from the URL query parameters using the stringToArray function - const url = new URL(request.url); + const locale = await i18next.getLocale(request) + // Extracting the selected sensors from the URL query parameters using the stringToArray function + const url = new URL(request.url) - if (!params.deviceId) { - throw new Response("Device not found", { status: 502 }); - } + if (!params.deviceId) { + throw new Response('Device not found', { status: 502 }) + } - const device = await getDevice({ id: params.deviceId }); - const sensorsWithLastestMeasurement = await getSensorsWithLastMeasurement( - params.deviceId, - ); + const device = await getDevice({ id: params.deviceId }) + const sensorsWithLastestMeasurement = await getSensorsWithLastMeasurement( + params.deviceId, + ) - // Find all sensors from the device response that have the same id as one of the sensor array value - const aggregation = url.searchParams.get("aggregation") || "raw"; - const startDate = url.searchParams.get("date_from") || undefined; - const endDate = url.searchParams.get("date_to") || undefined; + // Find all sensors from the device response that have the same id as one of the sensor array value + const aggregation = url.searchParams.get('aggregation') || 'raw' + const startDate = url.searchParams.get('date_from') || undefined + const endDate = url.searchParams.get('date_to') || undefined - // Combine the device data with the selected sensors and return the result as JSON + add env variable - const data = { - device: device, - sensors: sensorsWithLastestMeasurement, - aggregation: aggregation, - fromDate: startDate, - toDate: endDate, - OSEM_API_URL: process.env.OSEM_API_URL, - locale: locale, - }; + // Combine the device data with the selected sensors and return the result as JSON + add env variable + const data = { + device: device, + sensors: sensorsWithLastestMeasurement, + aggregation: aggregation, + fromDate: startDate, + toDate: endDate, + OSEM_API_URL: process.env.OSEM_API_URL, + locale: locale, + } - return data; + return data } -// Defining the component that will render the page export default function DeviceId() { - // Retrieving the data returned by the loader using the useLoaderData hook - const data = useLoaderData(); - const matches = useMatches(); - const isSensorView = matches[matches.length - 1].params.sensorId - ? true - : false; - const [hoveredPoint, setHoveredPoint] = useState(null); + const data = useLoaderData() + const location = useLocation() + const matches = useMatches() - const setHoveredPointDebug = (point: any) => { - setHoveredPoint(point); - }; + const compareMode = location.pathname.endsWith('/compare') - if (!data?.device && !data.sensors) { - return null; - } + const isSensorView = matches[matches.length - 1].params.sensorId + ? true + : false + const [hoveredPoint, setHoveredPoint] = useState(null) - return ( - <> - - {/* If the box is mobile, iterate over selected sensors and show trajectory */} - {data.device?.exposure === "mobile" && !isSensorView && ( - - )} - - - - - ); -} + const setHoveredPointDebug = (point: any) => { + setHoveredPoint(point) + } + + if (!data?.device && !data.sensors) { + return null + } -export function ErrorBoundary() { - return ( -
- -
- ); + return ( + <> + + {/* If the box is mobile, iterate over selected sensors and show trajectory */} + {data.device?.exposure === 'mobile' && !isSensorView && ( + + )} + {!compareMode && } + + + + ) } diff --git a/app/routes/explore.$deviceId_.compare.$deviceId2.$sensorId.$.tsx b/app/routes/explore.$deviceId_.compare.$deviceId2.$sensorId.$.tsx new file mode 100644 index 00000000..6982c204 --- /dev/null +++ b/app/routes/explore.$deviceId_.compare.$deviceId2.$sensorId.$.tsx @@ -0,0 +1,94 @@ +import { addDays } from 'date-fns' +import { redirect, type LoaderFunctionArgs, useLoaderData } from 'react-router' +import Graph from '~/components/device-overview/graph' +import MobileBoxView from '~/components/map/layers/mobile/mobile-box-view' +import { getDevice } from '~/models/device.server' +import { getMeasurement } from '~/models/measurement.server' +import { getSensor } from '~/models/sensor.server' +import { type SensorWithMeasurementData } from '~/schema' + +interface SensorWithColor extends SensorWithMeasurementData { + color: string +} + +export async function loader({ params, request }: LoaderFunctionArgs) { + const { deviceId, sensorId } = params + const { deviceId2 } = params + const sensorId2 = params['*'] + + if (!deviceId) { + return redirect('/explore') + } + + const device = await getDevice({ id: deviceId }) + + if (!device) { + return redirect('/explore') + } + + const url = new URL(request.url) + const aggregation = url.searchParams.get('aggregation') || 'raw' + const startDate = url.searchParams.get('date_from') + const endDate = url.searchParams.get('date_to') + + if (!sensorId) { + throw new Response('Sensor 1 not found', { status: 404 }) + } + + const sensor1 = (await getSensor(sensorId)) as SensorWithColor + const sensor1Data = await getMeasurement( + sensorId, + aggregation, + startDate ? new Date(startDate) : undefined, + endDate ? addDays(new Date(endDate), 1) : undefined, + ) + + sensor1.data = sensor1Data + sensor1.color = sensor1.color || '#8da0cb' + + let sensor2: SensorWithColor | null = null + + if (sensorId2) { + sensor2 = (await getSensor(sensorId2)) as SensorWithColor + const sensor2Data = await getMeasurement( + sensorId2, + aggregation, + startDate ? new Date(startDate) : undefined, + endDate ? addDays(new Date(endDate), 1) : undefined, + ) + + sensor2.data = sensor2Data + sensor2.color = sensor2.color || '#fc8d62' + } + + // if the two sensors are from two different devices, we need to add the device name to the sensor title + if (sensor2 && deviceId2 && sensor1.deviceId !== sensor2.deviceId) { + const device2 = await getDevice({ id: deviceId2 }) + if (sensor1.deviceId === deviceId) { + sensor1.title = `${device.name} - ${sensor1.title}` + } else { + sensor1.title = `${device2?.name} - ${sensor1.title}` + } + if (sensor2.deviceId === deviceId) { + sensor2.title = `${device.name} - ${sensor2.title}` + } else { + sensor2.title = `${device2?.name} - ${sensor2.title}` + } + } + + return { + device, + sensors: sensor2 ? [sensor1, sensor2] : [sensor1], + startDate, + endDate, + aggregation, + } +} + +export default function SensorView() { + const loaderData = useLoaderData() + + return ( + + ) +} diff --git a/app/routes/explore.$deviceId_.compare.$deviceId2.tsx b/app/routes/explore.$deviceId_.compare.$deviceId2.tsx new file mode 100644 index 00000000..acdd0955 --- /dev/null +++ b/app/routes/explore.$deviceId_.compare.$deviceId2.tsx @@ -0,0 +1,70 @@ +import { + type LoaderFunctionArgs, + Outlet, + redirect, + useLoaderData, +} from 'react-router' +import CompareDeviceBox from '~/components/compare-devices/compare-device-box' + +import i18next from '~/i18next.server' +import { getDevice, getDeviceWithoutSensors } from '~/models/device.server' +import { getSensorsWithLastMeasurement } from '~/models/sensor.server' + +export async function loader({ params, request }: LoaderFunctionArgs) { + const locale = await i18next.getLocale(request) + // Extracting the selected sensors from the URL query parameters using the stringToArray function + const url = new URL(request.url) + + if (!params.deviceId) { + // TODO: Alert user that device not found + return redirect('/explore') + } + + if (!params.deviceId2) { + return redirect(`/explore/${params.deviceId}/compare`) + } + + const device1 = await getDeviceWithoutSensors({ id: params.deviceId }) + const sensorsDevice1 = await getSensorsWithLastMeasurement(params.deviceId) + const device1WithSensors = { + ...device1, + sensors: sensorsDevice1, + } + + const device2 = await getDevice({ id: params.deviceId2 }) + const sensorsDevice2 = await getSensorsWithLastMeasurement(params.deviceId2) + const device2WithSensors = { + ...device2, + sensors: sensorsDevice2, + } + + const devices = [device1WithSensors, device2WithSensors] + + // Find all sensors from the device response that have the same id as one of the sensor array value + const aggregation = url.searchParams.get('aggregation') || 'raw' + const startDate = url.searchParams.get('date_from') || undefined + const endDate = url.searchParams.get('date_to') || undefined + + // Combine the device data with the selected sensors and return the result as JSON + add env variable + const data = { + devices, + aggregation, + fromDate: startDate, + toDate: endDate, + OSEM_API_URL: process.env.OSEM_API_URL, + locale, + } + + return data +} + +export default function CompareDevices() { + const data = useLoaderData() + + return ( + <> + + + + ) +} diff --git a/app/utils/device.ts b/app/utils/device.ts index 999815cb..3d87ca0b 100644 --- a/app/utils/device.ts +++ b/app/utils/device.ts @@ -8,7 +8,7 @@ const doubleGermanS = function (value: string) { return value; }; -export function getArchiveLink(device: any) { +export function getArchiveLink({ name, id } : { name: string, id: string }) { const date = new Date(); date.setDate(date.getDate() - 1); const yesterday = date @@ -17,8 +17,8 @@ export function getArchiveLink(device: any) { .reverse() .join("-"); const normalizedName = doubleGermanS( - device.name.replace(/[^A-Za-z0-9._-]/g, "_") + name.replace(/[^A-Za-z0-9._-]/g, "_") ); - return `https://archive.opensensemap.org/${yesterday}/${device.id}-${normalizedName}`; + return `https://archive.opensensemap.org/${yesterday}/${id}-${normalizedName}`; }