-
-
Notifications
You must be signed in to change notification settings - Fork 128
Expand file tree
/
Copy pathindex.tsx
More file actions
160 lines (146 loc) · 5.71 KB
/
Copy pathindex.tsx
File metadata and controls
160 lines (146 loc) · 5.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { CircularProgress, Grid, Typography } from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useContext, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SIRI_API } from 'src/api/apiConfig'
import { getAllRoutesList } from 'src/api/gtfsService'
import dayjs, { ISRAEL_TIMEZONE, toIsraelTimezone, utcNoonForDateStr } from 'src/dayjs'
import { usePageState } from 'src/hooks/usePageState'
import { fromGtfsRoute } from 'src/model/busRoute'
import { GlobalSearchContext } from 'src/model/globalState'
import { serviceDayBounds } from 'src/pages/components/utils/startTimeUtils'
import VehicleSelector, { normalizeVehicleNumber } from 'src/pages/components/VehicleSelector'
import { DateSelector } from '../components/DateSelector'
import { NotFound } from '../components/NotFound'
import { PageContainer } from '../components/PageContainer'
import { buildVehicleRideRows, VehicleRideRow } from './buildVehicleRideRows'
import { VehicleTable } from './VehicleTable'
// null, not '', is the "no vehicle chosen" value: usePageState omits null params
// from the share URL, so an unset page still produces a clean link.
type VehicleParams = { vehicleNumber: string | null }
type VehicleUi = { scrollPosition: number }
const VehiclePage = () => {
const { t } = useTranslation()
const { search, setSearch } = useContext(GlobalSearchContext)
const { date } = search
// The vehicle number is page-local — never in GlobalSearchContext. usePageState
// persists it for the session, seeds it from `vehicle.vehicleNumber` in an
// incoming URL, and publishes it to the Share button.
const { params, setParams } = usePageState<VehicleParams, VehicleUi>('vehicle', {
params: { vehicleNumber: null },
ui: { scrollPosition: 0 },
})
const vehicleNumber = useMemo(
() => normalizeVehicleNumber(params.vehicleNumber ?? ''),
[params.vehicleNumber],
)
const setVehicleNumber = useCallback(
(value: number) => {
setParams((prev) => ({ ...prev, vehicleNumber: value ? String(value) : null }))
},
[setParams],
)
const { start: serviceDayStart, end: serviceDayEnd } = useMemo(
() => serviceDayBounds(date),
[date],
)
const handleDateChange = (time: dayjs.Dayjs | null) => {
setSearch((current) => ({
...current,
date: toIsraelTimezone(time ?? dayjs()).format('YYYY-MM-DD'),
}))
}
const {
data: rides,
isFetching,
isError,
} = useQuery({
queryKey: ['vehicleRides', vehicleNumber, serviceDayStart.valueOf(), serviceDayEnd.valueOf()],
enabled: !!vehicleNumber,
queryFn: ({ signal }) =>
SIRI_API.siriRidesListGet(
{
vehicleRefs: String(vehicleNumber),
scheduledStartTimeFrom: serviceDayStart.toDate(),
scheduledStartTimeTo: serviceDayEnd.toDate(),
orderBy: 'scheduled_start_time asc',
limit: 500,
},
{ signal },
),
})
// SIRI rides carry only siri_route__line_ref; the human-readable line number and
// route names (gtfs_route__*) are frequently null on the ride. Resolve them from
// the operator's GTFS routes for the service day — same source the operator page
// uses (getAllRoutesList) — and key by line ref to match each ride.
const operatorIds = useMemo(
() =>
Array.from(
new Set(
(rides ?? [])
.map((ride) => ride.gtfsRouteOperatorRef ?? ride.siriRouteOperatorRef)
.filter((ref): ref is number => ref != null)
.map(String),
),
),
[rides],
)
const { data: routes } = useQuery({
queryKey: ['vehicleRoutes', date, operatorIds],
enabled: operatorIds.length > 0,
queryFn: async ({ signal }) => {
const routeLists = await Promise.all(
operatorIds.map((operatorId) =>
getAllRoutesList(operatorId, utcNoonForDateStr(date), signal),
),
)
return routeLists
.flat()
.map((route) => ({ ...fromGtfsRoute(route), agencyName: route.agencyName }))
},
})
// Built here, not in queryFn: the query cache is persisted (JSON), and a Map
// would not survive serialization — it rehydrates as a plain object with no .get.
const routeByLineRef = useMemo(
() => new Map((routes ?? []).map((route) => [String(route.lineRef), route])),
[routes],
)
const rows = useMemo<VehicleRideRow[]>(
() => buildVehicleRideRows({ rides, routeByLineRef, serviceDayStart, date }),
[rides, serviceDayStart, date, routeByLineRef],
)
const handleRowClick = (payload: VehicleRideRow['setSearchPayload']) => {
if (!payload) return
setSearch((current) => ({ ...current, ...payload }))
}
return (
<PageContainer>
<Typography className="page-title" variant="h4">
{t('vehicle_page_title')}
</Typography>
{/* choose date + vehicle — centered block, like the velocity-heatmap page */}
<Grid container spacing={2} sx={{ width: '100%', maxWidth: 600, mx: 'auto' }}>
{/* choose date */}
<Grid size={{ sm: 6, xs: 12 }}>
<DateSelector time={dayjs.tz(date, ISRAEL_TIMEZONE)} onChange={handleDateChange} />
</Grid>
{/* choose vehicle */}
<Grid size={{ sm: 6, xs: 12 }}>
<VehicleSelector vehicleNumber={vehicleNumber} setVehicleNumber={setVehicleNumber} />
</Grid>
</Grid>
{vehicleNumber ? (
isError ? (
<NotFound>{t('vehicle_load_error')}</NotFound>
) : isFetching ? (
<CircularProgress sx={{ mt: 2 }} />
) : rows.length === 0 ? (
<NotFound>{t('vehicle_no_rides')}</NotFound>
) : (
<VehicleTable rows={rows} onRowClick={handleRowClick} />
)
) : null}
</PageContainer>
)
}
export default VehiclePage