diff --git a/bun.lockb b/bun.lockb index 0a097f48..0186093f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 39ed0974..7dffbb52 100644 --- a/package.json +++ b/package.json @@ -48,11 +48,14 @@ }, "dependencies": { "@mapbox/polyline": "^1.2.1", + "@solid-primitives/keyboard": "^1.2.8", "@solidjs/router": "^0.13.5", "clsx": "^1.2.1", "dayjs": "^1.11.11", "hls.js": "^1.5.11", - "solid-js": "^1.8.17" + "mapbox-gl": "^3.4.0", + "solid-js": "^1.8.17", + "solid-map-gl": "^1.11.3" }, "engines": { "node": ">=20.11.0" diff --git a/public/images/logo-connect-placeholder.svg b/public/images/logo-connect-placeholder.svg new file mode 100644 index 00000000..5e979b52 --- /dev/null +++ b/public/images/logo-connect-placeholder.svg @@ -0,0 +1 @@ + diff --git a/src/App.tsx b/src/App.tsx index 24bce32f..52e3727f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ const App: VoidComponent = () => { - + ) } diff --git a/src/api/route.ts b/src/api/route.ts index 60bd0852..f0a047c7 100644 --- a/src/api/route.ts +++ b/src/api/route.ts @@ -1,6 +1,10 @@ import { fetcher } from '.' import { BASE_URL } from './config' import type { Device, Route, RouteShareSignature } from '~/types' +import { TimelineStatistics } from './derived' +import { getPlaceFromCoords } from '~/map' +import { getTimelineStatistics } from './derived' +import { formatRouteDistance, getRouteDuration } from '~/utils/date' export class RouteName { // dongle ID date str @@ -33,8 +37,47 @@ export class RouteName { } } -export const getRoute = (routeName: Route['fullname']): Promise => - fetcher(`/v1/route/${routeName}/`) +const formatEngagement = (timeline?: TimelineStatistics): number => { + if (!timeline) return 0 + const { engagedDuration, duration } = timeline + return parseInt((100 * (engagedDuration / duration)).toFixed(0)) +} + +const formatUserFlags = (timeline?: TimelineStatistics): number => { + return timeline?.userFlags ?? 0 +} + +export const getDerivedData = async(route: Route): Promise => { + const [startPlace, endPlace, timeline] = await Promise.all([ + getPlaceFromCoords(route.start_lng, route.start_lat), + getPlaceFromCoords(route.end_lng, route.end_lat), + getTimelineStatistics(route), + ]) + + route.ui_derived = { + distance: formatRouteDistance(route), + duration: getRouteDuration(route), + flags: formatUserFlags(timeline), + engagement: formatEngagement(timeline), + address: { + start: startPlace, + end: endPlace, + }, + } + + return route +} + +export const getRoute = (routeName: Route['fullname']): Promise => { + return new Promise((resolve, reject) => { + fetcher(`/v1/route/${routeName}/`) + .then(route => { + getDerivedData(route) + .then(res => resolve(res)) + .catch(() => resolve(route)) + }).catch(err => reject(err)) + }) +} export const getRouteShareSignature = (routeName: string): Promise => fetcher(`/v1/route/${routeName}/share_signature`) diff --git a/src/api/routelist.ts b/src/api/routelist.ts new file mode 100644 index 00000000..cd79d9f2 --- /dev/null +++ b/src/api/routelist.ts @@ -0,0 +1,62 @@ +import type { RouteSegments } from '~/types' +import { fetcher } from '.' +import { formatRouteDistance, getRouteDuration } from '~/utils/date' +import { TimelineStatistics, getTimelineStatistics } from '~/api/derived' +import { getPlaceFromCoords } from '~/map' + +const formatEngagement = (timeline?: TimelineStatistics): number => { + if (!timeline) return 0 + const { engagedDuration, duration } = timeline + return parseInt((100 * (engagedDuration / duration)).toFixed(0)) +} + +const formatUserFlags = (timeline?: TimelineStatistics): number => { + return timeline?.userFlags ?? 0 +} + +const endpoint = (dongleId: string | undefined, page_size: number) => `/v1/devices/${dongleId}/routes_segments?limit=${page_size}` +export const getKey = ( + dongleId:string | undefined, page_size: number, previousPageData?: RouteSegments[], +): string | undefined => { + if (!previousPageData && dongleId) return endpoint(dongleId, page_size) + if(previousPageData) { // just to satisfy typescript + if (previousPageData.length === 0) return undefined + const lastSegmentEndTime = previousPageData.at(-1)!.segment_start_times.at(-1)! + return `${endpoint(dongleId, page_size)}&end=${lastSegmentEndTime - 1}` + } +} + +export const getRouteCardsData = async (url: string | undefined): Promise => { + if (!url) return [] + + try { + const res = await fetcher(url) + + // TODO: use getDerviedData() /api/routes here to reduce code + const updatedRes = await Promise.all(res.map(async (each) => { + const [startPlace, endPlace, timeline] = await Promise.all([ + getPlaceFromCoords(each.start_lng, each.start_lat), + getPlaceFromCoords(each.end_lng, each.end_lat), + getTimelineStatistics(each), + ]) + + each.ui_derived = { + distance: formatRouteDistance(each), + duration: getRouteDuration(each), + flags: formatUserFlags(timeline), + engagement: formatEngagement(timeline), + address: { + start: startPlace, + end: endPlace, + }, + } + + return each + })) + + return updatedRes + } catch (err) { + console.error(err) + return [] + } +} diff --git a/src/components/Dates.tsx b/src/components/Dates.tsx new file mode 100644 index 00000000..a8f4307e --- /dev/null +++ b/src/components/Dates.tsx @@ -0,0 +1,106 @@ +import { VoidComponent, createEffect, createSignal, onMount, useContext } from 'solid-js' +import Button from './material/Button' +import Avatar from './material/Avatar' +import Icon from './material/Icon' +import Modal from './Modal' +import { DashboardContext, generateContextType } from '~/pages/dashboard/Dashboard' + +type Props = { + start: Date | null, + end: Date | null, + visible: boolean, + onSelect: (start: Date, end: Date) => void +} + +type InputProps = { + value: Date + onSelect: (value: Date) => void +} +const Input: VoidComponent = (props) => { + return { + const value = event.target.value + props.onSelect(value ? new Date(value) : props.value) + }} + /> +} + +const DatePicker: VoidComponent = (props) => { + + const [visible, setVisible] = createSignal(false) + const [start, setStart] = createSignal(new Date()) + const [end, setEnd] = createSignal(new Date()) + + createEffect(() => setVisible(props.visible)) + onMount(() => { + if(props.start) setStart(props.start) + if(props.end) setEnd(props.end) + }) + + return +
+
+

select a date range

+
+
+
+

From

+

To

+
+
+ setStart(value)} /> + setEnd(value)} /> +
+
+
+ +
+
+
+} + +type DisplayProps = { + value: Date, + isEnd?: boolean, + onClick: () => void +} + +const DateDisplay: VoidComponent = (props) => { + const { isDesktop } = useContext(DashboardContext) ?? generateContextType() + return
props.onClick()} class={`flex basis-1/2 flex-col ${props.isEnd && 'items-end'} justify-center`}> +

