mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
add open charging data and polish roadtrip warnings
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user