mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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)))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
|
||||
import { chargingLookupSchema } from '@trek/shared';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { ChargingService } from './charging.service';
|
||||
|
||||
/** Exported so the parity test can hold the route and the MCP tool to the same contract. */
|
||||
export class ChargingLookupDto extends createZodDto(chargingLookupSchema) {}
|
||||
|
||||
/**
|
||||
* Charging availability for a station nobody has added yet.
|
||||
*
|
||||
* A sibling of `ChargingController` rather than another verb on it, because the two
|
||||
* ask in different ways: that one names a row in `places`, this one hands over a
|
||||
* coordinate. Same service, same cache, same answer.
|
||||
*
|
||||
* Trip-scoped on purpose, even though the trip is not part of the question. Leaving
|
||||
* `:tripId` out would take `TripAccessGuard` with it, and what is left is an open
|
||||
* proxy in front of somebody else's public API: anyone with the URL could aim it
|
||||
* anywhere on the planet at whatever rate they liked. Nothing asks this while not
|
||||
* planning a trip, so the scope costs the caller nothing.
|
||||
*
|
||||
* A POST for a read follows `RoadtripSearchController`: the input is a coordinate pair
|
||||
* and a name, which belongs in a body behind a contract rather than in a query string.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/roadtrip/charging-lookup')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
|
||||
export class ChargingLookupController {
|
||||
constructor(private readonly charging: ChargingService) {}
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
lookup(@Body() input: ChargingLookupDto) { return this.charging.lookup(input.lat, input.lng, input.name); }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chargingLookupSchema } from '@trek/shared';
|
||||
import { z } from 'zod';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
@@ -17,4 +18,11 @@ export class ChargingMcp {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok(await this.charging.read(tripId, placeId));
|
||||
}
|
||||
@Tool({ name: 'lookup_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a charging station by coordinate, before it is added to a trip. Needs a trip only for access control. No API key required. Every caveat of get_roadtrip_charging_info applies unchanged: coverage varies by region and operator, unknown or stale status is not available capacity, prices are gross per-component figures that can carry conditions and extra fees and are not personalized roaming quotes. Report the source, licence and timestamp alongside any figure from it. Charging power, socket types and socket counts are not in this answer; they come from the place search.',
|
||||
inputSchema: { tripId: z.number().int().positive(), ...chargingLookupSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
|
||||
async lookup({ tripId, lat, lng, name }: { tripId: number; lat: number; lng: number; name: string }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok(await this.charging.lookup(lat, lng, name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { DatabaseService } from '../database/database.service';
|
||||
import { readCappedJson } from '../../utils/cappedFetch';
|
||||
import { chargingLocation, chargingSource, chargingTariff, matchChargingLocation, normalizeCharging, type ChargingSource, type ChargingTariff } from './charging.helpers';
|
||||
|
||||
const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false });
|
||||
/** The shape of "we cannot say", exported so nothing has to restate its thirteen fields. */
|
||||
export const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false });
|
||||
async function request(path: string) {
|
||||
const fetched = await fetch(`https://api.mobidata-bw.de/ocpdb${path}`, { headers: { 'User-Agent': 'TREK Roadtrip (https://github.com/liketrek/TREK)' }, signal: AbortSignal.timeout(12000), redirect: 'error' });
|
||||
if (!fetched.ok) { await fetched.body?.cancel(); throw new Error('Charging source unavailable'); }
|
||||
@@ -22,11 +23,28 @@ export class ChargingService {
|
||||
const place = this.db.get<{ name: string; lat: number | null; lng: number | null; stop_type: string }>('SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
|
||||
if (!place) throw new HttpException({ error: 'Place not found' }, 404);
|
||||
if (place.stop_type !== 'charging' || place.lat == null || place.lng == null) return empty('unknown');
|
||||
const key = `${place.lat},${place.lng}:${place.name}`;
|
||||
return this.lookup(place.lat, place.lng, place.name);
|
||||
}
|
||||
/**
|
||||
* The same answer for a station the trip has not saved yet.
|
||||
*
|
||||
* A hit found along the route has no `places` row, so it cannot be asked for by id,
|
||||
* but the match never needed one: a coordinate and a name is the whole input. Sharing
|
||||
* `cached` with the saved-stop path matters twice over. The answer a charger gives
|
||||
* while it is being considered is the answer it gives once it is on the trip, and the
|
||||
* minute of cover is what keeps a second look from becoming a second round of
|
||||
* upstream requests.
|
||||
*
|
||||
* The cache is keyed on the coordinate exactly as it arrived, not on a rounded one:
|
||||
* a stop added from a corridor hit carries the coordinate the hit had, so the two
|
||||
* paths land on the same entry without either of them knowing about the other.
|
||||
*/
|
||||
lookup(lat: number, lng: number, name: string): Promise<ChargingInfo> {
|
||||
const key = `${lat},${lng}:${name}`;
|
||||
const cached = this.cached.get(key);
|
||||
if (cached && cached.expires > Date.now()) return cached.value;
|
||||
if (this.cached.size >= 300) this.cached.delete(this.cached.keys().next().value!);
|
||||
const value = this.load(place.lat, place.lng, place.name).catch(() => empty('unavailable'));
|
||||
const value = this.load(lat, lng, name).catch(() => empty('unavailable'));
|
||||
this.cached.set(key, { expires: Date.now() + 60000, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChargingMcp } from './charging.mcp';
|
||||
import { ChargingService } from './charging.service';
|
||||
import { ChargingController } from './charging.controller';
|
||||
import { ChargingLookupController } from './charging-lookup.controller';
|
||||
import { GoogleRouteService } from './google-route.service';
|
||||
import { GoogleRouteController } from './google-route.controller';
|
||||
import { GoogleRouteMcp } from './google-route.mcp';
|
||||
@@ -47,7 +48,7 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
|
||||
PluginsRuntimeModule,
|
||||
MapsModule, PlacesModule, AssignmentsModule,
|
||||
],
|
||||
controllers: [ChargingController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
||||
controllers: [ChargingController, ChargingLookupController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
||||
providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp,
|
||||
RoadtripSearchService,
|
||||
RoadtripHazardsService,
|
||||
|
||||
@@ -146,6 +146,26 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => {
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
} finally { read.mockRestore(); }
|
||||
});
|
||||
it('gates the coordinate lookup like the saved stop and validates before fetching', async () => {
|
||||
const lookup = vi.spyOn(app.get(ChargingService), 'lookup').mockResolvedValue({} as never);
|
||||
const body = { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' };
|
||||
const post = () => request(server).post('/api/trips/5/roadtrip/charging-lookup').set('Cookie', cookie());
|
||||
try {
|
||||
await request(server).post('/api/trips/5/roadtrip/charging-lookup').send(body).expect(401);
|
||||
await request(server).post('/api/trips/6/roadtrip/charging-lookup').set('Cookie', cookie()).send(body).expect(404);
|
||||
await post().send({ ...body, lat: 91 }).expect(400);
|
||||
// A list is the shape a caller would reach for to fan a whole corridor out in one
|
||||
// request. There is no rate limiter here and each miss costs the upstream registry
|
||||
// several requests, so the contract refuses it before anything leaves the server.
|
||||
await post().send([body]).expect(400);
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
await post().send(body).expect(200);
|
||||
expect(lookup).toHaveBeenCalledWith(48.137, 11.575, 'Ladepark Nord');
|
||||
setAddon(false);
|
||||
await post().send(body).expect(404);
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
} finally { lookup.mockRestore(); }
|
||||
});
|
||||
it('validates Google route preview and import before executing either service', async () => {
|
||||
const routes = app.get(GoogleRouteService);
|
||||
const preview = vi.spyOn(routes, 'preview').mockResolvedValue({ stops: [] });
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChargingService } from '../../../src/nest/roadtrip/charging.service';
|
||||
import { chargingLookupSchema } from '@trek/shared';
|
||||
import { ChargingService, empty } from '../../../src/nest/roadtrip/charging.service';
|
||||
import { ChargingLookupController, ChargingLookupDto } from '../../../src/nest/roadtrip/charging-lookup.controller';
|
||||
import { ChargingMcp } from '../../../src/nest/roadtrip/charging.mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { matchChargingLocation, normalizeCharging, type ChargingLocation, type ChargingSource, type ChargingTariff } from '../../../src/nest/roadtrip/charging.helpers';
|
||||
|
||||
const now = Date.parse('2026-09-12T12:00:00Z');
|
||||
@@ -59,3 +63,86 @@ describe('Open charging data', () => {
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The same data for a station nobody has added yet (#1797 feedback: "it would be useful
|
||||
* to see this data also before the add"). One service, one cache, one answer: the only
|
||||
* difference is how the station is named.
|
||||
*/
|
||||
describe('Open charging data before the stop exists', () => {
|
||||
const answers = (tariffs: unknown) => vi.fn(async (url: string) => {
|
||||
if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] }));
|
||||
if (url.includes('/locations')) return new Response(JSON.stringify({ items: [station], total_count: 1 }));
|
||||
return new Response(JSON.stringify(tariffs));
|
||||
});
|
||||
|
||||
it('answers for a coordinate and lets the saved stop reuse that answer', async () => {
|
||||
const fetcher = answers({ items: [tariff], total_count: 1 });
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
const service = new ChargingService({ get: () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never);
|
||||
|
||||
const ahead = await service.lookup(48, 11, 'Test');
|
||||
expect(ahead.status).toBe('ok');
|
||||
// The count of individual charge points, which is what "how many chargers" means.
|
||||
expect(ahead).toMatchObject({ total: 3, available: 1, unknown: 1 });
|
||||
expect(ahead.tariffs[0].components[0]).toMatchObject({ kind: 'ENERGY', taxIncluded: true });
|
||||
// Added to the trip, the same coordinate and name key the same cache entry, so the
|
||||
// stop on the rail does not pay for the answer the dialog already got.
|
||||
expect(await service.read(1, 2)).toEqual(ahead);
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('never reports a station it could not identify as free', async () => {
|
||||
// Two operators within the radius is the dense case a station found along a route
|
||||
// routinely is. Returning the nearest one would attach another operator's prices.
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] }));
|
||||
return new Response(JSON.stringify({ items: [station, { ...station, id: 'other', operator: { name: 'Other' } }], total_count: 2 }));
|
||||
}));
|
||||
const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Charging');
|
||||
expect(info.status).toBe('ambiguous');
|
||||
expect(info.available).toBeNull();
|
||||
});
|
||||
|
||||
it('turns an unreachable source into an empty answer, not a failed request', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 503 })));
|
||||
const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Test');
|
||||
expect(info).toEqual({ ...empty('unavailable'), checkedAt: info.checkedAt });
|
||||
});
|
||||
|
||||
it('does not go to the database for a station that is not in it', async () => {
|
||||
vi.stubGlobal('fetch', answers({ items: [], total_count: 0 }));
|
||||
const get = vi.fn();
|
||||
await new ChargingService({ get } as never).lookup(48, 11, 'Test');
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves the route and the MCP tool from one contract and one access check', async () => {
|
||||
const charging = { read: vi.fn(), lookup: vi.fn(async () => empty('unknown')) };
|
||||
const db = { canAccessTrip: vi.fn(() => false) };
|
||||
const tool = new ChargingMcp(charging as never, db as never, {} as never);
|
||||
const input = { lat: 48.1, lng: 11.5, name: 'Ladepark Nord' };
|
||||
|
||||
// A trip the caller cannot reach is refused before anything is fetched, exactly as
|
||||
// TripAccessGuard refuses it on the route.
|
||||
expect((await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never)).isError).toBe(true);
|
||||
expect(charging.lookup).not.toHaveBeenCalled();
|
||||
db.canAccessTrip.mockReturnValue(true);
|
||||
await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never);
|
||||
await new ChargingLookupController(charging as never).lookup(input as unknown as ChargingLookupDto);
|
||||
|
||||
expect(charging.lookup.mock.calls).toEqual([[48.1, 11.5, 'Ladepark Nord'], [48.1, 11.5, 'Ladepark Nord']]);
|
||||
// Both surfaces validate through the shared contract rather than a restatement of
|
||||
// it, so a bound or a length limit cannot be tightened on one side only.
|
||||
for (const bad of [{ ...input, lat: 91 }, { ...input, lng: -181 }, { ...input, name: 'x'.repeat(201) }]) {
|
||||
expect(ChargingLookupDto.schema.safeParse(bad).success).toBe(false);
|
||||
expect(chargingLookupSchema.safeParse(bad).success).toBe(false);
|
||||
}
|
||||
expect(ChargingLookupDto.schema.safeParse(input).success).toBe(true);
|
||||
const declared = getEntry(ChargingMcp as unknown as ClassRef, 'lookup')?.options as { inputSchema: Record<string, unknown> };
|
||||
expect(Object.keys(declared.inputSchema).sort()).toEqual(['lat', 'lng', 'name', 'tripId']);
|
||||
for (const [field, type] of Object.entries(chargingLookupSchema.shape)) {
|
||||
expect(declared.inputSchema[field]).toBe(type);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.prices': 'التعريفات المنشورة',
|
||||
'roadtrip.charging.note': 'تعريفة منشورة؛ قد تُطبق ضرائب وشروط ورسوم إضافية. قد يختلف سعر بطاقة الشحن.',
|
||||
'roadtrip.charging.coverage': 'بيانات مفتوحة؛ تختلف التغطية حسب المنطقة والمشغل.',
|
||||
'roadtrip.charging.availability': 'التوفر والتعريفات',
|
||||
'roadtrip.import.title': 'استيراد مسار Google Maps',
|
||||
'roadtrip.import.note': 'تُضاف المحطات إلى اليوم المحدد. يعيد TREK حساب المسار.',
|
||||
'roadtrip.import.unresolved': 'تعذر تحديد الموقع؛ سيتم تخطي هذه المحطة.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Tarifa publicada; impostos, condições e taxas extras podem se aplicar. O preço do seu cartão pode variar.',
|
||||
'roadtrip.charging.coverage': 'Dados abertos; cobertura varia por região e operador.',
|
||||
'roadtrip.charging.availability': 'Disponibilidade e tarifas',
|
||||
'roadtrip.import.title': 'Importar rota do Google Maps',
|
||||
'roadtrip.import.note': 'As paradas são adicionadas ao dia escolhido. O TREK recalcula a rota.',
|
||||
'roadtrip.import.unresolved': 'Local não encontrado; esta parada será ignorada.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Tarifa publicada; pot haver-hi impostos, condicions i càrrecs addicionals. El preu de la targeta pot variar.',
|
||||
'roadtrip.charging.coverage': 'Dades obertes; cobertura segons regió i operador.',
|
||||
'roadtrip.charging.availability': 'Disponibilitat i tarifes',
|
||||
'roadtrip.import.title': 'Importa una ruta de Google Maps',
|
||||
'roadtrip.import.note': 'Les parades s’afegeixen al dia triat. TREK recalcula la ruta.',
|
||||
'roadtrip.import.unresolved': 'Ubicació no trobada; aquesta parada s’ometrà.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Zveřejněný tarif; mohou platit daně, podmínky a další poplatky. Cena vaší karty se může lišit.',
|
||||
'roadtrip.charging.coverage': 'Otevřená data; pokrytí závisí na regionu a provozovateli.',
|
||||
'roadtrip.charging.availability': 'Dostupnost a tarify',
|
||||
'roadtrip.import.title': 'Importovat trasu z Google Maps',
|
||||
'roadtrip.import.note': 'Zastávky se přidají ke zvolenému dni. TREK trasu přepočítá.',
|
||||
'roadtrip.import.unresolved': 'Místo nenalezeno; tato zastávka bude přeskočena.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Veröffentlichter Tarif; Steuern, Bedingungen und Zusatzgebühren beachten. Dein Ladekartenpreis kann abweichen.',
|
||||
'roadtrip.charging.coverage': 'Offene Daten; Abdeckung abhängig von Region und Betreiber.',
|
||||
'roadtrip.charging.availability': 'Verfügbarkeit und Tarife',
|
||||
'roadtrip.import.title': 'Google-Maps-Route importieren',
|
||||
'roadtrip.import.note': 'Stopps werden an den gewählten Tag angehängt. TREK berechnet die Route neu.',
|
||||
'roadtrip.import.unresolved': 'Ort nicht gefunden; dieser Stopp wird übersprungen.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Published tariff; taxes, conditions and extra fees may apply. Your charging-card price can differ.',
|
||||
'roadtrip.charging.coverage': 'Open data; coverage varies by region and operator.',
|
||||
'roadtrip.charging.availability': 'Availability and prices',
|
||||
'roadtrip.import.title': 'Import Google Maps route',
|
||||
'roadtrip.import.note': 'Stops are appended to the selected day. TREK recalculates the route.',
|
||||
'roadtrip.import.unresolved': 'Location unresolved; this stop will be skipped.',
|
||||
|
||||
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Tarifa publicada; pueden aplicarse impuestos, condiciones y cargos adicionales. El precio de tu tarjeta puede variar.',
|
||||
'roadtrip.charging.coverage': 'Datos abiertos; cobertura según región y operador.',
|
||||
'roadtrip.charging.availability': 'Disponibilidad y tarifas',
|
||||
'roadtrip.import.title': 'Importar ruta de Google Maps',
|
||||
'roadtrip.import.note': 'Las paradas se añaden al día elegido. TREK recalcula la ruta.',
|
||||
'roadtrip.import.unresolved': 'Ubicación no encontrada; se omitirá esta parada.',
|
||||
|
||||
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Tarif publié ; taxes, conditions et frais supplémentaires possibles. Le prix de votre carte peut différer.',
|
||||
'roadtrip.charging.coverage': 'Données ouvertes ; couverture variable selon la région et l’opérateur.',
|
||||
'roadtrip.charging.availability': 'Disponibilité et tarifs',
|
||||
'roadtrip.import.title': 'Importer un itinéraire Google Maps',
|
||||
'roadtrip.import.note': 'Les étapes sont ajoutées au jour choisi. TREK recalcule le trajet.',
|
||||
'roadtrip.import.unresolved': 'Lieu introuvable ; cette étape sera ignorée.',
|
||||
|
||||
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Δημοσιευμένη χρέωση· ενδέχεται να ισχύουν φόροι, όροι και πρόσθετα τέλη. Η τιμή της κάρτας σας μπορεί να διαφέρει.',
|
||||
'roadtrip.charging.coverage': 'Ανοικτά δεδομένα· η κάλυψη εξαρτάται από την περιοχή και τον πάροχο.',
|
||||
'roadtrip.charging.availability': 'Διαθεσιμότητα και χρεώσεις',
|
||||
'roadtrip.import.title': 'Εισαγωγή διαδρομής Google Maps',
|
||||
'roadtrip.import.note': 'Οι στάσεις προστίθενται στην επιλεγμένη ημέρα. Το TREK υπολογίζει ξανά τη διαδρομή.',
|
||||
'roadtrip.import.unresolved': 'Η τοποθεσία δεν βρέθηκε· αυτή η στάση θα παραλειφθεί.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.prices': 'Közzétett díjak',
|
||||
'roadtrip.charging.note': 'Közzétett díj; adók, feltételek és felárak lehetnek. A töltőkártya ára eltérhet.',
|
||||
'roadtrip.charging.coverage': 'Nyílt adatok; a lefedettség régiónként és szolgáltatónként változik.',
|
||||
'roadtrip.charging.availability': 'Elérhetőség és díjak',
|
||||
'roadtrip.import.title': 'Google Maps-útvonal importálása',
|
||||
'roadtrip.import.note': 'A megállók a kiválasztott naphoz kerülnek. A TREK újratervezi az útvonalat.',
|
||||
'roadtrip.import.unresolved': 'A hely nem található; ez a megálló kimarad.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Tarif publik; pajak, syarat, dan biaya tambahan mungkin berlaku. Harga kartu pengisian Anda bisa berbeda.',
|
||||
'roadtrip.charging.coverage': 'Data terbuka; cakupan bergantung wilayah dan operator.',
|
||||
'roadtrip.charging.availability': 'Ketersediaan dan tarif',
|
||||
'roadtrip.import.title': 'Impor rute Google Maps',
|
||||
'roadtrip.import.note': 'Perhentian ditambahkan ke hari yang dipilih. TREK menghitung ulang rute.',
|
||||
'roadtrip.import.unresolved': 'Lokasi tidak ditemukan; perhentian ini akan dilewati.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Tariffa pubblicata; verificare tasse, condizioni e supplementi. Il prezzo della tua tessera può variare.',
|
||||
'roadtrip.charging.coverage': 'Dati aperti; copertura variabile per regione e gestore.',
|
||||
'roadtrip.charging.availability': 'Disponibilità e tariffe',
|
||||
'roadtrip.import.title': 'Importa percorso Google Maps',
|
||||
'roadtrip.import.note': 'Le tappe vengono aggiunte al giorno scelto. TREK ricalcola il percorso.',
|
||||
'roadtrip.import.unresolved': 'Posizione non trovata; questa tappa verrà saltata.',
|
||||
|
||||
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'公開料金です。税金、条件、追加料金をご確認ください。充電カードの料金は異なる場合があります。',
|
||||
'roadtrip.charging.coverage': 'オープンデータ。地域や事業者によって対応範囲が異なります。',
|
||||
'roadtrip.charging.availability': '空き状況と料金',
|
||||
'roadtrip.import.title': 'Google Mapsのルートをインポート',
|
||||
'roadtrip.import.note': '立ち寄り先を選択した日に追加し、TREKでルートを再計算します。',
|
||||
'roadtrip.import.unresolved': '場所が見つからないため、この立ち寄り先はスキップされます。',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'공개 요금입니다. 세금, 조건, 추가 요금이 적용될 수 있으며 충전 카드 요금은 다를 수 있습니다.',
|
||||
'roadtrip.charging.coverage': '공개 데이터로 지역과 운영사에 따라 범위가 다릅니다.',
|
||||
'roadtrip.charging.availability': '이용 가능 여부와 요금',
|
||||
'roadtrip.import.title': 'Google Maps 경로 가져오기',
|
||||
'roadtrip.import.note': '경유지를 선택한 날짜에 추가합니다. TREK이 경로를 다시 계산합니다.',
|
||||
'roadtrip.import.unresolved': '위치를 찾을 수 없어 이 경유지를 건너뜁니다.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Gepubliceerd tarief; belastingen, voorwaarden en toeslagen kunnen gelden. Je laadpasprijs kan afwijken.',
|
||||
'roadtrip.charging.coverage': 'Open data; dekking verschilt per regio en aanbieder.',
|
||||
'roadtrip.charging.availability': 'Beschikbaarheid en tarieven',
|
||||
'roadtrip.import.title': 'Google Maps-route importeren',
|
||||
'roadtrip.import.note': 'Stops worden aan de gekozen dag toegevoegd. TREK berekent de route opnieuw.',
|
||||
'roadtrip.import.unresolved': 'Locatie niet gevonden; deze stop wordt overgeslagen.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Opublikowana taryfa; możliwe podatki, warunki i dodatkowe opłaty. Cena Twojej karty może się różnić.',
|
||||
'roadtrip.charging.coverage': 'Otwarte dane; zasięg zależy od regionu i operatora.',
|
||||
'roadtrip.charging.availability': 'Dostępność i taryfy',
|
||||
'roadtrip.import.title': 'Importuj trasę z Google Maps',
|
||||
'roadtrip.import.note': 'Przystanki zostaną dodane do wybranego dnia. TREK przeliczy trasę.',
|
||||
'roadtrip.import.unresolved': 'Nie znaleziono miejsca; ten przystanek zostanie pominięty.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Опубликованный тариф; возможны налоги, условия и доплаты. Цена по вашей карте может отличаться.',
|
||||
'roadtrip.charging.coverage': 'Открытые данные; охват зависит от региона и оператора.',
|
||||
'roadtrip.charging.availability': 'Доступность и тарифы',
|
||||
'roadtrip.import.title': 'Импорт маршрута Google Maps',
|
||||
'roadtrip.import.note': 'Остановки добавляются в выбранный день. TREK пересчитает маршрут.',
|
||||
'roadtrip.import.unresolved': 'Место не найдено; эта остановка будет пропущена.',
|
||||
|
||||
@@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.prices': 'Publicerade priser',
|
||||
'roadtrip.charging.note': 'Publicerat pris; skatter, villkor och tillägg kan gälla. Ditt laddkorts pris kan avvika.',
|
||||
'roadtrip.charging.coverage': 'Öppna data; täckning varierar efter region och operatör.',
|
||||
'roadtrip.charging.availability': 'Tillgänglighet och priser',
|
||||
'roadtrip.import.title': 'Importera Google Maps-rutt',
|
||||
'roadtrip.import.note': 'Stoppen läggs till på vald dag. TREK beräknar rutten på nytt.',
|
||||
'roadtrip.import.unresolved': 'Platsen hittades inte; stoppet hoppas över.',
|
||||
|
||||
@@ -11,6 +11,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Yayımlanan tarife; vergi, koşullar ve ek ücretler uygulanabilir. Şarj kartı fiyatınız farklı olabilir.',
|
||||
'roadtrip.charging.coverage': 'Açık veri; kapsam bölge ve operatöre göre değişir.',
|
||||
'roadtrip.charging.availability': 'Müsaitlik ve tarifeler',
|
||||
'roadtrip.import.title': 'Google Maps rotasını içe aktar',
|
||||
'roadtrip.import.note': 'Duraklar seçilen güne eklenir. TREK rotayı yeniden hesaplar.',
|
||||
'roadtrip.import.unresolved': 'Konum bulunamadı; bu durak atlanacak.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Опублікований тариф; можливі податки, умови та доплати. Ціна за вашою карткою може відрізнятися.',
|
||||
'roadtrip.charging.coverage': 'Відкриті дані; охоплення залежить від регіону й оператора.',
|
||||
'roadtrip.charging.availability': 'Доступність і тарифи',
|
||||
'roadtrip.import.title': 'Імпорт маршруту Google Maps',
|
||||
'roadtrip.import.note': 'Зупинки додаються до вибраного дня. TREK перерахує маршрут.',
|
||||
'roadtrip.import.unresolved': 'Місце не знайдено; цю зупинку буде пропущено.',
|
||||
|
||||
@@ -10,6 +10,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.note':
|
||||
'Biểu giá công bố; có thể áp dụng thuế, điều kiện và phụ phí. Giá thẻ sạc của bạn có thể khác.',
|
||||
'roadtrip.charging.coverage': 'Dữ liệu mở; phạm vi phụ thuộc khu vực và nhà vận hành.',
|
||||
'roadtrip.charging.availability': 'Tình trạng còn trống và biểu giá',
|
||||
'roadtrip.import.title': 'Nhập tuyến đường Google Maps',
|
||||
'roadtrip.import.note': 'Các điểm dừng được thêm vào ngày đã chọn. TREK tính lại tuyến đường.',
|
||||
'roadtrip.import.unresolved': 'Không tìm thấy vị trí; điểm dừng này sẽ bị bỏ qua.',
|
||||
|
||||
@@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.prices': '公布的費率',
|
||||
'roadtrip.charging.note': '公布的費率可能附帶稅費、條件和額外費用。您的充電卡價格可能不同。',
|
||||
'roadtrip.charging.coverage': '開放資料,涵蓋範圍因地區和營運商而異。',
|
||||
'roadtrip.charging.availability': '可用狀態與費率',
|
||||
'roadtrip.import.title': '匯入 Google Maps 路線',
|
||||
'roadtrip.import.note': '停靠點將新增至所選日期。TREK 將重新計算路線。',
|
||||
'roadtrip.import.unresolved': '找不到位置;將略過此停靠點。',
|
||||
|
||||
@@ -9,6 +9,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.prices': '公布的费率',
|
||||
'roadtrip.charging.note': '公布的费率可能附带税费、条件和额外费用。您的充电卡价格可能不同。',
|
||||
'roadtrip.charging.coverage': '开放数据,覆盖范围因地区和运营商而异。',
|
||||
'roadtrip.charging.availability': '可用状态与费率',
|
||||
'roadtrip.import.title': '导入 Google Maps 路线',
|
||||
'roadtrip.import.note': '停靠点将添加到所选日期。TREK 将重新计算路线。',
|
||||
'roadtrip.import.unresolved': '未找到位置;将跳过此停靠点。',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { chargingLookupSchema } from './charging.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Charging station lookup by coordinate', () => {
|
||||
const request = { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' };
|
||||
it('accepts a bounded coordinate and a nameless station', () => {
|
||||
expect(chargingLookupSchema.safeParse(request).success).toBe(true);
|
||||
// OSM charging nodes are regularly untagged; the matcher has its own answer for
|
||||
// that case, so an empty name must reach it rather than being refused here.
|
||||
expect(chargingLookupSchema.safeParse({ ...request, name: '' }).success).toBe(true);
|
||||
});
|
||||
it('refuses coordinates off the globe and an unbounded name', () => {
|
||||
expect(chargingLookupSchema.safeParse({ ...request, lat: 91 }).success).toBe(false);
|
||||
expect(chargingLookupSchema.safeParse({ ...request, lng: -181 }).success).toBe(false);
|
||||
expect(chargingLookupSchema.safeParse({ ...request, lat: Number.NaN }).success).toBe(false);
|
||||
expect(chargingLookupSchema.safeParse({ ...request, name: 'x'.repeat(201) }).success).toBe(false);
|
||||
});
|
||||
it('has no batch form: the array a caller might send is not a station', () => {
|
||||
// The cache key upstream is one station, and a cache miss costs several requests
|
||||
// against a shared public registry. Anything list-shaped is refused outright.
|
||||
expect(chargingLookupSchema.safeParse([request]).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -30,3 +30,28 @@ export const chargingInfoSchema = z.object({
|
||||
pricesUnavailable: z.boolean(),
|
||||
});
|
||||
export type ChargingInfo = z.infer<typeof chargingInfoSchema>;
|
||||
|
||||
/**
|
||||
* A charging station addressed by where it is, rather than by a row in `places`.
|
||||
*
|
||||
* The saved-stop route answers for a place id, which a hit found along the route does
|
||||
* not have yet, and "is anything free here, and what does it cost" is the question
|
||||
* people want answered before adding the charger, not after. The match needs nothing
|
||||
* more than this: a coordinate, and whatever the search called the place.
|
||||
*
|
||||
* One station per request, deliberately. A cache miss costs the upstream registry
|
||||
* several requests, nothing rate-limits the route, and the caches on both sides of it
|
||||
* are sized for the handful of stops a trip has. A batch would turn one click into a
|
||||
* burst against shared public infrastructure.
|
||||
*/
|
||||
export const chargingLookupSchema = z.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
/**
|
||||
* An empty name is allowed. OSM charging nodes are frequently nameless, and the
|
||||
* matcher already treats "nothing to go on" as its own case: it then insists the
|
||||
* station be within 30 m instead of accepting the nearest one inside 100 m.
|
||||
*/
|
||||
name: z.string().max(200),
|
||||
});
|
||||
export type ChargingLookup = z.infer<typeof chargingLookupSchema>;
|
||||
|
||||
Reference in New Issue
Block a user