{props.isEnd ? 'To' : 'From'}

+

{props.value.toLocaleDateString('en-US', { year: 'numeric', month: isDesktop() ? 'long' : 'numeric', day: 'numeric' })}

+
+} + +const Dates: VoidComponent = () => { + + const [selector, openSelector] = createSignal(false) + const [dates, setDates] = createSignal({start: new Date(), end: new Date()}) + + return
+ { + setDates({ start, end }) + openSelector(false) + }} + /> + openSelector(true)} value={dates().start} /> +
+ openSelector(true)}> + calendar_month + +
+ openSelector(true)} value={dates().end} isEnd /> +
+} + +export default Dates diff --git a/src/components/DeviceStatistics.tsx b/src/components/DeviceStatistics.tsx index 3632be91..7b339da3 100644 --- a/src/components/DeviceStatistics.tsx +++ b/src/components/DeviceStatistics.tsx @@ -1,35 +1,40 @@ import { createResource } from 'solid-js' import type { VoidComponent } from 'solid-js' import clsx from 'clsx' - import { getDeviceStats } from '~/api/devices' import { formatDistance, formatDuration } from '~/utils/date' +import { Device } from '~/types' +import Icon from './material/Icon' type DeviceStatisticsProps = { class?: string - dongleId: string + device: Device | undefined } const DeviceStatistics: VoidComponent = (props) => { - const [statistics] = createResource(() => props.dongleId, getDeviceStats) + const [statistics] = createResource(() => props.device?.dongle_id, getDeviceStats) const allTime = () => statistics()?.all - return ( -
-
- Distance - {formatDistance(allTime()?.distance)} -
- -
- Duration - {formatDuration(allTime()?.minutes)} + type StatProps = { + icon: string + data: string + label: string + } + const Stat: VoidComponent = (props) => { + return
+
+ {`${props.icon}`} +

{props.data}

+

{props.label}

+
+ } -
- Routes - {allTime()?.routes ?? 0} -
+ return ( +
+ + +
) } diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx new file mode 100644 index 00000000..6e18c0cc --- /dev/null +++ b/src/components/Loader.tsx @@ -0,0 +1,13 @@ +import { VoidComponent } from 'solid-js' + +const Loader: VoidComponent = () => { + return
+ + Loading... +
+} + +export default Loader diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 00000000..c740d77f --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,15 @@ +import { createEffect, createSignal, type ParentComponent } from 'solid-js' + +type Props = { + visible: boolean; +} + +const Modal:ParentComponent = (props) => { + const [visible, setVisible] = createSignal(false) + createEffect(() => { + setVisible(props.visible) + }) + return
{props.children}
+} + +export default Modal diff --git a/src/components/PlaceHolder.tsx b/src/components/PlaceHolder.tsx new file mode 100644 index 00000000..84df0534 --- /dev/null +++ b/src/components/PlaceHolder.tsx @@ -0,0 +1,23 @@ +import { Show, VoidComponent, useContext } from 'solid-js' +import { DashboardContext, generateContextType } from '~/pages/dashboard/Dashboard' + +const PlaceHolder: VoidComponent = () => { + + const { isDesktop } = useContext(DashboardContext) ?? generateContextType() + + return +
+ comma connect +

connect

+

v0.1

+

select a drive to view

+
+
+} + +export default PlaceHolder diff --git a/src/components/RouteCard.tsx b/src/components/RouteCard.tsx index 91a3a594..15cffcb3 100644 --- a/src/components/RouteCard.tsx +++ b/src/components/RouteCard.tsx @@ -1,55 +1,61 @@ -import { Suspense, type VoidComponent } from 'solid-js' +import { Show, type VoidComponent, useContext } from 'solid-js' +import { useNavigate } from '@solidjs/router' import dayjs from 'dayjs' -import Avatar from '~/components/material/Avatar' -import Card, { CardContent, CardHeader } from '~/components/material/Card' import Icon from '~/components/material/Icon' import RouteStaticMap from '~/components/RouteStaticMap' -import RouteStatistics from '~/components/RouteStatistics' +import { RouteCardStatistics } from '~/components/RouteStatistics' import type { RouteSegments } from '~/types' - -const RouteHeader = (props: { route: RouteSegments }) => { - const startTime = () => dayjs(props.route.segment_start_times[0]) - const endTime = () => dayjs(props.route.segment_end_times.at(-1)) - - const headline = () => startTime().format('ddd, MMM D, YYYY') - const subhead = () => `${startTime().format('h:mm A')} to ${endTime().format('h:mm A')}` - - return ( - - directions_car - - } - /> - ) -} +import { DashboardContext, generateContextType } from '~/pages/dashboard/Dashboard' +import Timeline from './Timeline' interface RouteCardProps { route: RouteSegments } const RouteCard: VoidComponent = (props) => { + + const { width, isDesktop } = useContext(DashboardContext) ?? generateContextType() + const navigate = useNavigate() + + const startTime = () => dayjs(props.route.segment_start_times[0]) + const endTime = () => dayjs(props.route.segment_end_times.at(-1)) + + const headline = () => startTime().format('dddd, MMM D, YYYY') + const subhead = () => `${startTime().format('h:mm A')} to ${endTime().format('h:mm A')}` + return ( - - - -
- } - > - - +
{ + const path = props.route.fullname.split('|') + const url = `/${path[0]}/${path[1]}` + navigate(url) + }} class="my-2 flex h-44 w-full animate-exist rounded-md border border-secondary-container hover:bg-secondary-container"> + 23 && isDesktop()}> +
+
+ +
+
+
+
+

