mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(roadtrip): what a charger costs, before it is a stop
A tester found the charging panel once a station was on the trip and asked for it while still deciding. The availability and the published tariffs were reachable only for a saved place: the route takes a places id, and a hit found along the route has no row yet. ChargingService.load already needed nothing but a coordinate and a name, so it now has a public method that says so, behind a trip-scoped route with the same guard stack, a Zod contract in shared, and the parallel MCP tool in this same change. Same service, same caches, so a station looked at in the dialog and then added keys the entry already fetched. Trip-scoped even though the trip is not part of the question: without :tripId the trip access guard goes too, and what is left is an open proxy in front of somebody else's public API. It renders in the add dialog, and only while the selected kind is charging, so a fuel or food stop costs nothing and one opened dialog is one lookup. Deliberately not per result row: the corridor search publishes box by box, every mounted panel owns a refresh timer, a cache miss can cost seven upstream requests, and a station in a dense charging park is the case the upstream matcher refuses as ambiguous. Power and socket counts are not in this answer and were not asked of it. They are OSM tag data and already ride along on the corridor row for free.
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import ChargingInfo from './ChargingInfo'
|
||||
import { chargingRepo } from '../../repo/chargingRepo'
|
||||
|
||||
vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) }))
|
||||
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn() } }))
|
||||
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn(), readAt: vi.fn() } }))
|
||||
const info = { checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Station', source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0', updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0, tariffs: [], pricesUnavailable: false }
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
describe('Charging information', () => {
|
||||
it('shows availability, attribution and missing prices without inventing a price', async () => {
|
||||
vi.mocked(chargingRepo.read).mockResolvedValue(info)
|
||||
@@ -23,4 +24,49 @@ describe('Charging information', () => {
|
||||
expect(await screen.findByText('Status outdated')).toBeInTheDocument()
|
||||
expect(screen.queryByText('2/4')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// A station found along the route has no place row yet, and the availability and the
|
||||
// price are what somebody wants before deciding to add it.
|
||||
it('FE-ROADTRIP-CHARGING-010: a station that is not a stop yet is asked for by coordinate', async () => {
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(info)
|
||||
render(<TranslationProvider><ChargingInfo lat={48.137} lng={11.575} name="Ladepark Nord" /></TranslationProvider>)
|
||||
|
||||
expect(await screen.findByText('2/4 available')).toBeInTheDocument()
|
||||
expect(chargingRepo.readAt).toHaveBeenCalledWith(1, 48.137, 11.575, 'Ladepark Nord')
|
||||
expect(chargingRepo.read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CHARGING-011: pointed at another station, it asks again', async () => {
|
||||
// The coordinate is the identity here. Left out of the effect's dependencies the
|
||||
// panel would keep showing the first charger's availability for the second one.
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(info)
|
||||
const { rerender } = render(<TranslationProvider><ChargingInfo lat={48.137} lng={11.575} name="Ladepark Nord" /></TranslationProvider>)
|
||||
await screen.findByText('2/4 available')
|
||||
|
||||
rerender(<TranslationProvider><ChargingInfo lat={49.013} lng={12.101} name="Autohof Sued" /></TranslationProvider>)
|
||||
|
||||
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenLastCalledWith(1, 49.013, 12.101, 'Autohof Sued'))
|
||||
expect(chargingRepo.readAt).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CHARGING-012: a saved stop is still asked for by place id', async () => {
|
||||
vi.mocked(chargingRepo.read).mockResolvedValue(info)
|
||||
render(<TranslationProvider><ChargingInfo placeId={7} compact /></TranslationProvider>)
|
||||
|
||||
expect(await screen.findByText('2/4')).toBeInTheDocument()
|
||||
expect(chargingRepo.read).toHaveBeenCalledWith(1, 7)
|
||||
expect(chargingRepo.readAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CHARGING-013: an unidentified station is never shown as free', async () => {
|
||||
// `available` is null whenever the answer is not both fresh and countable, and an
|
||||
// unknown status is not spare capacity. Outside the covered regions this is the
|
||||
// usual answer, so it is the one the dialog has to get right.
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue({ ...info, status: 'ambiguous', available: null, total: 0, station: null })
|
||||
render(<TranslationProvider><ChargingInfo lat={48.137} lng={11.575} name="Ladepark Nord" /></TranslationProvider>)
|
||||
|
||||
// Said twice over: once where the free count would be, once where the price would.
|
||||
expect(await screen.findAllByText('No reliable data available')).toHaveLength(2)
|
||||
expect(screen.queryByText(/\d+\/\d+/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,20 @@ import { chargingRepo } from '../../repo/chargingRepo'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
export default function ChargingInfo({ placeId, compact = false }: { placeId: number; compact?: boolean }) {
|
||||
/**
|
||||
* Which station the panel is asking about, one way or the other.
|
||||
*
|
||||
* A stop on the trip is named by its `places` row. A station found along the route has
|
||||
* no row yet, and the availability and the price are exactly what somebody wants before
|
||||
* deciding to add it, so that one is named by where it is instead. The two are mutually
|
||||
* exclusive: the optional `undefined` members are what stop a caller passing both and
|
||||
* leaving it to the component to guess which it meant.
|
||||
*/
|
||||
type ChargingTarget =
|
||||
| { placeId: number; lat?: undefined; lng?: undefined; name?: undefined }
|
||||
| { placeId?: undefined; lat: number; lng: number; name: string }
|
||||
|
||||
export default function ChargingInfo({ placeId, lat, lng, name, compact = false }: ChargingTarget & { compact?: boolean }) {
|
||||
const tripId = useTripStore(s => s.trip?.id)
|
||||
const { t } = useTranslation()
|
||||
const [info, setInfo] = useState<Info | null>(null)
|
||||
@@ -17,7 +30,12 @@ export default function ChargingInfo({ placeId, compact = false }: { placeId: nu
|
||||
setInfo(null); setLoading(true)
|
||||
const refresh = async () => {
|
||||
if (!tripId || document.hidden) return
|
||||
try { const value = await chargingRepo.read(tripId, placeId); if (active) setInfo(value) }
|
||||
try {
|
||||
const value = placeId != null
|
||||
? await chargingRepo.read(tripId, placeId)
|
||||
: await chargingRepo.readAt(tripId, lat!, lng!, name!)
|
||||
if (active) setInfo(value)
|
||||
}
|
||||
catch { if (active) setInfo(null) }
|
||||
finally { if (active) setLoading(false) }
|
||||
}
|
||||
@@ -28,7 +46,9 @@ export default function ChargingInfo({ placeId, compact = false }: { placeId: nu
|
||||
window.addEventListener('online', refresh)
|
||||
document.addEventListener('visibilitychange', refresh)
|
||||
return () => { active = false; clearInterval(timer); window.removeEventListener('offline', offline); window.removeEventListener('online', refresh); document.removeEventListener('visibilitychange', refresh) }
|
||||
}, [tripId, placeId])
|
||||
// The identity of the station, whichever way it was given: a changed coordinate is a
|
||||
// different charger and has to re-ask, exactly as a changed place id does.
|
||||
}, [tripId, placeId, lat, lng, name])
|
||||
const known = !isEffectivelyOffline() && info?.available != null && !info.stale && Date.now() - Date.parse(info.checkedAt) < 120000
|
||||
const status = loading ? t('common.loading') : known ? `${info.available}/${info.total} ${t('roadtrip.charging.available')}` : t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown')
|
||||
const components = info?.tariffs.flatMap(tariff => tariff.components.map(component => ({ ...component, currency: tariff.currency }))) ?? []
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup'
|
||||
import { chargingRepo } from '../../repo/chargingRepo'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
|
||||
// The dialog carries the charging panel, which reads the open availability data for a
|
||||
// station that is not on the trip yet. Both of its collaborators are stubbed: the trip
|
||||
// is whichever one is open, and the data comes off the network.
|
||||
vi.mock('../../store/tripStore', () => ({ useTripStore: (select: (s: { trip: { id: number } }) => unknown) => select({ trip: { id: 1 } }) }))
|
||||
vi.mock('../../repo/chargingRepo', () => ({ chargingRepo: { read: vi.fn(), readAt: vi.fn() } }))
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
const charging = {
|
||||
checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Ladepark Nord',
|
||||
source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0',
|
||||
updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0,
|
||||
tariffs: [], pricesUnavailable: false,
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
function poi(over: Partial<CorridorPoi> = {}): CorridorPoi {
|
||||
return {
|
||||
osm_id: 'node:9',
|
||||
@@ -116,15 +132,24 @@ describe('RoadtripStopPopup', () => {
|
||||
['sights', 'Sights'],
|
||||
]
|
||||
|
||||
/**
|
||||
* One kind pill, by the name on it.
|
||||
*
|
||||
* Filtered on `aria-pressed` rather than taken as the only match: for a charging hit
|
||||
* the dialog also carries the charging panel, whose `<summary>` reads "Charging" too.
|
||||
*/
|
||||
const kindPill = (label: string): HTMLElement =>
|
||||
screen.getAllByRole('button', { name: new RegExp(label) }).filter(el => el.hasAttribute('aria-pressed'))[0]
|
||||
|
||||
it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => {
|
||||
const { unmount } = wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category, name: 'Found on the way' }) })} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(kindPill(label)).toHaveAttribute('aria-pressed', 'true')
|
||||
// Every other kind is off, so the preselect cannot be reading the wrong row. Scoped
|
||||
// to the kind buttons: the dwell presets use `aria-pressed` for their own state.
|
||||
for (const [, other] of CORRIDOR_KINDS) {
|
||||
if (other === label) continue
|
||||
expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(kindPill(other)).toHaveAttribute('aria-pressed', 'false')
|
||||
}
|
||||
unmount()
|
||||
})
|
||||
@@ -270,6 +295,56 @@ describe('RoadtripStopPopup', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Charging, before the add ───────────────────────────────────────────────
|
||||
//
|
||||
// The data was only reachable once the charger was on the trip, which is the wrong
|
||||
// way round: how busy the place is and what it charges is what decides whether to
|
||||
// add it at all.
|
||||
|
||||
const chargingDraft = () => draft({ poi: poi({ category: 'charging', name: 'Ladepark Nord', lat: 48.137, lng: 11.575 }) })
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-030: a charging hit is asked about by where it is', async () => {
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(charging)
|
||||
wrap(<RoadtripStopPopup draft={chargingDraft()} {...noop} />)
|
||||
|
||||
// By coordinate and name: there is no place row to name it by yet.
|
||||
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledWith(1, 48.137, 11.575, 'Ladepark Nord'))
|
||||
expect(await screen.findByText('2/4 available')).toBeInTheDocument()
|
||||
// The licence line travels with the numbers wherever they are shown.
|
||||
expect(screen.getByRole('link', { name: 'Operator' })).toHaveAttribute('href', 'https://example.com')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-031: a fuel stop costs nothing', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
|
||||
expect(chargingRepo.readAt).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('Published tariffs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-032: the question follows the kind that is picked', async () => {
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(charging)
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
expect(chargingRepo.readAt).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(kindPill('Charging'))
|
||||
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.click(kindPill('Fuel'))
|
||||
expect(screen.queryByText('2/4 available')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-033: one station, one question, however much else is changed', async () => {
|
||||
// A cache miss upstream costs several requests against a shared public registry, so
|
||||
// the dialog asks once and re-rendering it for an unrelated answer must not re-ask.
|
||||
vi.mocked(chargingRepo.readAt).mockResolvedValue(charging)
|
||||
wrap(<RoadtripStopPopup draft={chargingDraft()} {...noop} />)
|
||||
await waitFor(() => expect(chargingRepo.readAt).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /45 min/ }))
|
||||
|
||||
expect(chargingRepo.readAt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-025: without a way to book a night the switch stays away', () => {
|
||||
// An onSaveNight-less caller gets the dialog it always had, rather than a mode that
|
||||
// leads nowhere.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import StayPortals from './StayPortals'
|
||||
import ChargingInfo from './ChargingInfo'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
@@ -266,6 +267,26 @@ export default function RoadtripStopPopup({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How busy the charging area is and what it charges, while the stop is still
|
||||
being considered rather than once it is on the trip. Only for the kind that
|
||||
has an answer: a petrol station or a bakery asks nothing, so picking either
|
||||
of those costs no request at all.
|
||||
|
||||
One panel for one station, for as long as the dialog is open. The corridor
|
||||
list deliberately does not do this per row: it publishes results box by
|
||||
box, each row would own its own refresh timer, and a station in a dense
|
||||
row of chargers is the case the upstream match refuses as ambiguous. */}
|
||||
{stopType === 'charging' ? (
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.charging.availability')}
|
||||
</span>
|
||||
<div className="mt-1.5">
|
||||
<ChargingInfo lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { chargingRepo } from './chargingRepo'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }))
|
||||
vi.mock('../api/client', () => ({ apiClient: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
const info = {
|
||||
checkedAt: new Date().toISOString(), status: 'ok' as const, station: 'Ladepark Nord',
|
||||
source: 'Operator', sourceUrl: 'https://example.com', license: 'CC-0',
|
||||
updatedAt: new Date().toISOString(), stale: false, available: 2, total: 4, unknown: 0,
|
||||
tariffs: [], pricesUnavailable: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: info })
|
||||
})
|
||||
|
||||
describe('charging data for a station that is not a stop yet', () => {
|
||||
it('FE-ROADTRIP-CHARGEREPO-001: asks the trip-scoped route with the coordinate and the name', async () => {
|
||||
expect(await chargingRepo.readAt(1, 48.137, 11.575, 'Ladepark Nord')).toEqual(info)
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/trips/1/roadtrip/charging-lookup', { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' })
|
||||
// Never the saved-stop route: that one takes a place id, which a hit found along
|
||||
// the route does not have.
|
||||
expect(apiClient.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CHARGEREPO-002: the same station twice is one request, a different one is another', async () => {
|
||||
const first = chargingRepo.readAt(2, 48.2, 11.2, 'Ladepark')
|
||||
const again = chargingRepo.readAt(2, 48.2, 11.2, 'Ladepark')
|
||||
expect(await first).toEqual(await again)
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1)
|
||||
// A charger 200 m down the road is a different charger, cache or no cache.
|
||||
await chargingRepo.readAt(2, 48.202, 11.2, 'Ladepark')
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CHARGEREPO-003: offline it refuses rather than queueing a read', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
|
||||
await expect(chargingRepo.readAt(3, 48.3, 11.3, 'Ladepark')).rejects.toThrow('Offline')
|
||||
expect(apiClient.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CHARGEREPO-004: an answer that is not the contract is rejected, not rendered', async () => {
|
||||
// Availability is nullable on purpose and the panel branches on it; a string where
|
||||
// a count belongs must not reach it.
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { ...info, available: 'lots' } })
|
||||
await expect(chargingRepo.readAt(4, 48.4, 11.4, 'Ladepark')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -3,15 +3,42 @@ import { apiClient } from '../api/client'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
const pending = new Map<string, { until: number; value: Promise<ChargingInfo> }>()
|
||||
|
||||
/**
|
||||
* One request per station per minute, however many places ask for it.
|
||||
*
|
||||
* The rail renders a badge for every charging stop, the inspector a panel for the same
|
||||
* one and the add dialog a third for a station that is not on the trip yet, each on
|
||||
* its own refresh timer. The window is a shade under the server's own minute of cache,
|
||||
* so a refresh that gets through lands on a fresh answer rather than the cached one.
|
||||
*
|
||||
* A rejection stays in the map for the full window on purpose: a station the upstream
|
||||
* registry cannot answer for should not be asked again a second later.
|
||||
*/
|
||||
function coalesce(key: string, load: () => Promise<ChargingInfo>): Promise<ChargingInfo> {
|
||||
const cached = pending.get(key)
|
||||
if (cached && cached.until > Date.now()) return cached.value
|
||||
if (pending.size > 200) pending.clear()
|
||||
const value = load()
|
||||
pending.set(key, { until: Date.now() + 55000, value })
|
||||
return value
|
||||
}
|
||||
|
||||
export const chargingRepo = {
|
||||
async read(tripId: number, placeId: number) {
|
||||
if (isEffectivelyOffline()) throw new Error('Offline')
|
||||
const key = `${tripId}/${placeId}`
|
||||
const cached = pending.get(key)
|
||||
if (cached && cached.until > Date.now()) return cached.value
|
||||
if (pending.size > 200) pending.clear()
|
||||
const value = apiClient.get(`/trips/${tripId}/roadtrip/charging/${placeId}`).then(reply => chargingInfoSchema.parse(reply.data))
|
||||
pending.set(key, { until: Date.now() + 55000, value })
|
||||
return value
|
||||
return coalesce(`${tripId}/${placeId}`, () =>
|
||||
apiClient.get(`/trips/${tripId}/roadtrip/charging/${placeId}`).then(reply => chargingInfoSchema.parse(reply.data)))
|
||||
},
|
||||
/**
|
||||
* The same answer for a station the trip has not saved yet, which is the only way to
|
||||
* ask before adding it: the saved-stop route takes a place id, and a hit found along
|
||||
* the route has none. Cached under the coordinate rather than an id, so looking at
|
||||
* the same charger twice is one request.
|
||||
*/
|
||||
async readAt(tripId: number, lat: number, lng: number, name: string) {
|
||||
if (isEffectivelyOffline()) throw new Error('Offline')
|
||||
return coalesce(`${tripId}/at:${lat},${lng}:${name}`, () =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/charging-lookup`, { lat, lng, name }).then(reply => chargingInfoSchema.parse(reply.data)))
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user