add open charging data and polish roadtrip warnings

This commit is contained in:
Maurice
2026-09-12 13:25:15 +02:00
parent ff5fa55fad
commit f56e7a161e
52 changed files with 820 additions and 32 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] })
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
pointToLayer={(_, point) => 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 })}
/>)}</>
}
@@ -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: () => <div data-testid="tile-layer" />,
Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
<div
ref={node => { 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(<MapView pois={[poi]} onPoiClick={first} onPoiDropOnRoute={() => {}} />)
const marker = markersWithZ('500')[0]
expect(marker).toHaveAttribute('draggable', 'true')
fireEvent.mouseDown(marker)
fireEvent.mouseUp(marker)
fireEvent.click(marker)
expect(first).toHaveBeenCalledTimes(1)
rerender(<MapView pois={[poi]} onPoiClick={latest} onPoiDropOnRoute={() => {}} />)
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(<MapView pois={[buildPoi({ osm_id: 'node/8', category: 'not-a-category' })]} />)
const html = iconHtmlOf(markersWithZ('500')[0])
+8 -1
View File
@@ -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) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, poi.lng]}
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
zIndexOffset={500}
@@ -697,7 +700,11 @@ export const MapView = memo(function MapView({
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => 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)
},
}}
>
+1 -1
View File
@@ -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 ? <div role="status" className="absolute bottom-10 left-1/2 z-[500] flex max-w-[calc(100%-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2 text-caption text-content shadow-md">
const status = hazards.enabled ? <div role="status" style={props.hasInspector || props.hasDayDetail ? { top: 64 } : { bottom: 40 }} className="absolute left-1/2 z-[500] flex max-w-[calc(100%-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2 text-caption text-content shadow-md">
{hazards.status !== 'ready' ? t(`roadtrip.hazards.${hazards.status}`) : <>
<span className="flex items-center gap-2 whitespace-nowrap font-medium">
<AlertTriangle size={14} className="text-content-muted" aria-hidden />
+1 -1
View File
@@ -710,7 +710,7 @@ export function MapViewGL({
const containerRef = useRef<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any | null>(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<Map<number, PlacePin>>(new Map())
+133
View File
@@ -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;
}
+73 -11
View File
@@ -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
}
@@ -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' && <ChargingInfo placeId={place.id} />}
{(roadtripEndDay || roadtripStay) && <VisitControls endDay={roadtripEndDay} stay={roadtripStay} />}
{(place.description || googleDetails?.summary) && (
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
@@ -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(<TranslationProvider><ChargingInfo placeId={7} /></TranslationProvider>)
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(<TranslationProvider><ChargingInfo placeId={7} /></TranslationProvider>)
expect(await screen.findByText('Status outdated')).toBeInTheDocument()
expect(screen.queryByText('2/4')).not.toBeInTheDocument()
})
})
@@ -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<Info | null>(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<typeof energy[number] | null>((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 = <span className="inline-flex shrink-0 items-center gap-1 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none">
<Tooltip label={`${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`}><span tabIndex={0} className={`inline-flex items-center gap-1 rounded px-1 py-0.5 ${known && info.available! > 0 ? 'bg-success-soft text-success' : 'bg-surface-secondary text-content-muted'}`}><PlugZap size={10} />{known ? `${info.available}/${info.total}` : '?'}</span></Tooltip>
{cheapest && <Tooltip label={t('roadtrip.charging.note')}><span tabIndex={0} className="inline-flex items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 text-content-muted"><Coins size={10} />{price(cheapest.price, cheapest.currency)}/kWh*</span></Tooltip>}
</span>
if (compact) return badges
return <details className="group rounded-[10px] bg-surface-hover text-[length:calc(12px*var(--fs-scale-body,1))] text-content-secondary">
<summary className="flex cursor-pointer list-none items-center gap-1.5 px-3 py-2 [&::-webkit-details-marker]:hidden">
<PlugZap size={13} className="shrink-0 text-content-faint" />
<span className="font-medium">{t('roadtrip.poi.charging')}</span>
<span className="ml-2 text-content-muted">{status}</span>
{cheapest && <span className="ml-auto whitespace-nowrap font-medium">{price(cheapest.price, cheapest.currency)}/kWh*</span>}
<ChevronDown size={13} className={'shrink-0 text-content-faint group-open:rotate-180 ' + (cheapest ? '' : 'ml-auto')} />
</summary>
<div className="space-y-2 px-3 pb-3 text-content-muted">
{info?.station && <p>{info.station}</p>}
<div className="space-y-1"><p className="font-medium">{t('roadtrip.charging.prices')}</p>
{!components.length && <p className="text-content-muted">{info?.pricesUnavailable ? `${t('common.error')}: ` : ''}{t('roadtrip.charging.unknown')}</p>}
{components.map((component, index) => <p key={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)` : ''}</p>)}
{!!components.length && <p className="text-content-muted">* {t('roadtrip.charging.note')}</p>}
</div>
<p className="text-content-muted">{t('roadtrip.charging.coverage')}</p>
{info?.source && <p className="text-content-muted"><a className="underline" href={info.sourceUrl?.startsWith('https://') ? info.sourceUrl : 'https://mobidata-bw.de/de/dataset/e-ladesaulen'} target="_blank" rel="noreferrer">{info.source}</a> · <a className="underline" href="https://mobidata-bw.de/de/dataset/e-ladesaulen" target="_blank" rel="noreferrer">MobiData BW</a><br />{info.license}{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}</p>}
</div>
</details>
}
@@ -12,8 +12,8 @@ export default function EndDayControl({ active, onToggle }: EndDayControlProps)
const { t } = useTranslation()
const [pending, setPending] = useState(false)
return (
<fieldset disabled={pending} aria-busy={pending} className="flex min-w-0 items-center justify-between gap-2 rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 disabled:opacity-60">
<span className="flex flex-1 items-center gap-2 text-caption font-medium text-content"><Moon size={15} className={`shrink-0 ${active ? 'text-accent-on' : 'text-content-faint'}`} aria-hidden />{t('roadtrip.window.endHere')}</span>
<fieldset disabled={pending} aria-busy={pending} className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 disabled:opacity-60">
<span className="flex flex-1 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary"><Moon size={13} className={`shrink-0 ${active ? 'text-accent-on' : 'text-content-faint'}`} aria-hidden />{t('roadtrip.window.endHere')}</span>
<ToggleSwitch on={active} label={t('roadtrip.window.endHere')} onToggle={async () => {
if (pending) return
setPending(true)
@@ -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()
})
})
@@ -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. */}
<span
className="min-w-0 truncate font-semibold leading-6 tracking-[-0.012em] text-content-secondary"
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content-secondary"
style={{ fontSize: FS.name }}
>
{stop.name}
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}
</span>
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
@@ -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. */}
<span
className="min-w-0 break-words font-semibold leading-6 tracking-[-0.012em] text-content"
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content"
style={{ fontSize: FS.name }}
>
{stop.name}
<span className={stop.stopType === 'charging' ? 'min-w-0 truncate' : 'min-w-0 break-words'}>{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}
</span>
{/* Two halves under one border: the word says what the number means, so the
number needs no unit of explanation beside it. */}
@@ -14,9 +14,9 @@ export default function VisitControls({ endDay, stay }: { endDay?: EndDayControl
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
{endDay && <EndDayControl {...endDay} />}
{stay && <button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
className="flex min-w-0 items-center justify-between gap-2 rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 text-start enabled:hover:bg-surface-hover disabled:cursor-default">
<span className="flex items-center gap-2 text-caption font-medium uppercase text-content"><Clock size={15} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
{' '}<span className="flex shrink-0 items-center gap-2 text-caption font-medium text-content">
className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 text-start enabled:hover:bg-surface-tertiary disabled:cursor-default">
<span className="flex items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium uppercase text-content-secondary"><Clock size={13} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
{' '}<span className="flex shrink-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
{formatDurationShort((stay.minutes ?? 0) * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
</span>
</button>}
+2 -2
View File
@@ -773,7 +773,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{selectedPlace && !isMobile && (
<PlaceInspector
roadtripEndDay={roadtripEndDay}
roadtripStay={roadtripStay}
roadtripStay={roadtripStay} roadtripActive={roadtripActive}
onEditTransport={openLinkedTransport}
onEditReservation={openLinkedReservation}
place={selectedPlace}
@@ -817,7 +817,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
<div style={{ width: '100%', maxHeight: '85vh' }} role="presentation" onClick={e => e.stopPropagation()}>
<PlaceInspector
roadtripEndDay={roadtripEndDay}
roadtripStay={roadtripStay}
roadtripStay={roadtripStay} roadtripActive={roadtripActive}
onEditTransport={openLinkedTransport}
onEditReservation={openLinkedReservation}
place={selectedPlace}
+17
View File
@@ -0,0 +1,17 @@
import { chargingInfoSchema, type ChargingInfo } from '@trek/shared'
import { apiClient } from '../api/client'
import { isEffectivelyOffline } from '../sync/networkMode'
const pending = new Map<string, { until: number; value: Promise<ChargingInfo> }>()
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
},
}
@@ -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); }
}
@@ -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<typeof chargingLocation>;
export type ChargingSource = z.infer<typeof chargingSource>;
export type ChargingTariff = z.infer<typeof chargingTariff>;
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,
}))),
})),
};
}
+20
View File
@@ -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));
}
}
@@ -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<unknown>(fetched, 8_000_000);
}
@Injectable()
export class ChargingService {
private cached = new Map<string, { expires: number; value: Promise<ChargingInfo> }>();
private sources?: { expires: number; value: Promise<ChargingSource[]> };
private tariffs = new Map<string, { expires: number; value: Promise<ChargingTariff[]> }>();
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<ChargingInfo> {
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<ChargingTariff[]> {
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');
}
}
@@ -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; }
}
+5 -2
View File
@@ -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,
+13
View File
@@ -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: [] });
+61
View File
@@ -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);
});
});
+6
View File
@@ -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': 'تعذر تحديد الموقع؛ سيتم تخطي هذه المحطة.',
+6
View File
@@ -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.',
+7
View File
@@ -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à.',
+7
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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': 'Η τοποθεσία δεν βρέθηκε· αυτή η στάση θα παραλειφθεί.',
+6
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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': '場所が見つからないため、この立ち寄り先はスキップされます。',
+7
View File
@@ -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': '위치를 찾을 수 없어 이 경유지를 건너뜁니다.',
+7
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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': 'Место не найдено; эта остановка будет пропущена.',
+6
View File
@@ -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.',
+7
View File
@@ -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.',
+7
View File
@@ -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': 'Місце не знайдено; цю зупинку буде пропущено.',
+7
View File
@@ -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.',
+6
View File
@@ -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': '找不到位置;將略過此停靠點。',
+6
View File
@@ -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': '未找到位置;将跳过此停靠点。',
+1
View File
@@ -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';
+32
View File
@@ -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<typeof chargingInfoSchema>;
+1
View File
@@ -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(),
+2
View File
@@ -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.
+6
View File
@@ -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.