{headline()}

+

{subhead()}

+ +
23 ? 'flex w-full sm:w-3/4':'inline-flex'} h-6 items-center justify-center space-x-3 rounded-sm bg-black`}> + +

{props.route.ui_derived?.address?.start}

+
+ + arrow_forward +

{props.route.ui_derived?.address?.end}

+
+
+
+ +
- - - - - +
) } diff --git a/src/components/RouteStaticMap.tsx b/src/components/RouteStaticMap.tsx index d8e83d5d..5aaa2cd3 100644 --- a/src/components/RouteStaticMap.tsx +++ b/src/components/RouteStaticMap.tsx @@ -81,7 +81,7 @@ const RouteStaticMap: VoidComponent = (props) => { diff --git a/src/components/RouteStatistics.tsx b/src/components/RouteStatistics.tsx index 5e0ca456..c60546f4 100644 --- a/src/components/RouteStatistics.tsx +++ b/src/components/RouteStatistics.tsx @@ -1,56 +1,64 @@ -import { createResource, Suspense } from 'solid-js' import type { VoidComponent } from 'solid-js' import clsx from 'clsx' -import { TimelineStatistics, getTimelineStatistics } from '~/api/derived' import type { Route } from '~/types' -import { formatRouteDistance, formatRouteDuration } from '~/utils/date' - -const formatEngagement = (timeline?: TimelineStatistics): string => { - if (!timeline) return '' - const { engagedDuration, duration } = timeline - return `${(100 * (engagedDuration / duration)).toFixed(0)}%` -} - -const formatUserFlags = (timeline?: TimelineStatistics): string => { - return timeline?.userFlags.toString() ?? '' -} +import { formatRouteDuration } from '~/utils/date' +import Icon from './material/Icon' type RouteStatisticsProps = { class?: string route?: Route } -const RouteStatistics: VoidComponent = (props) => { - const [timeline] = createResource(() => props.route, getTimelineStatistics) - +export const RouteCardStatistics: VoidComponent = (props) => { return ( -
-
+
+
Distance - {formatRouteDistance(props.route)} + {props.route?.ui_derived?.distance}mi
-
+
Duration - {formatRouteDuration(props.route)} + {formatRouteDuration(props.route?.ui_derived?.duration)}
-
+
Engaged - - {formatEngagement(timeline())} - + {`${props.route?.ui_derived?.engagement}%`}
-
+
User flags - - {formatUserFlags(timeline())} - + {props.route?.ui_derived?.flags}
) } -export default RouteStatistics +export const DriveStatistics: VoidComponent = (props) => { + + type Props = { + icon: string + data: string | number | undefined + label: string + } + const Statistic: VoidComponent = (statisticProps) => { + return
+
+ +

{statisticProps?.data}

+
+
+

{statisticProps.label}

+
+
+ } + + return
+ + + + +
+} diff --git a/src/components/material/Avatar.tsx b/src/components/material/Avatar.tsx index c5c1734d..a28e909f 100644 --- a/src/components/material/Avatar.tsx +++ b/src/components/material/Avatar.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' type AvatarProps = { class?: string color?: string + onClick?: () => void } const Avatar: ParentComponent = (props) => { @@ -24,6 +25,7 @@ const Avatar: ParentComponent = (props) => { colorClasses(), props.class, )} + onClick={props.onClick} > {props.children}
diff --git a/src/index.css b/src/index.css index e28fa8a6..14bb9858 100644 --- a/src/index.css +++ b/src/index.css @@ -3,6 +3,7 @@ @tailwind utilities; @layer base { + /* https://m3.material.io/styles/color/roles */ :root { --color-primary: rgb(83 90 146); @@ -58,15 +59,15 @@ .dark, [data-theme="dark"] { - --color-primary: rgb(188 195 255); + --color-primary: rgb(145, 151, 201); --color-surface-tint: rgb(188 195 255); --color-on-primary: rgb(36 43 97); --color-primary-container: rgb(59 66 121); --color-on-primary-container: rgb(223 224 255); --color-secondary: rgb(196 197 221); --color-on-secondary: rgb(45 47 66); - --color-secondary-container: rgb(67 69 89); - --color-on-secondary-container: rgb(224 225 249); + --color-secondary-container: rgb(34, 34, 43); + --color-on-secondary-container: rgb(92, 92, 134); --color-tertiary: rgb(168 210 147); --color-on-tertiary: rgb(21 56 9); --color-tertiary-container: rgb(43 79 30); @@ -114,7 +115,8 @@ @apply select-none selection:bg-transparent; } - html, body { + html, + body { /* * fill visible viewport * https://developers.google.com/web/updates/2016/12/url-bar-resizing @@ -126,6 +128,23 @@ /* revert user-agent style */ text-align: unset; } + + ::-webkit-scrollbar { + width: 5px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: var(--color-secondary-container); + border-radius: 5px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--color-on-secondary-container); + } } @layer components { @@ -206,11 +225,14 @@ } .hide-scrollbar { - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE 10+ */ + scrollbar-width: none; + /* Firefox */ + -ms-overflow-style: none; + /* IE 10+ */ &::-webkit-scrollbar { - display: none; /* Safari and Chrome */ + display: none; + /* Safari and Chrome */ } } -} +} \ No newline at end of file diff --git a/src/map/DriveMap.tsx b/src/map/DriveMap.tsx new file mode 100644 index 00000000..8c54cc26 --- /dev/null +++ b/src/map/DriveMap.tsx @@ -0,0 +1,120 @@ +/* eslint-disable solid/style-prop */ +import { VoidComponent, createEffect, createSignal } from 'solid-js' +import MapGL, { Source, Layer, Viewport } from 'solid-map-gl' +import { MAPBOX_TOKEN, MAPBOX_USERNAME } from './config' +import { GPSPathPoint } from '~/api/derived' +import { getMapStyleId } from '.' +import { getThemeId } from '~/theme' +import { calculateAverageBearing } from './bearing' + +type Props = { + coords: GPSPathPoint[], + point: number +} + +const DriveMap: VoidComponent = (props) => { + + const coords = () => props.coords + const point = () => props.point + + const [marker, setMarker] = createSignal([coords()[0].lng, coords()[0].lat]) + const [viewport, setViewport] = createSignal({ + // eslint-disable-next-line solid/reactivity + center: marker(), + zoom: 12, + } as Viewport) + + let lastBearing: number = 180 + const BEARING_THRESHOLD = 15 + + const getBearing = (time: number) => { + const start = coords().findIndex(coord => coord.t === Math.round(time)) + const path = coords().slice(start, Math.min(start + 10, coords().length)) + + const newBearing = calculateAverageBearing(path) + if(Math.abs(newBearing - lastBearing) > BEARING_THRESHOLD) { + lastBearing = newBearing + return newBearing + } + return lastBearing + } + + createEffect(() => { + const coord = coords().find(coord => coord.t === Math.round(point())) + if(coord?.lat && coord.lng) { + setMarker([coord.lng, coord.lat]) + setViewport({ + center: [coord.lng, coord.lat], + zoom: 15, + pitch: 45, + bearing: getBearing(coord.t), + }) + } + }) + + return ( +
+
+ setViewport(evt)} + > + [coord.lng, coord.lat]), + }, + }, + }} + > + + + + + + +
+
+ ) +} + +export default DriveMap diff --git a/src/map/bearing.ts b/src/map/bearing.ts new file mode 100644 index 00000000..f38deb58 --- /dev/null +++ b/src/map/bearing.ts @@ -0,0 +1,27 @@ +import type { GPSPathPoint } from '~/api/derived' + +const calculateBearing = (start: GPSPathPoint, end: GPSPathPoint) => { + + const radians = (degree: number): number => degree * (Math.PI / 180.0) + const degrees = (radian: number): number => radian * (180.0 / Math.PI) + + const startLat = radians(start.lat) + const startLng = radians(start.lng) + const endLat = radians(end.lat) + const endLng = radians(end.lng) + + + const dLng = endLng - startLng + const dPhi = Math.log(Math.tan(endLat / 2 + Math.PI / 4) / Math.tan(startLat / 2 + Math.PI / 4)) + + const bearing = degrees(Math.atan2( + Math.abs(dLng) > Math.PI ? (dLng > 0 ? -(2 * Math.PI - dLng) : (2 * Math.PI + dLng)) : dLng, dPhi, + )) + + return (bearing + 360) % 360 +} + +export const calculateAverageBearing = (points: GPSPathPoint[]): number => + points.length < 2 ? 0 : + points.slice(0, -1) + .reduce((sum, point, i) => sum + calculateBearing(point, points[i + 1]), 0) / (points.length - 1) diff --git a/src/map/index.ts b/src/map/index.ts index cd52e234..c546d2d1 100644 --- a/src/map/index.ts +++ b/src/map/index.ts @@ -12,7 +12,7 @@ export type Coords = [number, number][] const POLYLINE_SAMPLE_SIZE = 50 const POLYLINE_PRECISION = 4 -function getMapStyleId(themeId: string): string { +export function getMapStyleId(themeId: string): string { return themeId === 'light' ? MAPBOX_LIGHT_STYLE_ID : MAPBOX_DARK_STYLE_ID } @@ -50,3 +50,21 @@ export function getPathStaticMapUrl( )})` return `https://api.mapbox.com/styles/v1/${MAPBOX_USERNAME}/${styleId}/static/${path}/auto/${width}x${height}${hidpiStr}?logo=false&attribution=false&padding=30,30,30,30&access_token=${MAPBOX_TOKEN}` } + +export function getPlaceFromCoords(lng: number | undefined, lat:number | undefined): Promise { + return new Promise((resolve) => { + if(!lat || !lng) resolve('') // keeps the calling code a bit cleaner + fetch(`https://api.mapbox.com/search/geocode/v6/reverse?longitude=${lng}&latitude=${lat}.733&types=address&worldview=us&access_token=${MAPBOX_TOKEN}`) + .then(res => res.json()) + .then(res => { + // if the object is not found, we can handle the error appropriately in the ui + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @stylistic/max-len, @typescript-eslint/no-unsafe-member-access + const neighborhood = res.features[0].properties.context.neighborhood, region = res.features[0].properties.context.region + if(neighborhood && region) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + resolve(`${neighborhood.name}, ${region.region_code}`) + } else resolve('') + }) + .catch(() => resolve('')) + }) +} diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index 7eb64f58..8cb51a98 100644 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -1,12 +1,16 @@ +import { useNavigate } from '@solidjs/router' import { getGoogleAuthUrl, getAppleAuthUrl, getGitHubAuthUrl } from '~/api/auth' import { setAccessToken } from '~/api/auth/client' import Button from '~/components/material/Button' export default function Login() { + + const navigate = useNavigate() + const loginAsDemoUser = function () { setAccessToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NDg1ODI0NjUsIm5iZiI6MTcxNzA0NjQ2NSwiaWF0IjoxNzE3MDQ2NDY1LCJpZGVudGl0eSI6IjBkZWNkZGNmZGYyNDFhNjAifQ.g3khyJgOkNvZny6Vh579cuQj1HLLGSDeauZbfZri9jw') - window.location.href = window.location.origin + navigate('/') } return ( diff --git a/src/pages/dashboard/Dashboard.tsx b/src/pages/dashboard/Dashboard.tsx index 4e84bfab..d7b43126 100644 --- a/src/pages/dashboard/Dashboard.tsx +++ b/src/pages/dashboard/Dashboard.tsx @@ -1,110 +1,111 @@ import { - Accessor, createContext, createResource, createSignal, - Match, - Setter, + onCleanup, Show, - Switch, + onMount, + createEffect, } from 'solid-js' -import type { VoidComponent } from 'solid-js' -import { Navigate, useLocation } from '@solidjs/router' +import { Navigate, useNavigate, useLocation } from '@solidjs/router' -import { getDevices } from '~/api/devices' +import { getDevices, getDevice } from '~/api/devices' import { getProfile } from '~/api/profile' -import type { Device } from '~/types' -import Button from '~/components/material/Button' -import Drawer from '~/components/material/Drawer' -import IconButton from '~/components/material/IconButton' -import TopAppBar from '~/components/material/TopAppBar' - -import DeviceList from './components/DeviceList' -import DeviceActivity from './activities/DeviceActivity' +import { Controls } from '~/pages/dashboard/components/Controls' +import Search from './components/Search' +import RouteList from './components/RouteList' +import Loader from '~/components/Loader' +import PlaceHolder from '~/components/PlaceHolder' import RouteActivity from './activities/RouteActivity' -type DashboardState = { - drawer: Accessor - setDrawer: Setter - toggleDrawer: () => void -} +const MAX_WIDTH = 30 +const MIN_WIDTH = 20 -export const DashboardContext = createContext() +// adapted from https://www.solidjs.com/guides/typescript#context +// TODO: find a better way to type annotate context without child components complaining DashboardState | undefined +export const generateContextType = () => { + const pathParts = (): string[] => location.pathname.split('/').slice(1).filter(Boolean) -const DashboardDrawer = (props: { - onClose: () => void - devices: Device[] | undefined -}) => { - return ( - <> - arrow_back} - > - comma connect - -

- Devices -

- - {(devices: Device[]) => } - -
-
- - - ) -} + const [dongleId] = createSignal(pathParts()[0]) + const [width] = createSignal(MAX_WIDTH) + const [route] = createSignal(pathParts()[1]) + const [device] = createResource(() => dongleId(), getDevice) + const [isDesktop] = createSignal(window.innerWidth > 768) -const DashboardLayout: VoidComponent = () => { - const location = useLocation() + return {width, isDesktop, device, route} as const +} - const pathParts = () => location.pathname.split('/').slice(1).filter(Boolean) - const dongleId = () => pathParts()[0] - const dateStr = () => pathParts()[1] +type DashboardState = ReturnType +export const DashboardContext = createContext() - const [drawer, setDrawer] = createSignal(false) - const onOpen = () => setDrawer(true) - const onClose = () => setDrawer(false) - const toggleDrawer = () => setDrawer((prev) => !prev) +function DashboardLayout() { + const navigate = useNavigate() + const location = useLocation() + + const pathParts = (): string[] => location.pathname.split('/').slice(1).filter(Boolean) + const [dongleId, setDongleId] = createSignal(pathParts()[0]) + const [route, setRoute] = createSignal(pathParts()[1]) const [devices] = createResource(getDevices) const [profile] = createResource(getProfile) + const [device] = createResource(() => dongleId(), getDevice) + + const [leftContainerWidth, setLeftContainerWidth] = createSignal(MAX_WIDTH) + const [isDesktop, setView] = createSignal(window.innerWidth > 768) + + const [searchQuery, setSearchQuery] = createSignal('') + + onMount(() => { + window.addEventListener('resize', () => { + setView(window.innerWidth > 768) + }) + }) + + createEffect(() => { + const deviceList = devices.latest + if (!dongleId() && deviceList && deviceList.length > 0) { + setDongleId(deviceList[0].dongle_id) + navigate(`/${deviceList[0].dongle_id}`) + } + setRoute(pathParts()[1]) + }) + + onCleanup(() => { + window.removeEventListener('resize', () => {}) + }) + + const handleResize = (e: { clientX: number, preventDefault: () => void }) => { + const offset = (e.clientX / window.innerWidth) * 100 + setLeftContainerWidth(Math.min(Math.max(offset, MIN_WIDTH), MAX_WIDTH)) + e.preventDefault() + } return ( - - } - > - - menu} - > - No device - - - } - > - - - - - - - - - - - - - - + + }> +
+ +
+ +
}> +
+ +
+
+ +
+
+
+
+ }> + + +
+
+
) } diff --git a/src/pages/dashboard/activities/DeviceActivity.tsx b/src/pages/dashboard/activities/DeviceActivity.tsx deleted file mode 100644 index f47638f0..00000000 --- a/src/pages/dashboard/activities/DeviceActivity.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { createResource, Suspense, useContext } from 'solid-js' -import type { VoidComponent } from 'solid-js' - -import { getDevice } from '~/api/devices' - -import IconButton from '~/components/material/IconButton' -import TopAppBar from '~/components/material/TopAppBar' -import DeviceStatistics from '~/components/DeviceStatistics' -import { getDeviceName } from '~/utils/device' - -import RouteList from '../components/RouteList' -import { DashboardContext } from '../Dashboard' - -type DeviceActivityProps = { - dongleId: string -} - -const DeviceActivity: VoidComponent = (props) => { - const { toggleDrawer } = useContext(DashboardContext)! - - const [device] = createResource(() => props.dongleId, getDevice) - const [deviceName] = createResource(device, getDeviceName) - return ( - <> - menu}> - {deviceName()} - -
-
- }> -
- -
-
-
-
- Routes - -
-
- - ) -} - -export default DeviceActivity diff --git a/src/pages/dashboard/activities/RouteActivity.tsx b/src/pages/dashboard/activities/RouteActivity.tsx index d4aa35f7..e2726d89 100644 --- a/src/pages/dashboard/activities/RouteActivity.tsx +++ b/src/pages/dashboard/activities/RouteActivity.tsx @@ -1,69 +1,155 @@ import { + createEffect, createResource, createSignal, lazy, Suspense, + useContext, type VoidComponent, + Show, } from 'solid-js' +import { createShortcut } from '@solid-primitives/keyboard' import { getRoute } from '~/api/route' - import IconButton from '~/components/material/IconButton' -import TopAppBar from '~/components/material/TopAppBar' - -import RouteStaticMap from '~/components/RouteStaticMap' -import RouteStatistics from '~/components/RouteStatistics' import Timeline from '~/components/Timeline' import { parseDateStr } from '~/utils/date' +import { DashboardContext, generateContextType } from '../Dashboard' +import Icon from '~/components/material/Icon' +import DriveMap from '~/map/DriveMap' +import { DriveStatistics } from '~/components/RouteStatistics' +import { getCoords } from '~/api/derived' +import { useNavigate } from '@solidjs/router' const RouteVideoPlayer = lazy(() => import('~/components/RouteVideoPlayer')) type RouteActivityProps = { - dongleId: string - dateStr: string + dongleId: string | undefined + dateStr: string | undefined } const RouteActivity: VoidComponent = (props) => { + + const navigate = useNavigate() + const [seekTime, setSeekTime] = createSignal(0) + const { isDesktop, width } = useContext(DashboardContext) ?? generateContextType() const routeName = () => `${props.dongleId}|${props.dateStr}` const [route] = createResource(routeName, getRoute) - const [startTime] = createResource(route, (route) => parseDateStr(route.start_time)?.format('ddd, MMM D, YYYY')) - - return ( - <> - arrow_back}> - {startTime()} - - -
- - } - > - - - -
-

