Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Sort and filter routes #66

Draft
wants to merge 22 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ca0fdf7
UI looks good but data NOT linked
ugtthis Jul 10, 2024
3bf268c
Its functioning - Still needs work
ugtthis Jul 10, 2024
020f4a6
Switch sort arrows to reflect asc dsc
ugtthis Jul 10, 2024
05cf3c7
UI still functions - cleaner code - but still needs work
ugtthis Jul 10, 2024
67646d7
Includes console log to understand why sorting NOT desc
ugtthis Jul 10, 2024
424b3b0
Added hover effect for filter btns
ugtthis Jul 10, 2024
e6e3361
Remove console log code and changed some stuff
ugtthis Jul 11, 2024
4a5c020
TEMP fix for desc to be default - also removed null
ugtthis Jul 12, 2024
e5e261f
Added comments - maybe to much
ugtthis Jul 12, 2024
25af92f
Added comments and fixed code from previous commit
ugtthis Jul 12, 2024
d836cc6
Desc bug was in sorting.ts - desc is now default w/out temp fix
ugtthis Jul 12, 2024
86f6df8
Made it full width so it stretches out on desktop
ugtthis Jul 16, 2024
37d488d
Resolves merge conflict ?
ugtthis Jul 17, 2024
0b86a9a
Works again - Rm date btn - infinite scroll works better - performanc…
ugtthis Jul 17, 2024
bb5d07e
Deleted eslint comment, not needed ?
ugtthis Jul 17, 2024
f9a0410
Skeleton UI shows up faster
ugtthis Jul 17, 2024
c170ebe
Merge branch 'commaai:master' into sort-filter-feature
ugtthis Jul 18, 2024
96724e6
Temp UI on card - Faster sorting - Code still needs more work
ugtthis Jul 21, 2024
dd05eee
Add back skeleton UI
ugtthis Jul 21, 2024
cd9042f
Better sorting logic and performance RouteList
ugtthis Jul 21, 2024
0137ff3
Rm duplicate routes - added debouncing data fetches
ugtthis Jul 21, 2024
2f85f32
Added loading spinnner - Worked on infinite scroll
ugtthis Jul 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 77 additions & 14 deletions src/api/derived.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -85,14 +90,33 @@ export interface TimelineStatistics {
userFlags: number
}

const getDerived = <T>(route: Route, fn: string): Promise<T[]> => {
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 <T>(url: string, attempts: number = 3): Promise<T | null> => {
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 <T>(route: Route, fn: string): Promise<T[]> => {
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<T>(url)))
return results.flat().filter(item => item !== null) as T[]
}

export const getCoords = (route: Route): Promise<GPSPathPoint[]> =>
Expand All @@ -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
Expand Down Expand Up @@ -170,7 +190,6 @@ const generateTimelineEvents = (
}
})

// ensure events have an end timestamp
if (lastEngaged) {
res.push({
type: 'engaged',
Expand Down Expand Up @@ -227,3 +246,47 @@ export const getTimelineStatistics = async (
getTimelineEvents(route).then((timeline) =>
generateTimelineStatistics(route, timeline),
)

export const fetchRoutesWithinDays = async (dongleId: string, days: number): Promise<RouteSegments[]> => {
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<RouteSegments[]>(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<RouteSegmentsWithStats[]> => {
const routes = await fetchRoutesWithinDays(dongleId, days)
console.log('Fetched routes:', routes.length)
const routesWithStats = await Promise.all(
routes.map(async (route): Promise<RouteSegmentsWithStats> => {
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
}
18 changes: 18 additions & 0 deletions src/api/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,21 @@ export const getQCameraStreamUrl = (routeName: Route['fullname']): Promise<strin
getRouteShareSignature(routeName).then((signature) =>
createQCameraStreamUrl(routeName, signature),
)

export const fetchRoutes = async ({
dongleId,
page,
pageSize,
}: {
dongleId: string
page: number
pageSize: number
}): Promise<RouteSegments[]> => {
const endpoint = `/v1/devices/${dongleId}/routes_segments`
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((page - 1) * pageSize).toString(),
})

return fetcher<RouteSegments[]>(`${endpoint}?${params.toString()}`)
}
28 changes: 26 additions & 2 deletions src/components/RouteCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<RouteCardProps> = (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 (
<Card href={`/${props.route.dongle_id}/${props.route.fullname.slice(17)}`}>
<RouteHeader route={props.route} />
Expand All @@ -48,6 +69,9 @@ const RouteCard: VoidComponent<RouteCardProps> = (props) => {

<CardContent>
<RouteStatistics route={props.route} />
<div class="mt-2 text-sm font-bold text-primary">
{props.sortKey}: {getSortedValue()}
</div>
</CardContent>
</Card>
)
Expand Down
92 changes: 92 additions & 0 deletions src/components/RouteSorter.tsx
Original file line number Diff line number Diff line change
@@ -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<RouteSorterProps> = (props) => {
const [sortOptions] = createStore<SortOption[]>([
{ 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 (
<div class="w-full pt-2.5">
<div
class="hide-scrollbar flex overflow-x-auto pb-1"
style={{ 'scroll-behavior': 'smooth' }}
onWheel={handleScroll}
>
<For each={sortOptions}>
{(option) => (
<button
class="relative mr-2 flex items-center justify-center overflow-hidden whitespace-nowrap rounded-sm px-5 py-3 text-sm transition-all duration-500 ease-in-out first:ml-1"
style={{ 'min-width': 'fit-content' }}
onClick={() => handleClick(option)}
>
<div
class={`absolute inset-0 bg-gradient-to-r ${GRADIENT} transition-all duration-300 ease-in-out`}
style={{
opacity: props.currentSort.key === option.key ? 1 : 0,
'background-size': '200% 100%',
'background-position': props.currentSort.order === 'asc' ? 'right bottom' : 'left bottom',
}}
/>
<div
class="absolute inset-0 bg-black transition-opacity duration-300 ease-in-out"
style={{ opacity: props.currentSort.key === option.key ? 0.4 : 0 }}
/>
<div
class="absolute inset-0 bg-surface-container transition-opacity duration-500 ease-in"
style={{ opacity: props.currentSort.key === option.key ? 0 : 1 }}
/>
<span class={`relative z-10 antialiased transition-colors duration-300 ${props.currentSort.key === option.key ? 'font-semibold text-white' : 'font-regular text-gray-400'}`}>
{option.label}
</span>
{props.currentSort.key === option.key && (
<span class="relative z-10 ml-3 inline-block w-4 text-white transition-transform duration-300">
{props.currentSort.order === 'asc' ? '↑' : props.currentSort.order === 'desc' ? '↓' : ''}
</span>
)}
{/* Added div for hover effect since gradient effect is using absolute positioning */}
<div class="absolute inset-0 z-20 bg-white opacity-0 mix-blend-soft-light transition-opacity duration-300 ease-in-out hover:opacity-25" />
</button>
)}
</For>
</div>
</div>
)
}

export default RouteSorter
Loading