diff --git a/client/src/components/Map/HazardLayers.tsx b/client/src/components/Map/HazardLayers.tsx index 9ccaa050b..3fe34d4d7 100644 --- a/client/src/components/Map/HazardLayers.tsx +++ b/client/src/components/Map/HazardLayers.tsx @@ -10,6 +10,6 @@ export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) return <>{hazards.map(hazard => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })} - onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip', maxWidth: 320 })} + onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })} />)} } diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 49b59e18e..80e0274cc 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -11,6 +11,7 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons' import * as photoService from '../../services/photoService' const mapMock = vi.hoisted(() => ({ + getContainer: vi.fn(() => document.createElement('div')), panTo: vi.fn(), setView: vi.fn(), fitBounds: vi.fn(), @@ -64,6 +65,7 @@ vi.mock('react-leaflet', () => ({ TileLayer: () =>
, Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
{ if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } }) }} data-testid="marker" data-lat={position[0]} data-lng={position[1]} @@ -632,6 +634,23 @@ describe('MapView explore POIs', () => { expect(onPoiClick).toHaveBeenCalledWith(poi) }) + it('keeps native POI clicks working after a pan and uses the latest callback', () => { + const first = vi.fn() + const latest = vi.fn() + const poi = buildPoi({ osm_id: 'node/7' }) + const { rerender } = render( {}} />) + const marker = markersWithZ('500')[0] + expect(marker).toHaveAttribute('draggable', 'true') + fireEvent.mouseDown(marker) + fireEvent.mouseUp(marker) + fireEvent.click(marker) + expect(first).toHaveBeenCalledTimes(1) + rerender( {}} />) + fireEvent.click(markersWithZ('500')[0]) + expect(latest).toHaveBeenCalledExactlyOnceWith(poi) + expect(first).toHaveBeenCalledTimes(1) + }) + it('FE-COMP-MAPVIEW-035: an unknown POI category falls back to grey and draws no glyph', () => { render() const html = iconHtmlOf(markersWithZ('500')[0]) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 3f68a6bae..35fabe83d 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -685,9 +685,12 @@ export const MapView = memo(function MapView({ // that is decides which layer draws it. A saved raster template still wins, // the default is a vector style. const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl]) + const poiClickRef = useRef(onPoiClick) + poiClickRef.current = onPoiClick const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => ( HTMLElement | undefined } }) => { const el = e.target.getElement() - if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id) + if (!el) return + // Native clicks avoid Leaflet suppressing a click after an earlier map drag. + el.setAttribute('aria-label', poi.name) + el.onclick = event => { event.stopPropagation(); poiClickRef.current?.(poi) } + if (onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id) }, }} > diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index f6084e340..f5548d965 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -21,7 +21,7 @@ export function MapViewAuto(props: any) { const { t } = useTranslation() const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely) const mapProps = { ...props, hazards: hazards.feed?.hazards } - const status = hazards.enabled ?
+ const status = hazards.enabled ?
{hazards.status !== 'ready' ? t(`roadtrip.hazards.${hazards.status}`) : <> diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 4266841f4..e4dcf25bf 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -710,7 +710,7 @@ export function MapViewGL({ const containerRef = useRef(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef(null) - const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip', maxWidth: '320px' }), [gl]) + const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl]) useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory) // eslint-disable-next-line @typescript-eslint/no-explicit-any const markersRef = useRef>(new Map()) diff --git a/client/src/components/Map/hazardPopup.css b/client/src/components/Map/hazardPopup.css new file mode 100644 index 000000000..53b33d10e --- /dev/null +++ b/client/src/components/Map/hazardPopup.css @@ -0,0 +1,133 @@ +.trek-hazard-content { + font-family: var(--font-system); +} + +.trek-hazard-score { + padding: 7px 10px 5px; + border: 1px solid var(--border-faint); + border-radius: 10px; + background: var(--bg-hover); +} + +.trek-hazard-score-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + color: var(--text-secondary); + font-weight: 500; +} + +.trek-hazard-score-heading strong { + font-size: calc(16px * var(--fs-scale-caption, 1)); + font-weight: 650; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.trek-hazard-score-track { + position: relative; + display: flex; + gap: 3px; + height: 5px; +} + +.trek-hazard-score-track > span:not(.trek-hazard-score-marker) { + flex: 1; + border-radius: 3px; + opacity: 0.75; +} + +.trek-hazard-score-marker { + position: absolute; + top: 50%; + width: 10px; + height: 10px; + border: 2px solid var(--bg-card); + border-radius: 50%; + background: var(--text-primary); + box-sizing: border-box; + transform: translate(-50%, -50%); + box-shadow: var(--shadow-sm); +} + +.trek-hazard-score-ticks { + display: flex; + justify-content: space-between; + margin-top: 4px; + color: var(--text-muted); + font-size: calc(10px * var(--fs-scale-caption, 1)); + font-variant-numeric: tabular-nums; +} + +.trek-hazard-popup .leaflet-popup-content { + margin: 14px 16px; +} + +.trek-hazard-popup .mapboxgl-popup-content, +.trek-hazard-popup .maplibregl-popup-content { + padding: 14px 16px; + border: 1px solid var(--border-faint); + border-radius: 14px; + background: var(--bg-card); + color: var(--text-primary); + box-shadow: var(--shadow-popover); +} + +.trek-hazard-popup a.trek-hazard-source { + color: var(--text-secondary); + text-decoration: none; +} + +.trek-hazard-popup a.trek-hazard-source:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.leaflet-container .trek-hazard-popup a.leaflet-popup-close-button, +.trek-hazard-popup .mapboxgl-popup-close-button, +.trek-hazard-popup .maplibregl-popup-close-button { + top: 10px; + right: 10px; + width: 26px; + height: 26px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-muted); + font-size: 0; + transition: background-color 150ms ease, color 150ms ease !important; +} + +.leaflet-container .trek-hazard-popup a.leaflet-popup-close-button span { + display: none; +} + +.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::before, +.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::after { + content: ''; + position: absolute; + top: 12px; + left: 6px; + width: 14px; + height: 1.5px; + border-radius: 1px; + background: currentColor; + transform: rotate(45deg); +} + +.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button)::after { + transform: rotate(-45deg); +} + +.trek-hazard-popup :is(.leaflet-popup-close-button, .mapboxgl-popup-close-button, .maplibregl-popup-close-button):hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: none; +} + +.trek-hazard-popup :is(a, button):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} diff --git a/client/src/components/Map/hazardPopup.ts b/client/src/components/Map/hazardPopup.ts index cdb3adc86..1c3d77e8d 100644 --- a/client/src/components/Map/hazardPopup.ts +++ b/client/src/components/Map/hazardPopup.ts @@ -1,3 +1,7 @@ +import { createElement } from 'react' +import { AlertTriangle, ArrowUpRight } from 'lucide-react' +import { renderIconMarkup } from '../../utils/iconMarkup' +import './hazardPopup.css' import type { RoadtripHazard } from '@trek/shared' import type { Feature, Geometry } from 'geojson' @@ -7,21 +11,79 @@ export function hazardFeature(hazard: RoadtripHazard): Feature { export function hazardPopup(hazard: RoadtripHazard, note: string, pointNote: string): HTMLDivElement { const box = document.createElement('div') - box.className = 'flex max-w-xs flex-col gap-2 text-caption text-content' - const title = document.createElement('strong') - title.textContent = hazard.title - const details = document.createElement('p') - details.textContent = hazard.description - const timestamp = document.createElement('p') - timestamp.textContent = `${hazard.source}: ${new Date(hazard.updatedAt).toLocaleString()}` - const caution = document.createElement('p') - caution.textContent = hazard.geometry.type === 'Point' ? `${pointNote} ${note}` : note + box.className = 'trek-hazard-content flex max-w-xs flex-col gap-3 text-content' + box.style.fontSize = 'calc(12px * var(--fs-scale-caption, 1))' + box.style.lineHeight = '1.45' + const title = document.createElement('div') + title.className = 'flex items-center gap-2.5 pr-7 font-semibold' + const symbol = document.createElement('span') + symbol.className = 'flex size-7 shrink-0 items-center justify-center rounded-lg bg-warning-soft text-warning' + symbol.innerHTML = renderIconMarkup(createElement(AlertTriangle, { size: 15, strokeWidth: 1.8 })) + const heading = document.createElement('span') + heading.textContent = hazard.title + title.append(symbol, heading) + box.append(title) + if (hazard.description.trim() && hazard.description.trim().toLocaleLowerCase() !== hazard.title.trim().toLocaleLowerCase()) { + const details = document.createElement('div') + details.className = 'text-content-secondary' + details.textContent = hazard.description + box.append(details) + } + const metadata = document.createElement('div') + metadata.className = 'flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-content-muted' const source = document.createElement('a') source.textContent = hazard.source source.href = hazard.url source.target = '_blank' source.rel = 'noopener noreferrer' - source.className = 'text-accent-on underline' - box.append(title, details, timestamp, caution, source) + source.className = 'trek-hazard-source inline-flex items-center gap-1 rounded-md bg-surface-hover px-1.5 py-0.5 font-medium' + const external = document.createElement('span') + external.innerHTML = renderIconMarkup(createElement(ArrowUpRight, { size: 12 })) + source.append(external) + const timestamp = document.createElement('time') + timestamp.dateTime = hazard.updatedAt + timestamp.textContent = new Date(hazard.updatedAt).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) + metadata.append(source, timestamp) + const caution = document.createElement('div') + caution.className = 'border-t border-edge-faint pt-2.5 text-content-muted' + caution.textContent = hazard.geometry.type === 'Point' ? pointNote + ' ' + note : note + box.append(metadata) + if (hazard.source === 'GDACS' && hazard.alertScore != null) box.append(scoreBar(hazard.alertScore)) + box.append(caution) return box } + +function scoreBar(score: number): HTMLDivElement { + const panel = document.createElement('div') + panel.className = 'trek-hazard-score' + const heading = document.createElement('div') + heading.className = 'trek-hazard-score-heading' + const label = document.createElement('span') + label.textContent = 'GDACS Score' + const value = document.createElement('strong') + value.textContent = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(score) + value.style.color = score < 1 ? 'var(--success)' : score < 2 ? 'var(--warning)' : 'var(--danger)' + heading.append(label, value) + const track = document.createElement('div') + track.className = 'trek-hazard-score-track' + track.setAttribute('role', 'img') + track.setAttribute('aria-label', 'GDACS Score ' + value.textContent) + for (const color of ['success', 'warning', 'danger']) { + const segment = document.createElement('span') + segment.style.background = 'var(--' + color + ')' + track.append(segment) + } + const marker = document.createElement('span') + marker.className = 'trek-hazard-score-marker' + marker.style.left = Math.min(score / 3, 1) * 100 + '%' + track.append(marker) + const ticks = document.createElement('div') + ticks.className = 'trek-hazard-score-ticks' + for (const tick of ['0', '1', '2', '3+']) { + const number = document.createElement('span') + number.textContent = tick + ticks.append(number) + } + panel.append(heading, track, ticks) + return panel +} diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 6ee88edea..cebbadeee 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -1,3 +1,4 @@ +import ChargingInfo from '../Roadtrip/ChargingInfo' import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { avatarSrc } from '../../utils/avatarSrc' import { safeHttpUrl } from '../../utils/safeUrl' @@ -135,6 +136,7 @@ interface TripMember { } interface PlaceInspectorProps { + roadtripActive?: boolean roadtripEndDay?: EndDayControlProps roadtripStay?: RoadtripStayControl place: Place | null @@ -183,7 +185,7 @@ export default function PlaceInspector({ onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate, leftWidth = 0, rightWidth = 0, - collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, + collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive, }: PlaceInspectorProps) { // Plugins that declared a place-detail slot mount at the bottom of this panel, // scoped to the open place (trip mode only). Inline-filter like the other sites. @@ -421,6 +423,7 @@ export default function PlaceInspector({ )} {/* Description / Summary */} + {roadtripActive && place.stop_type === 'charging' && } {(roadtripEndDay || roadtripStay) && } {(place.description || googleDetails?.summary) && (
diff --git a/client/src/components/Roadtrip/ChargingInfo.test.tsx b/client/src/components/Roadtrip/ChargingInfo.test.tsx new file mode 100644 index 000000000..e46a27dfa --- /dev/null +++ b/client/src/components/Roadtrip/ChargingInfo.test.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } 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() } })) +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 } +describe('Charging information', () => { + it('shows availability, attribution and missing prices without inventing a price', async () => { + vi.mocked(chargingRepo.read).mockResolvedValue(info) + render() + expect(await screen.findByText('2/4 available')).toBeInTheDocument() + expect(screen.getByText('No reliable data available')).toBeInTheDocument() + fireEvent.click(screen.getByText('Charging')) + expect(screen.getByRole('link', { name: 'Operator' })).toHaveAttribute('href', 'https://example.com') + }) + it('does not display free capacity for stale data', async () => { + vi.mocked(chargingRepo.read).mockResolvedValue({ ...info, stale: true }) + render() + expect(await screen.findByText('Status outdated')).toBeInTheDocument() + expect(screen.queryByText('2/4')).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Roadtrip/ChargingInfo.tsx b/client/src/components/Roadtrip/ChargingInfo.tsx new file mode 100644 index 000000000..1b35767ad --- /dev/null +++ b/client/src/components/Roadtrip/ChargingInfo.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react' +import { PlugZap, Coins, ChevronDown } from 'lucide-react' +import type { ChargingInfo as Info } from '@trek/shared' +import { useTranslation } from '../../i18n/TranslationContext' +import { useTripStore } from '../../store/tripStore' +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 }) { + const tripId = useTripStore(s => s.trip?.id) + const { t } = useTranslation() + const [info, setInfo] = useState(null) + const [loading, setLoading] = useState(true) + useEffect(() => { + let active = true + setInfo(null); setLoading(true) + const refresh = async () => { + if (!tripId || document.hidden) return + try { const value = await chargingRepo.read(tripId, placeId); if (active) setInfo(value) } + catch { if (active) setInfo(null) } + finally { if (active) setLoading(false) } + } + const offline = () => { setInfo(null); setLoading(false) } + void refresh() + const timer = window.setInterval(() => void refresh(), 60000) + window.addEventListener('offline', offline) + 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]) + 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 }))) ?? [] + const energy = components.filter(component => component.kind === 'ENERGY' && component.taxIncluded && !component.conditional) + const sameCurrency = new Set(energy.map(component => component.currency)).size === 1 + const cheapest = sameCurrency ? energy.reduce((best, component) => !best || component.price < best.price ? component : best, null) : null + const price = (value: number, currency: string) => new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value) + const badges = + 0 ? 'bg-success-soft text-success' : 'bg-surface-secondary text-content-muted'}`}>{known ? `${info.available}/${info.total}` : '?'} + {cheapest && {price(cheapest.price, cheapest.currency)}/kWh*} + + if (compact) return badges + return
+ + + {t('roadtrip.poi.charging')} + {status} + {cheapest && {price(cheapest.price, cheapest.currency)}/kWh*} + + +
+ {info?.station &&

{info.station}

} +

{t('roadtrip.charging.prices')}

+ {!components.length &&

{info?.pricesUnavailable ? `${t('common.error')}: ` : ''}{t('roadtrip.charging.unknown')}

} + {components.map((component, index) =>

{price(component.price, component.currency)}{component.kind === 'ENERGY' ? '/kWh' : ['TIME', 'PARKING_TIME'].includes(component.kind) ? '/h' : ''} {component.kind === 'FLAT' ? 'Σ' : ''}{component.conditional || !component.taxIncluded ? '*' : ''}{component.afterSeconds != null && component.afterSeconds > 0 ? ` (> ${Math.round(component.afterSeconds / 60)} min)` : ''}

)} + {!!components.length &&

* {t('roadtrip.charging.note')}

} +
+

{t('roadtrip.charging.coverage')}

+ {info?.source &&

{info.source} · MobiData BW
{info.license}{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}

} +
+
+} diff --git a/client/src/components/Roadtrip/EndDayControl.tsx b/client/src/components/Roadtrip/EndDayControl.tsx index c1c0cf048..86d11a7ba 100644 --- a/client/src/components/Roadtrip/EndDayControl.tsx +++ b/client/src/components/Roadtrip/EndDayControl.tsx @@ -12,8 +12,8 @@ export default function EndDayControl({ active, onToggle }: EndDayControlProps) const { t } = useTranslation() const [pending, setPending] = useState(false) return ( -
- {t('roadtrip.window.endHere')} +
+ {t('roadtrip.window.endHere')} { if (pending) return setPending(true) diff --git a/client/src/components/Roadtrip/GoogleRouteImport.test.tsx b/client/src/components/Roadtrip/GoogleRouteImport.test.tsx index 3fbbcc4d0..2c1a5207d 100644 --- a/client/src/components/Roadtrip/GoogleRouteImport.test.tsx +++ b/client/src/components/Roadtrip/GoogleRouteImport.test.tsx @@ -14,7 +14,7 @@ async function openPreview() { fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' })) fireEvent.click(screen.getByText('Import Google Maps route', { selector: 'span' })) fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), { target: { value: 'https://google.com/maps/dir/A/B' } }) - fireEvent.click(screen.getByRole('button', { name: 'Preview', exact: true })) + fireEvent.click(screen.getByRole('button', { name: 'Preview' })) await screen.findByText('Munich') } @@ -28,7 +28,7 @@ describe('Google route preview', () => { expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual([expect.stringContaining('Munich'), expect.stringContaining('Unknown'), expect.stringContaining('Rome')]) expect(screen.getByText('Location unresolved; this stop will be skipped.')).toBeInTheDocument() expect(googleRouteRepo.append).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'Import', exact: true })) + fireEvent.click(screen.getByRole('button', { name: 'Import' })) await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1)) expect(googleRouteRepo.append).toHaveBeenCalledWith(1, { dayId: 3, stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Rome', lat: 41, lng: 12 }] }, expect.any(String)) }) @@ -36,7 +36,7 @@ describe('Google route preview', () => { it('blocks import when fewer than two locations resolve', async () => { vi.mocked(googleRouteRepo.preview).mockResolvedValue({ stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Unknown', lat: null, lng: null }] }) await openPreview() - expect(screen.getByRole('button', { name: 'Import', exact: true })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled() expect(googleRouteRepo.append).not.toHaveBeenCalled() }) }) diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 723c46b96..b8bdf0ef6 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1,3 +1,4 @@ +import ChargingInfo from './ChargingInfo' import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import React, { useState } from 'react' import { @@ -833,10 +834,10 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE {/* Truncated, not wrapped: a service stop is a waypoint, and its full name lives on the map pin — where a place's name is the row's whole reason to exist. */} - {stop.name} + {stop.name}{stop.stopType === 'charging' && } @@ -1090,10 +1091,10 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s {/* Wraps rather than truncates: the name is what the row is for, and thirty of them cut off mid-word is a list nobody reads. */} - {stop.name} + {stop.name}{stop.stopType === 'charging' && } {/* Two halves under one border: the word says what the number means, so the number needs no unit of explanation beside it. */} diff --git a/client/src/components/Roadtrip/VisitControls.tsx b/client/src/components/Roadtrip/VisitControls.tsx index edea40dc8..de7177383 100644 --- a/client/src/components/Roadtrip/VisitControls.tsx +++ b/client/src/components/Roadtrip/VisitControls.tsx @@ -14,9 +14,9 @@ export default function VisitControls({ endDay, stay }: { endDay?: EndDayControl
{endDay && } {stay && } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 859772eed..ee15503c5 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -773,7 +773,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { {selectedPlace && !isMobile && ( e.stopPropagation()}> }>() +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 + }, +} diff --git a/server/src/nest/roadtrip/charging.controller.ts b/server/src/nest/roadtrip/charging.controller.ts new file mode 100644 index 000000000..fce48f220 --- /dev/null +++ b/server/src/nest/roadtrip/charging.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, Param, ParseIntPipe, UseGuards } from '@nestjs/common'; +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'; + +@Controller('api/trips/:tripId/roadtrip/charging') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class ChargingController { + constructor(private readonly charging: ChargingService) {} + @Get(':placeId') + read(@Param('tripId', ParseIntPipe) tripId: number, @Param('placeId', ParseIntPipe) placeId: number) { return this.charging.read(tripId, placeId); } +} diff --git a/server/src/nest/roadtrip/charging.helpers.ts b/server/src/nest/roadtrip/charging.helpers.ts new file mode 100644 index 000000000..2b401e6ff --- /dev/null +++ b/server/src/nest/roadtrip/charging.helpers.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; +import type { ChargingInfo } from '@trek/shared'; + +const evse = z.object({ + uid: z.string(), evse_id: z.string().optional(), status: z.string(), last_updated: z.string(), + status_last_updated: z.string().optional(), + connectors: z.array(z.object({ tariff_ids: z.array(z.string()).optional() })).default([]), +}); +export const chargingLocation = z.object({ + id: z.string(), source: z.string(), name: z.string().nullish(), address: z.string(), + city: z.string(), last_updated: z.string(), + coordinates: z.object({ latitude: z.number(), longitude: z.number() }), + operator: z.object({ name: z.string() }).optional(), + charging_pool: z.array(z.object({ evses: z.array(evse).default([]) })).default([]), +}); +export const chargingSource = z.object({ + uid: z.string(), name: z.string(), public_url: z.string().nullable(), + attribution_license: z.string().nullable(), attribution_contributor: z.string().nullable(), + realtime_data_updated_at: z.string().nullable(), realtime_status: z.string(), +}); +export const chargingTariff = z.object({ + id: z.string(), original_id: z.string().optional(), source: z.string(), currency: z.string().regex(/^[A-Z]{3}$/), last_updated: z.string(), + start_date_time: z.string().nullish(), end_date_time: z.string().nullish(), + elements: z.array(z.object({ + restrictions: z.record(z.string(), z.unknown()).nullish(), + price_components: z.array(z.object({ + type: z.string(), price: z.number().nonnegative(), + taxes: z.array(z.object({ percentage: z.coerce.number().nonnegative() })).optional(), + })), + })), +}); +export type ChargingLocation = z.infer; +export type ChargingSource = z.infer; +export type ChargingTariff = z.infer; + +export function distanceMeters(lat: number, lng: number, location: ChargingLocation) { + const rad = Math.PI / 180; + const dLat = (location.coordinates.latitude - lat) * rad; + const dLng = (location.coordinates.longitude - lng) * rad; + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat * rad) * Math.cos(location.coordinates.latitude * rad) * Math.sin(dLng / 2) ** 2; + return 12742000 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0, 1 - a))); +} +const words = (name: string) => name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(word => word.length > 2 && !['charging', 'station', 'ladestation', 'charger'].includes(word)); + +export function matchChargingLocation(locations: ChargingLocation[], lat: number, lng: number, name: string) { + const candidates = locations.map(location => ({ location, distance: distanceMeters(lat, lng, location), + named: words(name).some(word => words(`${location.name ?? ''} ${location.operator?.name ?? ''}`).includes(word)), + })).filter(hit => hit.distance <= 100).sort((a, b) => Number(b.named) - Number(a.named) || a.distance - b.distance); + if (!candidates.length) return null; + const best = candidates[0]; + if (!best.named && best.distance > 30) return 'ambiguous'; + const competing = candidates.find(hit => hit !== best && hit.named === best.named && + hit.location.operator?.name !== best.location.operator?.name && hit.distance < best.distance + 25); + return competing ? 'ambiguous' : best.location; +} + +export function normalizeCharging(location: ChargingLocation, source: ChargingSource, tariffs: ChargingTariff[], now = Date.now()): ChargingInfo { + const evses = [...new Map(location.charging_pool.flatMap(pool => pool.evses).map(point => [point.evse_id || point.uid, point])).values()]; + const timestamps = [source.realtime_data_updated_at, ...evses.map(point => point.status_last_updated)].filter((value): value is string => !!value && Number.isFinite(Date.parse(value))); + const updatedAt = timestamps.sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null; + const stale = !updatedAt || now - Date.parse(updatedAt) > 20 * 60 * 1000 || Date.parse(updatedAt) > now + 60000 || source.realtime_status !== 'ACTIVE'; + const known = evses.filter(point => ['AVAILABLE', 'CHARGING', 'RESERVED', 'BLOCKED', 'INOPERATIVE', 'OUTOFORDER'].includes(point.status)); + const ids = new Set(evses.flatMap(point => point.connectors.flatMap(connector => connector.tariff_ids ?? []))); + const matching = tariffs.filter(tariff => tariff.source === location.source && (ids.has(tariff.id) || ids.has(tariff.original_id ?? '')) && + (!tariff.start_date_time || Date.parse(tariff.start_date_time) <= now) && (!tariff.end_date_time || Date.parse(tariff.end_date_time) > now)); + return { + checkedAt: new Date(now).toISOString(), status: 'ok', station: location.name || `${location.operator?.name ?? ''} ${location.address}, ${location.city}`.trim(), + source: source.attribution_contributor || source.name, sourceUrl: source.public_url, license: source.attribution_license || 'Datenlizenz Deutschland Namensnennung 2.0 (MobiData BW)', + updatedAt, stale, available: stale || !known.length ? null : known.filter(point => point.status === 'AVAILABLE').length, + total: evses.length, unknown: evses.length - known.length, pricesUnavailable: false, + tariffs: matching.map(tariff => ({ currency: tariff.currency, updatedAt: tariff.last_updated, + components: tariff.elements.flatMap(element => element.price_components.map(component => ({ + kind: component.type, price: component.price * (1 + (component.taxes?.reduce((sum, tax) => sum + tax.percentage, 0) ?? 0) / 100), + taxIncluded: component.taxes !== undefined, + conditional: !!element.restrictions && Object.keys(element.restrictions).length > 0, + afterSeconds: typeof element.restrictions?.min_duration === 'number' ? element.restrictions.min_duration : null, + }))), + })), + }; +} diff --git a/server/src/nest/roadtrip/charging.mcp.ts b/server/src/nest/roadtrip/charging.mcp.ts new file mode 100644 index 000000000..5317d35d0 --- /dev/null +++ b/server/src/nest/roadtrip/charging.mcp.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { ADDON_IDS } from '../../addons'; +import { noAccess } from '../../mcp/tools/_shared'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { DatabaseService } from '../database/database.service'; +import { ChargingService } from './charging.service'; + +@McpController() +export class ChargingMcp { + constructor(private readonly charging: ChargingService, private readonly db: DatabaseService, readonly addons: AddonsService) {} + @Tool({ name: 'get_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a saved charging stop. No API key required. Coverage varies by region and operator. Unknown or stale status is not available capacity. Prices can have conditions and extra fees and are not personalized roaming quotes.', + inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) }) + async read({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { + if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + return ok(await this.charging.read(tripId, placeId)); + } +} diff --git a/server/src/nest/roadtrip/charging.service.ts b/server/src/nest/roadtrip/charging.service.ts new file mode 100644 index 000000000..07a30f0d6 --- /dev/null +++ b/server/src/nest/roadtrip/charging.service.ts @@ -0,0 +1,64 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import type { ChargingInfo } from '@trek/shared'; +import { z } from 'zod'; +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 }); +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'); } + return await readCappedJson(fetched, 8_000_000); +} + +@Injectable() +export class ChargingService { + private cached = new Map }>(); + private sources?: { expires: number; value: Promise }; + private tariffs = new Map }>(); + constructor(private readonly db: DatabaseService) {} + async read(tripId: number, placeId: number) { + 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}`; + 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')); + this.cached.set(key, { expires: Date.now() + 60000, value }); + return value; + } + private async load(lat: number, lng: number, name: string): Promise { + if (!this.sources || this.sources.expires < Date.now()) this.sources = { expires: Date.now() + 60000, value: request('/api/public/v1/sources').then(raw => z.object({ items: z.array(chargingSource) }).parse(raw).items) }; + const [sources, nearby] = await Promise.all([this.sources.value, request(`/api/ocpi/3.0/locations?lat=${lat}&lon=${lng}&radius=100&limit=100&exclude_source_uid=bnetza_api`)]); + const page = z.object({ items: z.array(z.unknown()), total_count: z.number() }).parse(nearby); + if (page.total_count > 100) return empty('ambiguous'); + const locations = page.items.flatMap(item => { const parsed = chargingLocation.safeParse(item); return parsed.success ? [parsed.data] : []; }); + const location = matchChargingLocation(locations, lat, lng, name); + if (!location) return empty('unknown'); + if (location === 'ambiguous') return empty('ambiguous'); + const source = sources.find(source => source.uid === location.source); + if (!source) return empty('unknown'); + let pricesUnavailable = false; + const tariffs = await this.loadTariffs(source.uid).catch(() => { pricesUnavailable = true; return []; }); + return { ...normalizeCharging(location, source, tariffs), pricesUnavailable }; + } + private loadTariffs(source: string): Promise { + const cached = this.tariffs.get(source); + if (cached && cached.expires > Date.now()) return cached.value; + const value = this.fetchTariffs(source); + this.tariffs.set(source, { expires: Date.now() + 300000, value }); + return value; + } + private async fetchTariffs(source: string) { + const tariffs: ChargingTariff[] = []; + for (let offset = 0; offset < 5000; offset += 1000) { + const page = z.object({ items: z.array(z.unknown()), total_count: z.number() }).parse(await request(`/api/ocpi/3.0/tariffs?source_uid=${encodeURIComponent(source)}&limit=1000&offset=${offset}`)); + for (const item of page.items) { const parsed = chargingTariff.safeParse(item); if (parsed.success) tariffs.push(parsed.data); } + if (offset + page.items.length >= page.total_count) return tariffs; + } + throw new Error('Incomplete charging tariffs'); + } +} diff --git a/server/src/nest/roadtrip/roadtrip-hazards.service.ts b/server/src/nest/roadtrip/roadtrip-hazards.service.ts index af2ff8ed5..fe112de8d 100644 --- a/server/src/nest/roadtrip/roadtrip-hazards.service.ts +++ b/server/src/nest/roadtrip/roadtrip-hazards.service.ts @@ -13,6 +13,8 @@ const dwdProperties = z.object({ }); const gdacsProperties = z.object({ eventtype: z.string().regex(/^[A-Z]{2}$/), eventid: z.number().int().positive(), episodeid: z.number().int().nonnegative(), + episodealertscore: z.number().finite().nonnegative().optional().catch(undefined), + alertscore: z.number().finite().nonnegative().optional().catch(undefined), name: z.string(), description: z.string(), datemodified: z.string(), iscurrent: z.union([z.string(), z.boolean()]), }); const utc = (date: string) => new Date(/(?:Z|[+-]\d\d:\d\d)$/.test(date) ? date : `${date}Z`).toISOString(); @@ -76,6 +78,7 @@ export class RoadtripHazardsService { title: props.name, description: props.description, updatedAt: utc(props.datemodified), validUntil: null, url: `https://www.gdacs.org/report.aspx?eventtype=${props.eventtype}&eventid=${props.eventid}&episodeid=${props.episodeid}`, geometry: feature.geometry, + alertScore: props.episodealertscore ?? props.alertscore, })); } catch { partial = true; } } diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts index 4c770af42..22603ad9f 100644 --- a/server/src/nest/roadtrip/roadtrip.module.ts +++ b/server/src/nest/roadtrip/roadtrip.module.ts @@ -1,3 +1,6 @@ +import { ChargingMcp } from './charging.mcp'; +import { ChargingService } from './charging.service'; +import { ChargingController } from './charging.controller'; import { GoogleRouteService } from './google-route.service'; import { GoogleRouteController } from './google-route.controller'; import { GoogleRouteMcp } from './google-route.mcp'; @@ -44,8 +47,8 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp'; PluginsRuntimeModule, MapsModule, PlacesModule, AssignmentsModule, ], - controllers: [GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController], - providers: [GoogleRouteService, GoogleRouteMcp, + controllers: [ChargingController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController], + providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp, RoadtripSearchService, RoadtripHazardsService, RoadtripHazardsMcp, diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index 663d205be..70ab12a9e 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -1,5 +1,6 @@ import { RoadtripSearchService } from '../../src/nest/roadtrip/roadtrip-search.service'; import { GoogleRouteService } from '../../src/nest/roadtrip/google-route.service'; +import { ChargingService } from '../../src/nest/roadtrip/charging.service'; /** * Road trip module e2e — the real guard chain against a temp SQLite db. * @@ -133,6 +134,18 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { }); const cookie = () => sessionCookie(1); + it('gates charging data by addon and trip membership', async () => { + const read = vi.spyOn(app.get(ChargingService), 'read').mockResolvedValue({} as never); + try { + await request(server).get('/api/trips/5/roadtrip/charging/10').expect(401); + await request(server).get('/api/trips/6/roadtrip/charging/10').set('Cookie', cookie()).expect(404); + await request(server).get('/api/trips/5/roadtrip/charging/10').set('Cookie', cookie()).expect(200); + expect(read).toHaveBeenCalledWith(5, 10); + setAddon(false); + await request(server).get('/api/trips/5/roadtrip/charging/10').set('Cookie', cookie()).expect(404); + expect(read).toHaveBeenCalledTimes(1); + } finally { read.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: [] }); diff --git a/server/tests/unit/nest/charging.test.ts b/server/tests/unit/nest/charging.test.ts new file mode 100644 index 000000000..71355473a --- /dev/null +++ b/server/tests/unit/nest/charging.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ChargingService } from '../../../src/nest/roadtrip/charging.service'; +import { matchChargingLocation, normalizeCharging, type ChargingLocation, type ChargingSource, type ChargingTariff } from '../../../src/nest/roadtrip/charging.helpers'; + +const now = Date.parse('2026-09-12T12:00:00Z'); +const source: ChargingSource = { uid: 'test', name: 'Test operator', public_url: 'https://example.com', attribution_license: 'CC-0', attribution_contributor: null, realtime_data_updated_at: new Date(now).toISOString(), realtime_status: 'ACTIVE' }; +const station: ChargingLocation = { id: '1', source: 'test', name: 'Test charging', operator: { name: 'Test' }, address: 'Main street', city: 'City', last_updated: new Date(now).toISOString(), coordinates: { latitude: 48, longitude: 11 }, charging_pool: [{ evses: [ + { uid: '1', evse_id: 'DE*TEST*1', status: 'AVAILABLE', last_updated: new Date(now - 86400000).toISOString(), connectors: [{ tariff_ids: ['original'] }] }, + { uid: '2', status: 'CHARGING', last_updated: new Date(now).toISOString(), connectors: [] }, + { uid: '3', status: 'STATIC', last_updated: new Date(now).toISOString(), connectors: [] }, +] }] }; +const tariff: ChargingTariff = { id: '2', original_id: 'original', source: 'test', currency: 'EUR', last_updated: new Date(now).toISOString(), elements: [ + { price_components: [{ type: 'ENERGY', price: 0.5, taxes: [{ percentage: 19 }] }] }, + { restrictions: { min_duration: 1800 }, price_components: [{ type: 'TIME', price: 12, taxes: [] }] }, +] }; + +afterEach(() => vi.unstubAllGlobals()); +describe('Open charging data', () => { + it('keeps unchanged availability live when the source has refreshed, and does not count unknown as free', () => { + const info = normalizeCharging(station, source, [], now); + expect(info).toMatchObject({ available: 1, total: 3, unknown: 1, stale: false }); + }); + it('never shows old, future or failed source status as current availability', () => { + for (const date of [new Date(now - 3600000).toISOString(), new Date(now + 3600000).toISOString(), null]) { + expect(normalizeCharging(station, { ...source, realtime_data_updated_at: date }, [], now).available).toBeNull(); + } + expect(normalizeCharging(station, { ...source, realtime_status: 'FAILED' }, [], now).stale).toBe(true); + }); + it('matches tariff identifiers only within their source and retains taxes and conditions', () => { + const info = normalizeCharging(station, source, [tariff, { ...tariff, source: 'other' }], now); + expect(info.tariffs).toHaveLength(1); + expect(info.tariffs[0].components[0]).toMatchObject({ price: 0.595, taxIncluded: true, conditional: false }); + expect(info.tariffs[0].components[1]).toMatchObject({ afterSeconds: 1800, conditional: true }); + }); + it('rejects distant or ambiguous stations instead of attaching another operator’s data', () => { + expect(matchChargingLocation([station], 0, 0, 'Test')).toBeNull(); + expect(matchChargingLocation([station, { ...station, id: 'other', operator: { name: 'Other' } }], 48, 11, 'Charging')).toBe('ambiguous'); + expect(matchChargingLocation([station], 48, 11, 'Test')).toEqual(station); + }); + it('checks trip/place scope and ignores non-charging places', async () => { + const get = vi.fn().mockReturnValueOnce(null).mockReturnValueOnce({ name: 'Hotel', lat: 48, lng: 11, stop_type: 'hotel' }); + const service = new ChargingService({ get } as never); + await expect(service.read(1, 2)).rejects.toThrow(); + expect((await service.read(1, 2)).status).toBe('unknown'); + expect(get).toHaveBeenCalledWith(expect.stringContaining('trip_id = ?'), 2, 1); + }); + it('coalesces station requests and preserves availability if tariffs fail', async () => { + const fetcher = 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('', { status: 503 }); + }); + vi.stubGlobal('fetch', fetcher); + const service = new ChargingService({ get: () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never); + const [first, second] = await Promise.all([service.read(1, 2), service.read(1, 2)]); + expect(first).toEqual(second); + expect(first.status).toBe('ok'); + expect(first.pricesUnavailable).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(3); + }); +}); diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 801abca2e..bee84d9c4 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -1,6 +1,12 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'متاح', + 'roadtrip.charging.unknown': 'لا تتوفر بيانات موثوقة', + 'roadtrip.charging.stale': 'الحالة قديمة', + 'roadtrip.charging.prices': 'التعريفات المنشورة', + 'roadtrip.charging.note': 'تعريفة منشورة؛ قد تُطبق ضرائب وشروط ورسوم إضافية. قد يختلف سعر بطاقة الشحن.', + 'roadtrip.charging.coverage': 'بيانات مفتوحة؛ تختلف التغطية حسب المنطقة والمشغل.', 'roadtrip.import.title': 'استيراد مسار Google Maps', 'roadtrip.import.note': 'تُضاف المحطات إلى اليوم المحدد. يعيد TREK حساب المسار.', 'roadtrip.import.unresolved': 'تعذر تحديد الموقع؛ سيتم تخطي هذه المحطة.', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 901b6791a..48f361950 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -1,6 +1,12 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': "disponíveis", + 'roadtrip.charging.unknown': "Sem dados confiáveis", + 'roadtrip.charging.stale': "Status desatualizado", + 'roadtrip.charging.prices': "Tarifas publicadas", + '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.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.', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 8a5763d8c..0f4bed6dd 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'disponibles', + 'roadtrip.charging.unknown': 'No hi ha dades fiables', + 'roadtrip.charging.stale': 'Estat desactualitzat', + 'roadtrip.charging.prices': 'Tarifes publicades', + '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.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à.', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index b32a39a3d..b4ed5e584 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'volné', + 'roadtrip.charging.unknown': 'Spolehlivá data nejsou dostupná', + 'roadtrip.charging.stale': 'Zastaralý stav', + 'roadtrip.charging.prices': 'Zveřejněné tarify', + '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.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.', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index af11197f0..f0988408f 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'frei', + 'roadtrip.charging.unknown': 'Keine verlässlichen Daten verfügbar', + 'roadtrip.charging.stale': 'Status veraltet', + 'roadtrip.charging.prices': 'Veröffentlichte Tarife', + '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.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.', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 0f43f6a0e..99be52bf4 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'available', + 'roadtrip.charging.unknown': 'No reliable data available', + 'roadtrip.charging.stale': 'Status outdated', + 'roadtrip.charging.prices': 'Published tariffs', + '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.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.', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 0005ab6ba..e904449c6 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'disponibles', + 'roadtrip.charging.unknown': 'No hay datos fiables', + 'roadtrip.charging.stale': 'Estado desactualizado', + 'roadtrip.charging.prices': 'Tarifas publicadas', + '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.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.', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index bff73a847..12244e110 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'disponibles', + 'roadtrip.charging.unknown': 'Aucune donnée fiable disponible', + 'roadtrip.charging.stale': 'État périmé', + 'roadtrip.charging.prices': 'Tarifs publiés', + '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.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.', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 4039649a9..ba61a048b 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'διαθέσιμα', + 'roadtrip.charging.unknown': 'Δεν υπάρχουν αξιόπιστα δεδομένα', + 'roadtrip.charging.stale': 'Παρωχημένη κατάσταση', + 'roadtrip.charging.prices': 'Δημοσιευμένες χρεώσεις', + 'roadtrip.charging.note': + 'Δημοσιευμένη χρέωση· ενδέχεται να ισχύουν φόροι, όροι και πρόσθετα τέλη. Η τιμή της κάρτας σας μπορεί να διαφέρει.', + 'roadtrip.charging.coverage': 'Ανοικτά δεδομένα· η κάλυψη εξαρτάται από την περιοχή και τον πάροχο.', 'roadtrip.import.title': 'Εισαγωγή διαδρομής Google Maps', 'roadtrip.import.note': 'Οι στάσεις προστίθενται στην επιλεγμένη ημέρα. Το TREK υπολογίζει ξανά τη διαδρομή.', 'roadtrip.import.unresolved': 'Η τοποθεσία δεν βρέθηκε· αυτή η στάση θα παραλειφθεί.', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 871aa9b20..91599bead 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -1,6 +1,12 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'szabad', + 'roadtrip.charging.unknown': 'Nincs megbízható adat', + 'roadtrip.charging.stale': 'Elavult állapot', + '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.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.', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 9b9126768..b1927eb72 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'tersedia', + 'roadtrip.charging.unknown': 'Tidak ada data terpercaya', + 'roadtrip.charging.stale': 'Status kedaluwarsa', + 'roadtrip.charging.prices': 'Tarif yang dipublikasikan', + '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.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.', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index ea0a184b6..4819d9909 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'disponibili', + 'roadtrip.charging.unknown': 'Nessun dato affidabile', + 'roadtrip.charging.stale': 'Stato non aggiornato', + 'roadtrip.charging.prices': 'Tariffe pubblicate', + '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.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.', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index c89d9fa06..2842e56e7 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': '空き', + 'roadtrip.charging.unknown': '信頼できるデータがありません', + 'roadtrip.charging.stale': '古い状態情報', + 'roadtrip.charging.prices': '公開料金', + 'roadtrip.charging.note': + '公開料金です。税金、条件、追加料金をご確認ください。充電カードの料金は異なる場合があります。', + 'roadtrip.charging.coverage': 'オープンデータ。地域や事業者によって対応範囲が異なります。', 'roadtrip.import.title': 'Google Mapsのルートをインポート', 'roadtrip.import.note': '立ち寄り先を選択した日に追加し、TREKでルートを再計算します。', 'roadtrip.import.unresolved': '場所が見つからないため、この立ち寄り先はスキップされます。', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 1d4946681..148f45ebc 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': '이용 가능', + 'roadtrip.charging.unknown': '신뢰할 수 있는 데이터 없음', + 'roadtrip.charging.stale': '오래된 상태', + 'roadtrip.charging.prices': '공개 요금', + 'roadtrip.charging.note': + '공개 요금입니다. 세금, 조건, 추가 요금이 적용될 수 있으며 충전 카드 요금은 다를 수 있습니다.', + 'roadtrip.charging.coverage': '공개 데이터로 지역과 운영사에 따라 범위가 다릅니다.', 'roadtrip.import.title': 'Google Maps 경로 가져오기', 'roadtrip.import.note': '경유지를 선택한 날짜에 추가합니다. TREK이 경로를 다시 계산합니다.', 'roadtrip.import.unresolved': '위치를 찾을 수 없어 이 경유지를 건너뜁니다.', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index cfb5b78fd..5ee32c8e0 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'beschikbaar', + 'roadtrip.charging.unknown': 'Geen betrouwbare gegevens', + 'roadtrip.charging.stale': 'Status verouderd', + 'roadtrip.charging.prices': 'Gepubliceerde tarieven', + '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.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.', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 546ea92e9..c8a831b12 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'wolne', + 'roadtrip.charging.unknown': 'Brak wiarygodnych danych', + 'roadtrip.charging.stale': 'Nieaktualny status', + 'roadtrip.charging.prices': 'Opublikowane taryfy', + '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.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.', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 016d70227..a08eb021c 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'свободно', + 'roadtrip.charging.unknown': 'Нет надёжных данных', + 'roadtrip.charging.stale': 'Статус устарел', + 'roadtrip.charging.prices': 'Опубликованные тарифы', + 'roadtrip.charging.note': + 'Опубликованный тариф; возможны налоги, условия и доплаты. Цена по вашей карте может отличаться.', + 'roadtrip.charging.coverage': 'Открытые данные; охват зависит от региона и оператора.', 'roadtrip.import.title': 'Импорт маршрута Google Maps', 'roadtrip.import.note': 'Остановки добавляются в выбранный день. TREK пересчитает маршрут.', 'roadtrip.import.unresolved': 'Место не найдено; эта остановка будет пропущена.', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 80efca11d..0c4e167ad 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -1,6 +1,12 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'lediga', + 'roadtrip.charging.unknown': 'Inga tillförlitliga uppgifter', + 'roadtrip.charging.stale': 'Inaktuell status', + '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.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.', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index d01d9e44c..b3006fac8 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'boş', + 'roadtrip.charging.unknown': 'Güvenilir veri yok', + 'roadtrip.charging.stale': 'Durum güncel değil', + 'roadtrip.charging.prices': 'Yayımlanan tarifeler', + '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.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.', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index b9c2dd9a8..bf378a718 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'вільні', + 'roadtrip.charging.unknown': 'Немає надійних даних', + 'roadtrip.charging.stale': 'Статус застарів', + 'roadtrip.charging.prices': 'Опубліковані тарифи', + 'roadtrip.charging.note': + 'Опублікований тариф; можливі податки, умови та доплати. Ціна за вашою карткою може відрізнятися.', + 'roadtrip.charging.coverage': 'Відкриті дані; охоплення залежить від регіону й оператора.', 'roadtrip.import.title': 'Імпорт маршруту Google Maps', 'roadtrip.import.note': 'Зупинки додаються до вибраного дня. TREK перерахує маршрут.', 'roadtrip.import.unresolved': 'Місце не знайдено; цю зупинку буде пропущено.', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index df3fcbabd..b4ba82180 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -1,6 +1,13 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': 'còn trống', + 'roadtrip.charging.unknown': 'Không có dữ liệu đáng tin cậy', + 'roadtrip.charging.stale': 'Trạng thái đã cũ', + 'roadtrip.charging.prices': 'Biểu giá công bố', + '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.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.', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 31845fe5d..6b776c5b2 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -1,6 +1,12 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': '可用', + 'roadtrip.charging.unknown': '暫無可靠資料', + 'roadtrip.charging.stale': '狀態已過時', + 'roadtrip.charging.prices': '公布的費率', + 'roadtrip.charging.note': '公布的費率可能附帶稅費、條件和額外費用。您的充電卡價格可能不同。', + 'roadtrip.charging.coverage': '開放資料,涵蓋範圍因地區和營運商而異。', 'roadtrip.import.title': '匯入 Google Maps 路線', 'roadtrip.import.note': '停靠點將新增至所選日期。TREK 將重新計算路線。', 'roadtrip.import.unresolved': '找不到位置;將略過此停靠點。', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index f3b2c45a9..a5e02d93f 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -1,6 +1,12 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.charging.available': '可用', + 'roadtrip.charging.unknown': '暂无可靠数据', + 'roadtrip.charging.stale': '状态已过时', + 'roadtrip.charging.prices': '公布的费率', + 'roadtrip.charging.note': '公布的费率可能附带税费、条件和额外费用。您的充电卡价格可能不同。', + 'roadtrip.charging.coverage': '开放数据,覆盖范围因地区和运营商而异。', 'roadtrip.import.title': '导入 Google Maps 路线', 'roadtrip.import.note': '停靠点将添加到所选日期。TREK 将重新计算路线。', 'roadtrip.import.unresolved': '未找到位置;将跳过此停靠点。', diff --git a/shared/src/index.ts b/shared/src/index.ts index b1f121df2..35b55b6de 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -81,3 +81,4 @@ export * from './roadtrip/hazards.schema'; export * from './roadtrip/planning.schema'; export * from './roadtrip/search.schema'; export * from './roadtrip/google-import.schema'; +export * from './roadtrip/charging.schema'; diff --git a/shared/src/roadtrip/charging.schema.ts b/shared/src/roadtrip/charging.schema.ts new file mode 100644 index 000000000..df9369a56 --- /dev/null +++ b/shared/src/roadtrip/charging.schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const chargingInfoSchema = z.object({ + checkedAt: z.string(), + status: z.enum(['ok', 'unknown', 'ambiguous', 'unavailable']), + station: z.string().nullable(), + source: z.string().nullable(), + sourceUrl: z.string().nullable(), + license: z.string().nullable(), + updatedAt: z.string().nullable(), + stale: z.boolean(), + available: z.number().int().nonnegative().nullable(), + total: z.number().int().nonnegative(), + unknown: z.number().int().nonnegative(), + tariffs: z.array( + z.object({ + currency: z.string(), + updatedAt: z.string(), + components: z.array( + z.object({ + kind: z.string(), + price: z.number().nonnegative(), + taxIncluded: z.boolean(), + conditional: z.boolean(), + afterSeconds: z.number().nullable(), + }), + ), + }), + ), + pricesUnavailable: z.boolean(), +}); +export type ChargingInfo = z.infer; diff --git a/shared/src/roadtrip/hazards.schema.ts b/shared/src/roadtrip/hazards.schema.ts index adfea3837..0036d8543 100644 --- a/shared/src/roadtrip/hazards.schema.ts +++ b/shared/src/roadtrip/hazards.schema.ts @@ -16,6 +16,7 @@ export const roadtripHazardSchema = z.object({ validUntil: z.string().datetime().nullable(), url: z.string().url(), geometry: hazardGeometrySchema, + alertScore: z.number().finite().nonnegative().optional(), }); export const roadtripHazardsSchema = z.object({ fetchedAt: z.string().datetime(), diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index 27dc7d6f2..df086f90a 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -247,3 +247,5 @@ Roadtrip also exposes `get_roadtrip_hazards` (`tripId`, trips read scope). It re Roadtrip corridor searches include installed search-provider plugins. Providers receive the category and search bounds, and the host filters hits to the route. The response lists successful and failed sources; a failed provider does not discard the remaining results. Google Maps directions links can be reviewed with preview_google_maps_route and saved with import_google_maps_route. Import creates places and visits together in the supplied order, with trip access and place/day permission checks. Unresolved stops must be omitted from the confirmed input. The Google road geometry is not copied. + +get_roadtrip_charging_info accepts tripId and placeId. It requires trip access and the Roadtrip addon. It reports matching failures, unavailable feeds, freshness, known free capacity and unknown status counts separately. Published tariff components include currency, tax handling and conditions; they do not estimate a user's roaming price. Data source: [MobiData BW OCPDB](https://api.mobidata-bw.de/), with per-source attribution from its public source registry. diff --git a/wiki/Trip-Planner-Overview.md b/wiki/Trip-Planner-Overview.md index 659ba90cb..981f2347d 100644 --- a/wiki/Trip-Planner-Overview.md +++ b/wiki/Trip-Planner-Overview.md @@ -113,3 +113,9 @@ Driving settings also controls **Show in Days too** under **Service stops**. It In Roadtrip, open the three-dot menu beside the day selector to import a Google Maps directions link. Preview the ordered stops, choose a day, then confirm. Resolved stops are appended to that day; unresolved locations are marked and skipped. TREK calculates its own route between the stops. Import needs an online connection and permission to edit places and days. > **AI / MCP:** Use preview_google_maps_route to inspect a link, then import_google_maps_route to append the reviewed stops to a trip day. + +Roadtrip charging stops show compact availability and published energy-price badges. The place detail panel adds source attribution, timestamps and tariff conditions. Data comes from the public MobiData BW OCPDB aggregation, including participating German operators and Swiss data, without an API key or account. Coverage varies; an unknown or stale status does not mean a charger is free. Prices are published source tariffs, not personal charging-card quotes. Data refreshes while Roadtrip is visible and is not advertised as live offline. + +> **AI / MCP:** get_roadtrip_charging_info reads the same availability, tariff components, freshness and source information for a saved charging stop. + +GDACS warning popups show the current episode score when supplied, falling back to the overall event score. The compact scale indicates GDACS humanitarian impact, not whether roads are passable.