Timeline

- - }> - + const [coords] = createResource(() => route(), getCoords) + + const [startTime] = createResource(route, (route) => parseDateStr(route.start_time)?.format('dddd, MMM D, YYYY')) + const [videoHeight, setVideoHeight] = createSignal(60) + const [speed, setSpeed] = createSignal(0) + + createShortcut( + ['ESC'], + () => navigate(`/${props.dongleId}`), + { preventDefault: true }, + ) + + createEffect(() => { + setVideoHeight(90 - width()) + }) + + const copyToClipboard = () => { + navigator.clipboard.writeText(props.dateStr || '') + .then(() => { }) + .catch(() => { }) + } + + const shareDrive = () => { + navigator.share({ url: window.location.href }) + .then(() => { }) + .catch(() => { }) + } + + createEffect(() => { + const coord = coords.latest?.find(coord => coord.t === Math.round(seekTime())) + setSpeed(coord ? Math.round(coord.speed) : 0) + }) + + type ActionProps = { + icon?: string + label: string + selectable?: boolean + selected?: boolean + onClick?: () => void + } + const Action: VoidComponent = (props) => { + const [selected, setSelected] = createSignal(props.selected) + return
+
{ + if (props.selectable) setSelected(!selected()) + if (props.onClick) props.onClick() + }} + class={`mx-1 flex size-full items-center justify-center space-x-2 rounded-md border-2 border-secondary-container p-2 lg:h-3/4 ${selected() ? 'bg-primary-container' : 'hover:bg-secondary-container'}`} + > + {`${props.icon}`} +

