Skip to content

Commit 902d14c

Browse files
authored
feat: Gaps page UI/UX (#1741)
1 parent 47f0d02 commit 902d14c

4 files changed

Lines changed: 126 additions & 90 deletions

File tree

src/api/gapsService.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import dayjs, { ISRAEL_TIMEZONE } from 'src/dayjs'
1+
import dayjs, { ISRAEL_TIMEZONE, toIsraelTimezone } from 'src/dayjs'
22
import { USER_CASE_API } from './apiConfig'
33

44
export type Gap = {
@@ -7,6 +7,27 @@ export type Gap = {
77
gtfsRideId?: number
88
}
99

10+
// What actually lives in the (localStorage-persisted) React Query cache: JSON-native
11+
// only. dayjs times are held as ISO strings and revived at the UI edge (GapsTable),
12+
// so a rehydrated cache can never hand the table a bare string to call .format() on.
13+
export type SerializedGap = {
14+
plannedStartTime?: string
15+
actualStartTime?: string
16+
gtfsRideId?: number
17+
}
18+
19+
export const serializeGap = (gap: Gap): SerializedGap => ({
20+
gtfsRideId: gap.gtfsRideId,
21+
plannedStartTime: gap.plannedStartTime?.toISOString(),
22+
actualStartTime: gap.actualStartTime?.toISOString(),
23+
})
24+
25+
export const reviveGap = (gap: SerializedGap): Gap => ({
26+
gtfsRideId: gap.gtfsRideId,
27+
plannedStartTime: gap.plannedStartTime ? toIsraelTimezone(gap.plannedStartTime) : undefined,
28+
actualStartTime: gap.actualStartTime ? toIsraelTimezone(gap.actualStartTime) : undefined,
29+
})
30+
1031
export function parseTime(time?: dayjs.ConfigType) {
1132
if (!time) return undefined
1233
const utcDayjs = dayjs.utc(time).utcOffset(0, true).tz(ISRAEL_TIMEZONE)

src/pages/gaps/GapsTable.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { TFunction } from 'i18next'
1313
import React, { memo, useMemo, useState } from 'react'
1414
import { useTranslation } from 'react-i18next'
1515
import { Link } from 'react-router'
16-
import { Gap } from 'src/api/gapsService'
16+
import { Gap, reviveGap, SerializedGap } from 'src/api/gapsService'
1717
import dayjs from 'src/dayjs'
1818
import {
1919
formatServiceDayTime,
@@ -26,9 +26,11 @@ import DisplayGapsPercentage from '../components/DisplayGapsPercentage'
2626
import { Row } from '../components/Row'
2727

2828
interface GapsTableProps {
29-
gaps?: Gap[]
29+
gaps?: SerializedGap[]
3030
loading?: boolean
3131
initOnlyGapped?: boolean
32+
onlyGapped?: boolean
33+
onOnlyGappedChange?: (value: boolean) => void
3234
singleLineMapBaseHref: string
3335
date: string
3436
onStartTimeClick?: (rideTime: string) => void
@@ -89,16 +91,28 @@ function buildTooltip(gap: Gap, t: TFunction): React.ReactNode {
8991
const getGap = (gap: Gap) => gap.plannedStartTime || gap.actualStartTime
9092

9193
const GapsTable: React.FC<GapsTableProps> = ({
92-
gaps,
94+
gaps: rawGaps,
9395
loading,
9496
initOnlyGapped = false,
97+
onlyGapped: onlyGappedProp,
98+
onOnlyGappedChange,
9599
singleLineMapBaseHref,
96100
date,
97101
onStartTimeClick,
98102
}) => {
99103
const { t } = useTranslation()
104+
// The gaps cache is persisted as JSON (dayjs → ISO strings). Revive to dayjs here,
105+
// at the single consumption edge, so all the comparison/formatting below is unchanged.
106+
const gaps = useMemo(() => rawGaps?.map(reviveGap), [rawGaps])
100107
const { start: serviceDayStart } = serviceDayBounds(date)
101-
const [onlyGapped, setOnlyGapped] = useState(initOnlyGapped)
108+
// Controllable: the gaps page owns and persists this via usePageState; the
109+
// story leaves it uncontrolled and seeds it with initOnlyGapped.
110+
const [onlyGappedState, setOnlyGappedState] = useState(initOnlyGapped)
111+
const onlyGapped = onlyGappedProp ?? onlyGappedState
112+
const setOnlyGapped = (value: boolean) => {
113+
setOnlyGappedState(value)
114+
onOnlyGappedChange?.(value)
115+
}
102116

103117
const filteredGaps: Gap[] = useMemo(() => {
104118
if (!gaps) return []
@@ -133,7 +147,7 @@ const GapsTable: React.FC<GapsTableProps> = ({
133147
}, [gaps])
134148

135149
return (
136-
<Widget marginBottom sx={{ overflowY: 'none', maxWidth: '600px' }}>
150+
<Widget marginBottom sx={{ overflowY: 'none', maxWidth: '600px', mx: 'auto' }}>
137151
<Row style={{ justifyContent: 'space-between', fontWeight: 500 }}>
138152
<FormControlLabel
139153
control={

src/pages/gaps/gapsTable.stories.tsx

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite'
2-
import { Gap } from 'src/api/gapsService'
3-
import dayjs from 'src/dayjs'
2+
import { Gap, serializeGap } from 'src/api/gapsService'
3+
import dayjs, { ISRAEL_TIMEZONE } from 'src/dayjs'
44
import GapsTable from './GapsTable'
55

66
const meta = {
@@ -38,43 +38,55 @@ const meta = {
3838
export default meta
3939

4040
type Story = StoryObj<typeof meta>
41-
const yesteday = dayjs().startOf('day').subtract(1, 'day')
41+
// Fixed Israel-local service day: dayjs.tz(str, tz) reads the digits AS Israel
42+
// wall-clock, so the rendered times are identical on every machine (CI runs in UTC)
43+
// and stable across DST — unlike dayjs(str).tz(tz), which re-anchors by the runner's
44+
// zone. The gaps are passed through serializeGap → reviveGap (the real cache path).
45+
const serviceDay = dayjs.tz('2025-01-01', ISRAEL_TIMEZONE)
4246
const mockGaps: Gap[] = [
47+
// as planned (green): planned === actual
4348
{
44-
plannedStartTime: yesteday.set('hour', 13),
45-
actualStartTime: yesteday.set('hour', 13),
49+
plannedStartTime: serviceDay.set('hour', 13),
50+
actualStartTime: serviceDay.set('hour', 13),
4651
},
52+
// duplicate (cyan): a second ride sharing the 13:00 actual
4753
{
4854
plannedStartTime: undefined,
49-
actualStartTime: yesteday.set('hour', 13),
55+
actualStartTime: serviceDay.set('hour', 13),
5056
},
57+
// missing (red): a past planned ride with no actual
5158
{
52-
plannedStartTime: yesteday.set('hour', 14),
59+
plannedStartTime: serviceDay.set('hour', 14),
5360
actualStartTime: undefined,
5461
},
62+
// extra (yellow): an actual with no matching planned
5563
{
5664
plannedStartTime: undefined,
57-
actualStartTime: yesteday.set('hour', 14).set('minute', 30),
65+
actualStartTime: serviceDay.set('hour', 14).set('minute', 30),
5866
},
67+
// in the future (blue): GapsTable derives this state by comparing against the
68+
// current time, so this row must stay relative to "now" — a fixed past literal
69+
// would render as "missing". Being a later calendar day, it also carries the
70+
// single 🌙 next-day marker.
5971
{
60-
plannedStartTime: yesteday.add(2, 'day').set('hour', 15),
72+
plannedStartTime: dayjs().tz(ISRAEL_TIMEZONE).startOf('day').add(1, 'day').set('hour', 15),
6173
actualStartTime: undefined,
6274
},
6375
]
6476

6577
export const Default: Story = {
6678
args: {
67-
gaps: mockGaps,
68-
date: dayjs().format('YYYY-MM-DD'),
79+
gaps: mockGaps.map(serializeGap),
80+
date: serviceDay.format('YYYY-MM-DD'),
6981
singleLineMapBaseHref:
7082
'/single-line-map?date=2025-01-01&operatorId=3&lineNumber=5&routeKey=10018-2',
7183
},
7284
}
7385

7486
export const OnlyGappe: Story = {
7587
args: {
76-
gaps: mockGaps,
77-
date: dayjs().format('YYYY-MM-DD'),
88+
gaps: mockGaps.map(serializeGap),
89+
date: serviceDay.format('YYYY-MM-DD'),
7890
initOnlyGapped: true,
7991
singleLineMapBaseHref:
8092
'/single-line-map?date=2025-01-01&operatorId=3&lineNumber=5&routeKey=10018-2',

src/pages/gaps/index.tsx

Lines changed: 60 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Alert, CircularProgress, Grid, Typography } from '@mui/material'
2-
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
2+
import { useQuery } from '@tanstack/react-query'
3+
import { useCallback, useContext, useMemo } from 'react'
34
import { useTranslation } from 'react-i18next'
45
import dayjs, { ISRAEL_TIMEZONE } from 'src/dayjs'
6+
import { usePageState } from 'src/hooks/usePageState'
57
import { GlobalSearchContext } from 'src/model/globalState'
68
import { INPUT_SIZE } from 'src/resources/sizes'
7-
import { Gap, getGapsAsync } from '../../api/gapsService'
9+
import { getGapsAsync, SerializedGap, serializeGap } from '../../api/gapsService'
810
import { getServiceDayRoutes } from '../../api/serviceDayRoutesService'
9-
import { BusRoute } from '../../model/busRoute'
1011
import { DateSelector } from '../components/DateSelector'
1112
import { Label } from '../components/Label'
1213
import LineNumberSelector from '../components/LineSelector'
@@ -22,9 +23,13 @@ const GapsPage = () => {
2223
const { t } = useTranslation()
2324
const { search, setSearch } = useContext(GlobalSearchContext)
2425
const { operatorId, lineNumber, date, routeKey } = search
25-
const [routes, setRoutes] = useState<BusRoute[] | undefined>()
26-
const [gaps, setGaps] = useState<Gap[]>()
27-
const [gapsIsLoading, setGapsIsLoading] = useState(false)
26+
27+
// scrollPosition (auto-restored by usePageState) and the "only gaps" toggle are
28+
// device-local UI state, kept out of the shareable params.
29+
const { ui, setUi } = usePageState('gaps', {
30+
params: {},
31+
ui: { scrollPosition: 0, gapsOnly: false },
32+
})
2833

2934
const singleLineMapBaseHref = useMemo(() => {
3035
const params = new URLSearchParams()
@@ -35,48 +40,39 @@ const GapsPage = () => {
3540
return `/single-line-map?${params.toString()}`
3641
}, [search.date, search.lineNumber, search.operatorId, search.routeKey])
3742

38-
useEffect(() => {
39-
if (!(operatorId && routes && routeKey && date)) return
40-
const selectedRoute = routes.find((route) => route.key === routeKey)
41-
if (!selectedRoute) return
42-
43-
setGapsIsLoading(true)
44-
const { start, end } = serviceDayBounds(date)
45-
getGapsAsync(start, end, operatorId, selectedRoute.lineRef)
46-
.then((res) =>
47-
setGaps(
48-
res.filter((g) => {
49-
const t = g.plannedStartTime || g.actualStartTime
50-
return t && !t.isBefore(start) && t.isBefore(end)
51-
}),
52-
),
53-
)
54-
.catch((err) => {
55-
console.error('Failed to fetch gaps:', err.message)
56-
setGaps(undefined)
57-
})
58-
.finally(() => setGapsIsLoading(false))
59-
}, [operatorId, routes, routeKey, date])
60-
61-
useEffect(() => {
62-
if (!operatorId || !lineNumber) {
63-
return
64-
}
65-
66-
const controller = new AbortController()
43+
const routesQuery = useQuery({
44+
queryFn: ({ signal }) => {
45+
if (!operatorId || !lineNumber) return null
46+
return getServiceDayRoutes(dayjs.tz(date, ISRAEL_TIMEZONE), operatorId, lineNumber, signal)
47+
},
48+
queryKey: ['gapsRoutes', operatorId, lineNumber, date],
49+
})
50+
const routes = routesQuery.data ?? undefined
6751

68-
getServiceDayRoutes(dayjs.tz(date, ISRAEL_TIMEZONE), operatorId, lineNumber, controller.signal)
69-
.then((fetchedRoutes) => {
70-
if (search.lineNumber === lineNumber) {
71-
setRoutes(fetchedRoutes)
72-
}
73-
})
74-
.catch((err) => {
75-
console.error('Failed to fetch routes:', err.message)
76-
})
52+
const selectedRoute = useMemo(
53+
() => routes?.find((route) => route.key === routeKey),
54+
[routes, routeKey],
55+
)
7756

78-
return () => controller.abort()
79-
}, [operatorId, lineNumber, date, setSearch])
57+
const gapsQuery = useQuery({
58+
queryFn: async (): Promise<SerializedGap[] | null> => {
59+
if (!operatorId || !selectedRoute || !date) return null
60+
const { start, end } = serviceDayBounds(date)
61+
const res = await getGapsAsync(start, end, operatorId, selectedRoute.lineRef)
62+
return (
63+
res
64+
.filter((g) => {
65+
const gapTime = g.plannedStartTime || g.actualStartTime
66+
return gapTime && !gapTime.isBefore(start) && gapTime.isBefore(end)
67+
})
68+
// Store JSON-serializable strings, not dayjs, so the persisted cache
69+
// rehydrates losslessly; GapsTable revives them to dayjs on read.
70+
.map(serializeGap)
71+
)
72+
},
73+
queryKey: ['gaps', operatorId, selectedRoute?.lineRef, date],
74+
})
75+
const gaps = gapsQuery.data ?? undefined
8076

8177
const handleDateChange = (time: dayjs.Dayjs | null) => {
8278
if (!time) return
@@ -87,7 +83,9 @@ const GapsPage = () => {
8783
}
8884

8985
const handleOperatorChange = (operatorId: string) => {
90-
setSearch((current) => ({ ...current, operatorId }))
86+
// Changing/clearing the operator invalidates the chosen route (routes are
87+
// per operator+line), so reset it to close the stale results table.
88+
setSearch((current) => ({ ...current, operatorId, routeKey: null }))
9189
}
9290

9391
const handleLineNumberChange = (lineNumber: string) => {
@@ -96,9 +94,6 @@ const GapsPage = () => {
9694
? { ...current }
9795
: { ...current, lineNumber, routeKey: null },
9896
)
99-
if (lineNumber !== search.lineNumber) {
100-
setRoutes(undefined)
101-
}
10297
}
10398

10499
const handleRouteKeyChange = (routeKey?: string) => {
@@ -123,36 +118,28 @@ const GapsPage = () => {
123118
<Alert severity="info" variant="outlined" icon={false}>
124119
{t('gaps_page_description')}
125120
</Alert>
126-
<Grid container spacing={2} sx={{ maxWidth: INPUT_SIZE }}>
121+
<Grid container spacing={2} sx={{ maxWidth: INPUT_SIZE, width: '100%', mx: 'auto' }}>
127122
{/* choose date */}
128-
<Grid size={{ xs: 4 }}>
129-
<Label text={t('choose_date')} />
130-
</Grid>
131-
<Grid size={{ xs: 8 }}>
123+
<Grid size={{ sm: 6, xs: 12 }}>
132124
<DateSelector time={dayjs.tz(date, ISRAEL_TIMEZONE)} onChange={handleDateChange} />
133125
</Grid>
134126
{/* choose operator */}
135-
<Grid size={{ xs: 4 }}>
136-
<Label text={t('choose_operator')} />
137-
</Grid>
138-
<Grid size={{ xs: 8 }}>
127+
<Grid size={{ sm: 6, xs: 12 }}>
139128
<OperatorSelector
140129
operatorId={operatorId ?? undefined}
141130
setOperatorId={handleOperatorChange}
142131
/>
143132
</Grid>
144133
{/* choose line */}
145-
<Grid size={{ xs: 4 }}>
146-
<Label text={t('choose_line')} />
147-
</Grid>
148-
<Grid size={{ xs: 8 }}>
134+
<Grid size={{ sm: 6, xs: 12 }}>
149135
<LineNumberSelector
136+
disabled={!operatorId}
150137
lineNumber={lineNumber ?? undefined}
151138
setLineNumber={handleLineNumberChange}
152139
/>
153140
</Grid>
154-
{/* choose routes */}
155-
<Grid size={{ xs: 12 }}>
141+
{/* choose route */}
142+
<Grid size={{ sm: 6, xs: 12 }}>
156143
{routes?.length === 0 ? (
157144
<NotFound>{t('line_not_found')}</NotFound>
158145
) : (
@@ -164,22 +151,24 @@ const GapsPage = () => {
164151
/>
165152
)}
166153
</Grid>
167-
<Grid size={{ xs: 12 }}>
168-
{gapsIsLoading && (
154+
{gapsQuery.isLoading && (
155+
<Grid size={{ xs: 12 }}>
169156
<Row>
170157
<Label text={t('loading_gaps')} />
171158
<CircularProgress />
172159
</Row>
173-
)}
174-
</Grid>
160+
</Grid>
161+
)}
175162
</Grid>
176-
{routeKey && routeKey !== '' && (
163+
{selectedRoute && (
177164
<GapsTable
178-
loading={gapsIsLoading}
165+
loading={gapsQuery.isLoading}
179166
gaps={gaps}
180167
date={date}
181168
singleLineMapBaseHref={singleLineMapBaseHref}
182169
onStartTimeClick={handleStartTimeClick}
170+
onlyGapped={ui.gapsOnly}
171+
onOnlyGappedChange={(value) => setUi((prev) => ({ ...prev, gapsOnly: value }))}
183172
/>
184173
)}
185174
</PageContainer>

0 commit comments

Comments
 (0)