diff --git a/src/api/derived.ts b/src/api/derived.ts index f2afdb1c..384805bd 100644 --- a/src/api/derived.ts +++ b/src/api/derived.ts @@ -1,5 +1,10 @@ -import type { Route } from '~/types' +import dayjs from 'dayjs' +import type { Route, RouteSegments } from '~/types' import { getRouteDuration } from '~/utils/date' +import { fetcher } from '~/api/index' + +export const PAGE_SIZE = 7 +export const DEFAULT_DAYS = 7 export interface GPSPathPoint { t: number @@ -85,14 +90,33 @@ export interface TimelineStatistics { userFlags: number } -const getDerived = (route: Route, fn: string): Promise => { - let urls: string[] = [] - if (route) { - const segmentNumbers = Array.from({ length: route.maxqlog }, (_, i) => i) - urls = segmentNumbers.map((i) => `${route.url}/${i}/${fn}`) +export interface RouteSegmentsWithStats extends RouteSegments { + timelineStatistics: TimelineStatistics +} + +const fetchWithRetry = async (url: string, attempts: number = 3): Promise => { + for (let attempt = 0; attempt < attempts; attempt++) { + try { + console.log(`Fetching ${url}, attempt ${attempt + 1}`) + const res = await fetch(url) + if (!res.ok) throw new Error(`Failed to fetch: ${res.statusText}`) + return await res.json() as T + } catch (error) { + console.error(`Attempt ${attempt + 1} failed for ${url}:`, error) + if (attempt === attempts - 1) { + console.error(`Failed to fetch after ${attempts} attempts: ${url}`) + return null + } + } } - const results = urls.map((url) => fetch(url).then((res) => res.json() as T)) - return Promise.all(results) + return null +} + +const getDerived = async (route: Route, fn: string): Promise => { + const segmentNumbers = Array.from({ length: route.maxqlog }, (_, i) => i) + const urls = segmentNumbers.map((i) => `${route.url}/${i}/${fn}`) + const results = await Promise.all(urls.map((url) => fetchWithRetry(url))) + return results.flat().filter(item => item !== null) as T[] } export const getCoords = (route: Route): Promise => @@ -109,12 +133,8 @@ const generateTimelineEvents = ( ): TimelineEvent[] => { const routeDuration = getRouteDuration(route)?.asMilliseconds() ?? 0 - // sort events by timestamp - events.sort((a, b) => { - return a.route_offset_millis - b.route_offset_millis - }) + events.sort((a, b) => a.route_offset_millis - b.route_offset_millis) - // convert events to timeline events const res: TimelineEvent[] = [] let lastEngaged: StateDriveEvent | undefined let lastAlert: StateDriveEvent | undefined @@ -170,7 +190,6 @@ const generateTimelineEvents = ( } }) - // ensure events have an end timestamp if (lastEngaged) { res.push({ type: 'engaged', @@ -227,3 +246,47 @@ export const getTimelineStatistics = async ( getTimelineEvents(route).then((timeline) => generateTimelineStatistics(route, timeline), ) + +export const fetchRoutesWithinDays = async (dongleId: string, days: number): Promise => { + const now = dayjs().valueOf() + const pastDate = dayjs().subtract(days, 'day').valueOf() + const endpoint = (end: number) => `/v1/devices/${dongleId}/routes_segments?limit=${PAGE_SIZE}&end=${end}` + + let allRoutes: RouteSegments[] = [] + let end = now + + while (true) { + const key = `${endpoint(end)}` + try { + const routes = await fetcher(key) + if (!routes || routes.length === 0) break + allRoutes = [...allRoutes, ...routes] + end = (routes.at(-1)?.end_time_utc_millis ?? 0) - 1 + if (end < pastDate) break + } catch (error) { + console.error('Error fetching routes:', error) + break + } + } + return allRoutes.filter(route => route.end_time_utc_millis >= pastDate) +} + +export const fetchRoutesWithStats = async (dongleId: string, days: number): Promise => { + const routes = await fetchRoutesWithinDays(dongleId, days) + console.log('Fetched routes:', routes.length) + const routesWithStats = await Promise.all( + routes.map(async (route): Promise => { + const stats = await getTimelineStatistics(route).catch((error) => { + console.error(`Error fetching statistics for route ${route.fullname}:`, error) + return { duration: 0, engagedDuration: 0, userFlags: 0 } + }) + console.log(`Route ${route.fullname} stats:`, stats) + return { + ...route, + timelineStatistics: stats, + } + }), + ) + console.log('Routes with stats:', routesWithStats.length) + return routesWithStats +} diff --git a/src/api/route.ts b/src/api/route.ts index d1ceeadf..9816f0b1 100644 --- a/src/api/route.ts +++ b/src/api/route.ts @@ -19,3 +19,21 @@ export const getQCameraStreamUrl = (routeName: Route['fullname']): Promise createQCameraStreamUrl(routeName, signature), ) + +export const fetchRoutes = async ({ + dongleId, + page, + pageSize, +}: { + dongleId: string + page: number + pageSize: number +}): Promise => { + const endpoint = `/v1/devices/${dongleId}/routes_segments` + const params = new URLSearchParams({ + limit: pageSize.toString(), + offset: ((page - 1) * pageSize).toString(), + }) + + return fetcher(`${endpoint}?${params.toString()}`) +} diff --git a/src/components/RouteCard.tsx b/src/components/RouteCard.tsx index 7ee0224f..d34cc9c1 100644 --- a/src/components/RouteCard.tsx +++ b/src/components/RouteCard.tsx @@ -6,8 +6,9 @@ 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 { formatRouteDistance, formatRouteDuration } from '~/utils/date' import type { RouteSegments } from '~/types' +import type { SortKey } from '~/utils/sorting' const RouteHeader = (props: { route: RouteSegments }) => { const startTime = () => dayjs(props.route.start_time_utc_millis) @@ -30,10 +31,30 @@ const RouteHeader = (props: { route: RouteSegments }) => { } interface RouteCardProps { - route: RouteSegments + route: RouteSegments & { timelineStatistics?: { duration: number, engagedDuration: number, userFlags: number } } + sortKey: SortKey } const RouteCard: VoidComponent = (props) => { + const getSortedValue = () => { + switch (props.sortKey) { + case 'date': + return dayjs(props.route.start_time_utc_millis).format('YYYY-MM-DD HH:mm:ss') + case 'miles': + return formatRouteDistance(props.route) + case 'duration': + return formatRouteDuration(props.route) + case 'engaged': + return props.route.timelineStatistics ? + `${((props.route.timelineStatistics.engagedDuration / props.route.timelineStatistics.duration) * 100).toFixed(2)}%` : + 'N/A' + case 'userFlags': + return props.route.timelineStatistics?.userFlags.toString() || 'N/A' + default: + return 'N/A' + } + } + return ( @@ -48,6 +69,9 @@ const RouteCard: VoidComponent = (props) => { +
+ {props.sortKey}: {getSortedValue()} +
) diff --git a/src/components/RouteSorter.tsx b/src/components/RouteSorter.tsx new file mode 100644 index 00000000..bcb15c90 --- /dev/null +++ b/src/components/RouteSorter.tsx @@ -0,0 +1,92 @@ +import { For } from 'solid-js' +import { createStore } from 'solid-js/store' +import type { Component } from 'solid-js' +import { SortKey, SortOption, SortOrder } from '~/utils/sorting' + +const GRADIENT = 'from-cyan-700 via-blue-800 to-purple-900' + +interface RouteSorterProps { + onSortChange: (key: SortKey, order: SortOrder | null) => void; + currentSort: SortOption; +} + +export const RouteSorter: Component = (props) => { + const [sortOptions] = createStore([ + { label: 'Duration', key: 'duration', order: 'desc' }, + { label: 'Miles', key: 'miles', order: 'desc' }, + { label: 'Engaged', key: 'engaged', order: 'desc' }, + { label: 'User Flags', key: 'userFlags', order: 'desc' }, + ]) + + // Allows mouse wheel to scroll through filters + const handleScroll = (e: WheelEvent) => { + const container = e.currentTarget as HTMLDivElement + container.scrollLeft += e.deltaY + } + + const handleClick = (clickedOption: SortOption) => { + let newOrder: SortOrder | null + if (props.currentSort.key === clickedOption.key) { + // If the same button is clicked, toggle the order or deactivate the filter + if (props.currentSort.order === 'desc') { + newOrder = 'asc' + } else if (props.currentSort.order === 'asc') { + newOrder = null + } else { + newOrder = 'desc' + } + } else { + newOrder = 'desc' + } + props.onSortChange(clickedOption.key, newOrder) + } + + return ( +
+
+ + {(option) => ( + + )} + +
+
+ ) +} + +export default RouteSorter diff --git a/src/pages/dashboard/components/RouteList.tsx b/src/pages/dashboard/components/RouteList.tsx index 658e6af6..eedba950 100644 --- a/src/pages/dashboard/components/RouteList.tsx +++ b/src/pages/dashboard/components/RouteList.tsx @@ -1,89 +1,174 @@ -/* eslint-disable @typescript-eslint/no-misused-promises */ import { createEffect, - createResource, createSignal, For, Suspense, + createResource, + onCleanup, } from 'solid-js' import type { VoidComponent } from 'solid-js' import clsx from 'clsx' import type { RouteSegments } from '~/types' - import RouteCard from '~/components/RouteCard' -import { fetcher } from '~/api' -import Button from '~/components/material/Button' +import RouteSorter from '~/components/RouteSorter' +import { SortOption, SortKey, sortRoutes, SortOrder } from '~/utils/sorting' +import { fetchRoutesWithStats, PAGE_SIZE, DEFAULT_DAYS } from '~/api/derived' -const PAGE_SIZE = 3 +interface RouteSegmentsWithStats extends RouteSegments { + timelineStatistics: { + duration: number + engagedDuration: number + userFlags: number + } +} type RouteListProps = { class?: string dongleId: string } -const pages: Promise[] = [] +const fetchRoutes = async (dongleId: string, days: number): Promise => { + return await fetchRoutesWithStats(dongleId, days) +} -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)!.end_time_utc_millis - return `${endpoint()}&end=${lastSegmentEndTime - 1}` +const debounce = ) => void>( + func: F, + delay: number, +): ((...args: Parameters) => void) => { + let debounceTimeout: number + return (...args: Parameters) => { + clearTimeout(debounceTimeout) + debounceTimeout = window.setTimeout(() => func(...args), delay) } - 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 RouteList: VoidComponent = (props) => { + const [sortOption, setSortOption] = createSignal({ label: 'Date', key: 'date', order: 'desc' }) + const [allRoutes, setAllRoutes] = createSignal([]) + const [sortedRoutes, setSortedRoutes] = createSignal([]) + const [days, setDays] = createSignal(DEFAULT_DAYS) + const [hasMore, setHasMore] = createSignal(true) + const [loading, setLoading] = createSignal(true) + const [fetchingMore, setFetchingMore] = createSignal(false) + let bottomRef: HTMLDivElement | undefined + + const [routesResource, { refetch }] = createResource( + () => `${props.dongleId}-${days()}`, + async () => { + setLoading(true) + const routes = await fetchRoutes(props.dongleId, days()) + setLoading(false) + return routes + }, + ) + + createEffect(() => { + const routes: RouteSegmentsWithStats[] = routesResource()?.map(route => ({ + ...route, + timelineStatistics: route.timelineStatistics || { duration: 0, engagedDuration: 0, userFlags: 0 }, + })) || [] + + setHasMore(routes.length >= PAGE_SIZE) + + const routeMap = new Map() + allRoutes().forEach(route => routeMap.set(route.fullname, route)) + routes.forEach(route => routeMap.set(route.fullname, route)) + + const uniqueRoutes = Array.from(routeMap.values()) + + setAllRoutes(prevRoutes => { + if (uniqueRoutes.length !== prevRoutes.length) { + console.log('Updated allRoutes:', uniqueRoutes.length) + return uniqueRoutes + } + return prevRoutes + }) + + setFetchingMore(false) + }) + + createEffect(() => { + const routes = allRoutes() + const currentSortOption = sortOption() + console.log('Sorting effect triggered:', { routesCount: routes.length, currentSortOption }) + if (routes.length > 0) { + const sorted = sortRoutes(routes, currentSortOption) + setSortedRoutes(sorted) + console.log('Sorted routes:', sorted.length) + } else { + setSortedRoutes(routes) + } + }) + + const handleSortChange = (key: SortKey, order: SortOrder | null) => { + if (order === null) { + console.log('Reverting to default sort') + setSortOption({ label: 'Date', key: 'date', order: 'desc' }) + } else { + console.log(`Changing sort to ${key} ${order}`) + setSortOption({ label: key.charAt(0).toUpperCase() + key.slice(1), key, order }) } - return pages[page] } createEffect(() => { - if (props.dongleId) { - pages.length = 0 - setSize(1) + const observer = new IntersectionObserver( + debounce((entries: IntersectionObserverEntry[]) => { + if (entries[0].isIntersecting && hasMore() && !loading() && !fetchingMore()) { + setFetchingMore(true) + setDays((days) => days + DEFAULT_DAYS) + void refetch() + } + }, 200), + { rootMargin: '200px' }, + ) + + if (bottomRef) { + observer.observe(bottomRef) } - }) - const [size, setSize] = createSignal(1) - const onLoadMore = () => setSize(size() + 1) - const pageNumbers = () => Array.from(Array(size()).keys()) + onCleanup(() => observer.disconnect()) + }) return ( -
- - {(i) => { - const [routes] = createResource(() => i, getPage) - return ( - -
-
-
- - } - > - - {(route) => } - - - ) - }} - -
- +
+ + +
+
+
+ + } + > + {loading() && allRoutes().length === 0 ? ( + <> +
+
+
+ + ) : ( + + {(route) => ( + + )} + + )} + +
+ {fetchingMore() && ( +
+
+
+ )} + {hasMore() && !fetchingMore() && ( +
+ )} +
+
+ {!hasMore() && sortedRoutes().length === 0 &&
No routes found
} + {!hasMore() && sortedRoutes().length > 0 &&
All routes loaded
}
) diff --git a/src/utils/sorting.ts b/src/utils/sorting.ts new file mode 100644 index 00000000..f85475b2 --- /dev/null +++ b/src/utils/sorting.ts @@ -0,0 +1,42 @@ +import { RouteSegmentsWithStats } from '~/api/derived' + +export type SortKey = 'date' | 'miles' | 'duration' | 'engaged' | 'userFlags' +export type SortOrder = 'asc' | 'desc' +export interface SortOption { + label: string + key: SortKey + order: SortOrder +} + +export const sortRoutes = (routes: RouteSegmentsWithStats[], option: SortOption): RouteSegmentsWithStats[] => { + console.log('Sorting routes with option:', option) + + const getSortValue = (route: RouteSegmentsWithStats, key: SortKey): number => { + switch (key) { + case 'date': return route.start_time_utc_millis + case 'miles': return route.length || 0 + case 'duration': return route.timelineStatistics?.duration || 0 + case 'engaged': return route.timelineStatistics?.engagedDuration || 0 + case 'userFlags': return route.timelineStatistics?.userFlags || 0 + default: return 0 + } + } + + console.log('First 5 routes before sorting:', routes.slice(0, 5).map(r => ({ + id: r.fullname, + sortValue: getSortValue(r, option.key), + }))) + + const sortedRoutes = [...routes].sort((a, b) => { + const aValue = getSortValue(a, option.key) + const bValue = getSortValue(b, option.key) + return option.order === 'desc' ? bValue - aValue : aValue - bValue + }) + + console.log('First 5 routes after sorting:', sortedRoutes.slice(0, 5).map(r => ({ + id: r.fullname, + sortValue: getSortValue(r, option.key), + }))) + + return sortedRoutes +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 29a06ab9..4e9b9eb7 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -201,6 +201,7 @@ export default { 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', + 'spin': 'spin 1s linear infinite', }, transitionProperty: { indeterminate: 'transform, background-color',