{props.label}

+
+
+ } + + return
+
+
+
+ {isDesktop() ? 'close' : 'arrow_back_ios'} +
+
+
+

{startTime()}

+
+

{props.dongleId}

+
+
+
+
+
+
+ +
+
+ + } + > +
+

{Math.round(speed() * 2.237)} mph

+
+ +
- -
-

Route Map

-
- }> - - +
+
+
+ } > + + + + +
+
+
+ + + + + +
+ {/* + + */}
- - ) +
+
} export default RouteActivity diff --git a/src/pages/dashboard/components/Controls.tsx b/src/pages/dashboard/components/Controls.tsx new file mode 100644 index 00000000..47d7fa91 --- /dev/null +++ b/src/pages/dashboard/components/Controls.tsx @@ -0,0 +1,96 @@ +import { createEffect, createSignal, For, Show, useContext } from 'solid-js' +import type { VoidComponent, Resource } from 'solid-js' +import { useNavigate } from '@solidjs/router' +import Avatar from '~/components/material/Avatar' +import Icon from '~/components/material/Icon' +import Dates from '../../../components/Dates' +import { DashboardContext, generateContextType } from '../Dashboard' +import { Device } from '~/types' +import { getDeviceName } from '~/utils/device' +import DeviceStatistics from '~/components/DeviceStatistics' + +type SelectorProps = { + onUiChange: (change: boolean) => void, + data: Device[] +} + +const DeviceSelector: VoidComponent = (props) => { + + const { device } = useContext(DashboardContext) ?? generateContextType() + const navigate = useNavigate() + + const [isSelectorOpen, openSelector] = createSignal(false) + // eslint-disable-next-line solid/reactivity + const [data] = createSignal(props.data) + + createEffect(() => props.onUiChange(isSelectorOpen())) + + return <> +
+ directions_car +
+
+ + +

{getDeviceName(device.latest)}

+

{device.latest?.dongle_id}

+
+
} + > + + {(item) => { + return
{ + openSelector(false) + navigate(`/${item.dongle_id}`) + setTimeout(() => window.location.reload(), 200) + }} + class="flex h-16 w-full flex-col justify-center rounded-md pl-3 hover:bg-on-secondary-container"> +

{getDeviceName(item)}

+

{item.dongle_id}

+
+ }} +
+
openSelector(false)} + class="flex h-16 w-full items-center space-x-2 rounded-md pl-3 hover:bg-on-secondary-container"> + add +

