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:
Maurice
2026-09-14 12:28:47 +02:00
parent accdd5905e
commit 00d00be1c0
37 changed files with 507 additions and 21 deletions
+1
View File
@@ -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': 'تعذر تحديد الموقع؛ سيتم تخطي هذه المحطة.',
+1
View File
@@ -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.',
+1
View File
@@ -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à.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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': 'Η τοποθεσία δεν βρέθηκε· αυτή η στάση θα παραλειφθεί.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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': '場所が見つからないため、この立ち寄り先はスキップされます。',
+1
View File
@@ -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': '위치를 찾을 수 없어 이 경유지를 건너뜁니다.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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': 'Место не найдено; эта остановка будет пропущена.',
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+1
View File
@@ -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': 'Місце не знайдено; цю зупинку буде пропущено.',
+1
View File
@@ -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.',
+1
View File
@@ -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': '找不到位置;將略過此停靠點。',
+1
View File
@@ -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);
});
});
+25
View File
@@ -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>;