Add device

+
+ +
+
openSelector(!isSelectorOpen())} class="flex w-2/12 items-center justify-center"> + + {isSelectorOpen() ? 'keyboard_arrow_down' : 'keyboard_arrow_up'} + +
+ +} + +type Props = { + devices: Resource, +} + +export const Controls: VoidComponent = (props) => { + + const { width, isDesktop, device } = useContext(DashboardContext) ?? generateContextType() + const [isSelectorOpen, setSelector] = createSignal(false) + + return +
+
+ setSelector(change)} + data={props.devices.latest || []} + /> +
+ +
+ +
+
+
+} diff --git a/src/pages/dashboard/components/DeviceList.tsx b/src/pages/dashboard/components/DeviceList.tsx deleted file mode 100644 index 14cdea95..00000000 --- a/src/pages/dashboard/components/DeviceList.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useContext, For } from 'solid-js' -import type { VoidComponent } from 'solid-js' -import { useLocation } from '@solidjs/router' -import clsx from 'clsx' - -import Icon from '~/components/material/Icon' -import List, { ListItem, ListItemContent } from '~/components/material/List' -import type { Device } from '~/types' -import { getDeviceName } from '~/utils/device' - -import { DashboardContext } from '../Dashboard' - -type DeviceListProps = { - class?: string - devices: Device[] -} - -const DeviceList: VoidComponent = (props) => { - const { setDrawer } = useContext(DashboardContext)! - const location = useLocation() - - return ( - - - {(device) => { - const isSelected = () => location.pathname.includes(device.dongle_id) - const onClick = () => setDrawer(false) - return ( - directions_car} - selected={isSelected()} - onClick={onClick} - href={`/${device.dongle_id}`} - > - - {device.dongle_id} - - } - /> - - ) - }} - - - ) -} - -export default DeviceList diff --git a/src/pages/dashboard/components/RouteList.tsx b/src/pages/dashboard/components/RouteList.tsx index 78d0cf40..24d0fb8e 100644 --- a/src/pages/dashboard/components/RouteList.tsx +++ b/src/pages/dashboard/components/RouteList.tsx @@ -1,91 +1,158 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ import { createEffect, - createResource, createSignal, For, + Show, Suspense, } from 'solid-js' import type { VoidComponent } from 'solid-js' -import clsx from 'clsx' - import type { RouteSegments } from '~/types' - +import { getKey, getRouteCardsData } from '~/api/routelist' import RouteCard from '~/components/RouteCard' -import { fetcher } from '~/api' +import Loader from '~/components/Loader' import Button from '~/components/material/Button' +import Icon from '~/components/material/Icon' -const PAGE_SIZE = 3 - -type RouteListProps = { - class?: string - dongleId: string +type Filter = { + label: string + sorter?: (a: RouteSegments, b: RouteSegments) => number } +const filters: Filter[] = [ + { + label: 'Miles', + sorter: (a: RouteSegments, b: RouteSegments) => { + return (b.ui_derived?.distance || 0) - (a.ui_derived?.distance || 0) + }, + }, + { + label: 'Duration', + sorter: (a: RouteSegments, b: RouteSegments) => { + return (b.ui_derived?.duration?.asMilliseconds() || 0) - (a.ui_derived?.duration?.asMilliseconds() || 0) + }, + }, + { + label: 'Engagement', + sorter: (a: RouteSegments, b: RouteSegments) => { + return (b.ui_derived?.engagement || 0) - (a.ui_derived?.engagement || 0) + }, + }, +] +const PAGE_SIZE = 5 const pages: Promise[] = [] -const RouteList: VoidComponent = (props) => { - const endpoint = () => `/v1/devices/${props.dongleId}/routes_segments?limit=${PAGE_SIZE}` - const getKey = (previousPageData?: RouteSegments[]): string | undefined => { - if (!previousPageData) return endpoint() - if (previousPageData.length === 0) return undefined - const lastSegmentEndTime = previousPageData.at(-1)!.segment_start_times.at(-1)! - return `${endpoint()}&end=${lastSegmentEndTime - 1}` - } +type Props = { + searchQuery: string, + dongleId: string | undefined +} + +const RouteList: VoidComponent = (props) => { + + const dongleId = () => props.dongleId + const query = () => props.searchQuery + + let routes: RouteSegments[] = [] + + const [display, setDisplay] = createSignal([]) + const [pageSize, setSize] = createSignal(0) + const [filter, setFilter] = createSignal({ label: '' }) + const getPage = (page: number): Promise => { if (!pages[page]) { // eslint-disable-next-line no-async-promise-executor pages[page] = new Promise(async (resolve) => { const previousPageData = page > 0 ? await getPage(page - 1) : undefined - const key = getKey(previousPageData) - resolve(key ? fetcher(key) : []) + const key = getKey(dongleId(), PAGE_SIZE, previousPageData) + const data = await getRouteCardsData(key) + resolve(data) }) } return pages[page] } + const searchResults = (searchQuery: string | undefined) => { + let results = routes + if(searchQuery) { + const query = searchQuery.toLowerCase() + results = routes.filter(route => { + const address = route.ui_derived?.address + return address?.start.toLowerCase().includes(query) || address?.end.toLowerCase().includes(query) + }) + } + return results + } + + const filteredResults = () => { + let results = routes + if(filter().label != '') { + results = results.slice().sort(filter().sorter) + } + return results + } + createEffect(() => { - if (props.dongleId) { - pages.length = 0 - setSize(1) + getPage(pageSize()) + .then(res => { + routes = routes.concat(res) + routes = searchResults(query()) + routes = filteredResults() + setDisplay(routes) + }) + .catch(() => {}) + }) + + createEffect(() => { + if(dongleId()) { + setSize(0) } }) - const [size, setSize] = createSignal(1) - const onLoadMore = () => setSize(size() + 1) - const pageNumbers = () => Array.from(Array(size()).keys()) + createEffect(() => { + setDisplay(searchResults(query())) + }) - return ( -
- - {(i) => { - const [routes] = createResource(() => i, getPage) - return ( - -
-
-
- - } + const Filters: VoidComponent = () => { + + return ( +
+ + {(each) => ( +
{ + setFilter(each.label === filter().label ? { label: '' } : each) + setDisplay(filteredResults()) + }} + class={`group flex items-center justify-center rounded-lg border-2 border-secondary-container px-4 py-2 ${each.label === filter().label ? 'bg-primary-container' : 'hover:bg-secondary-container'}`} > - - {(route) => } - - - ) - }} - -
- +

{each.label}

+
+ )} +
-
+ ) + } + + return ( + 0} + fallback={
+ car_crash +

No drives

+
} + > +
} > + + + {(route) => } + +
+ +
+
+ + + ) } diff --git a/src/pages/dashboard/components/Search.tsx b/src/pages/dashboard/components/Search.tsx new file mode 100644 index 00000000..523bc27d --- /dev/null +++ b/src/pages/dashboard/components/Search.tsx @@ -0,0 +1,66 @@ +import { VoidComponent, createSignal, useContext, onMount, Show } from 'solid-js' +import { createShortcut } from '@solid-primitives/keyboard' +import Icon from '~/components/material/Icon' +import { DashboardContext, generateContextType } from '../Dashboard' +import { A } from '@solidjs/router' + +type Props = { + onSearch: (query: string) => void +} + +const Search: VoidComponent = (props) => { + + const { width, isDesktop } = useContext(DashboardContext) ?? generateContextType() + + const onSearch = (searchQuery: string) => props.onSearch(searchQuery) + + const [placeholder, setPlaceHolder] = createSignal('connect') + const [focused, setFocus] = createSignal(false) + const [query, setQuery] = createSignal('') + + createShortcut( + ['Control', 'K'], + () => setFocus(true), + { preventDefault: true }, + ) + + onMount(() => { + setTimeout(() => setPlaceHolder('type to search'), 5000) + }) + + return
+ + + account_circle + +
+ setFocus(true)} class="text-xl text-on-secondary-container">{placeholder()}} + > + { + setQuery(ev.target.value) + onSearch(ev.target.value) + }} + onFocus={() => setFocus(true)} + onBlur={() => setFocus(false)} + autofocus + class="size-full border-0 bg-transparent outline-none" + /> + +
+
{ + setQuery('') + onSearch('') + }} class="flex w-20 items-center justify-center"> + {query() ? 'close' : 'search'} +
+
+} + +export default Search diff --git a/src/types.d.ts b/src/types.d.ts index cc9d76e4..12d4bed0 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,3 +1,5 @@ +import { Duration } from 'dayjs/plugin/duration' + export interface Profile { email: string id: string @@ -45,11 +47,26 @@ export enum SegmentDataSource { THREE = 7, } +interface Address { + start: string, + end: string +} + +interface UIDerived { + distance?: number, + duration?: Duration, + engagement?: number, + flags?: number, + address?: Address +} + export interface Route { can?: boolean create_time: number devicetype: number dongle_id: string + start_lat?: number + start_lng?: number end_lat?: number end_lng?: number end_time?: string @@ -79,7 +96,8 @@ export interface Route { url: string user_id: string | null version?: string - vin?: string + vin?: string, + ui_derived?: UIDerived } export interface RouteShareSignature extends Record { diff --git a/src/utils/date.ts b/src/utils/date.ts index 79cef8c9..2dd14950 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -7,13 +7,13 @@ import type { Route } from '~/types' dayjs.extend(customParseFormat) dayjs.extend(duration) -export const formatDistance = (miles: number | undefined): string => { - if (miles === undefined) return '' - return `${miles.toFixed(1) ?? 0} mi` +export const formatDistance = (miles: number | undefined): number => { + if (miles === undefined) return 0 + return parseFloat(miles.toFixed(1)) ?? 0 } -export const formatRouteDistance = (route: Route | undefined): string => { - if (route?.length === undefined) return '' +export const formatRouteDistance = (route: Route | undefined): number => { + if (route?.length === undefined) return 0 return formatDistance(route.length) } @@ -41,9 +41,8 @@ export const getRouteDuration = (route: Route): Duration | undefined => { return dayjs.duration(endTime.diff(startTime)) } -export const formatRouteDuration = (route: Route | undefined): string => { - if (!route) return '' - const duration = getRouteDuration(route) +export const formatRouteDuration = (duration: Duration | undefined): string => { + if (!duration) return '' return duration ? _formatDuration(duration) : '' } diff --git a/src/utils/device.ts b/src/utils/device.ts index 4e5a6f8e..524dc43a 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -1,6 +1,7 @@ import type { Device } from '~/types' -export function getDeviceName(device: Device) { +export function getDeviceName(device: Device | undefined) { + if(!device) return '' if (device.alias) return device.alias return `comma ${device.device_type}` } diff --git a/tailwind.config.ts b/tailwind.config.ts index 29a06ab9..e2cfbdcf 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -195,17 +195,31 @@ export default { strokeDashoffset: '-125', }, }, + 'exist': { + '0%': { + height: '0', + }, + '100%': { + height: '11rem', + }, + }, }, animation: { indeterminate1: 'indeterminate1 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite', indeterminate2: 'indeterminate2 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) 1.15s infinite', 'circular-rotate': 'circular-rotate 1.4s linear infinite', 'circular-dash': 'circular-dash 1.4s ease-in-out infinite', + 'load-bounce': 'bounce 1s ease-in 0.5', + 'exist': 'exist 0.4s ease-in 1', }, transitionProperty: { + controls: 'height', indeterminate: 'transform, background-color', drawer: 'left, opacity, width', }, + transitionDuration: { + slow: '1s', + }, }, }, } satisfies Config