feat(tours): find e-bike charging points along a bike tour, only those tagged for bicycles, with what they state

This commit is contained in:
Maurice
2026-10-21 11:58:39 +02:00
parent 924e38472e
commit 1a722e63fb
50 changed files with 1071 additions and 57 deletions
+3 -1
View File
@@ -1,4 +1,4 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, type LucideIcon } from 'lucide-react'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, BatteryCharging, type LucideIcon } from 'lucide-react'
import type { TourPoiCategory } from '@trek/shared'
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
// The road-trip kinds take their colour from the one place that defines it, so the
@@ -68,6 +68,8 @@ export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
cafe: { key: 'cafe', labelKey: 'tours.poi.cat.cafe', Icon: Coffee, color: coreColor('cafe') },
restaurant: { key: 'restaurant', labelKey: 'tours.poi.cat.restaurant', Icon: Utensils, color: coreColor('restaurant') },
market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') },
// BIK-07: the road trip's charging colour, so a charger reads as one on every map.
ebike_charging: { key: 'ebike_charging', labelKey: 'tours.poi.cat.ebike_charging', Icon: BatteryCharging, color: SERVICE_COLORS.charging },
}
// A Map rather than an object, so a category named after an Object.prototype member
@@ -12,6 +12,7 @@ import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { TourPoiChargingFacts } from './TourPoiChargingFacts';
import { TourPoiSlotPicker } from './TourPoiSlotPicker';
import { usePoiInsertion } from './usePoiInsertion';
import { useSelectedPoi } from './useTourPoiSearch';
@@ -135,6 +136,7 @@ function TourPoiCard({
</WhiteBadge>
</div>
<PoiFacts poi={poi} />
{poi.charging && <TourPoiChargingFacts charging={poi.charging} />}
<TourPoiSlotPicker planner={planner} poi={poi} insertion={insertion} unit={unit} />
<p className="mt-2 flex flex-wrap gap-x-3 text-content-faint" style={fs(10.5, 'body')}>
{suggested && <span>{t('tours.walk.themeReason', { theme: t(`tours.walk.theme.${suggested}`) })}</span>}
@@ -0,0 +1,111 @@
import type { TourPoiCharging } from '@trek/shared';
import { Bike, Plug } from 'lucide-react';
import type { ReactElement } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { Tooltip } from '../../shared/Tooltip';
/** Socket families as people call them; proper nouns, so not translated. */
const SOCKET_NAMES: Record<string, string> = {
schuko: 'Schuko',
typee: 'Type E',
bs1363: 'BS 1363',
sev1011_t13: 'T13',
sev1011_t23: 'T23',
cee_blue: 'CEE',
type2: 'Type 2',
};
/** The household plugs: the bike's own charger goes in, none hangs there. */
const HOUSEHOLD_SOCKETS: ReadonlySet<string> = new Set([
'schuko',
'typee',
'bs1363',
'sev1011_t13',
'sev1011_t23',
'cee_blue',
]);
type Translate = ReturnType<typeof useTranslation>['t'];
/** OpenStreetMap's access values, read as one of three answers; any other is left unsaid. */
function accessLabel(access: string | null, t: Translate) {
if (access === 'yes' || access === 'permissive' || access === 'public') return t('tours.poi.charging.access.public');
if (access === 'customers') return t('tours.poi.charging.access.customers');
if (access === 'private' || access === 'no') return t('tours.poi.charging.access.private');
return null;
}
function Fact({ hint, children }: { hint: string; children: ReactElement }) {
return (
<Tooltip label={hint} placement="top">
<span className="inline-flex">{children}</span>
</Tooltip>
);
}
/**
* BIK-07: what an e-bike charging point states, a badge per fact and only the
* facts given: made for bikes or letting them charge, its sockets, fee, access
* and how many bikes fit. A plug left unsaid is said to be unsaid, dimmed, so it
* never reads as one that fits.
*/
export function TourPoiChargingFacts({ charging }: { charging: TourPoiCharging }) {
const { t } = useTranslation();
const hint = t('tours.poi.charging.sourceHint');
const access = accessLabel(charging.access, t);
return (
<div className="mt-2" data-testid="tour-poi-charging">
<p className="m-0 mb-1 text-caption text-content-faint">{t('tours.poi.charging.label')}</p>
<div className="flex flex-wrap gap-1.5">
<Fact hint={hint}>
{charging.bicycle === 'designated' ? (
<SoftPill tone="success" icon={<Bike size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('tours.poi.charging.designated')}
</SoftPill>
) : (
<WhiteBadge icon={<Bike size={10} strokeWidth={2.2} className="text-content-faint" />}>
{t('tours.poi.charging.allowed')}
</WhiteBadge>
)}
</Fact>
{charging.sockets.map((socket) => (
<Fact key={socket.type} hint={HOUSEHOLD_SOCKETS.has(socket.type) ? t('tours.poi.charging.ownCharger') : hint}>
<WhiteBadge icon={<Plug size={10} strokeWidth={2.2} className="text-content-faint" />}>
{SOCKET_NAMES[socket.type] ?? socket.type}
{socket.count && socket.count > 1 ? ` ×${socket.count}` : ''}
{socket.kw ? ` ${socket.kw} kW` : ''}
</WhiteBadge>
</Fact>
))}
{charging.sockets.length === 0 && (
<Fact hint={t('tours.poi.charging.socketsUnknownHint')}>
<span className="inline-flex opacity-60" data-testid="tour-poi-charging-no-socket">
<WhiteBadge icon={<Plug size={10} strokeWidth={2.2} className="text-content-faint" />}>
{t('tours.poi.charging.socketsUnknown')}
</WhiteBadge>
</span>
</Fact>
)}
{charging.fee !== null && (
<Fact hint={hint}>
<WhiteBadge>{charging.fee ? t('tours.poi.charging.fee') : t('tours.poi.charging.free')}</WhiteBadge>
</Fact>
)}
{access && (
<Fact hint={hint}>
<WhiteBadge>{access}</WhiteBadge>
</Fact>
)}
{charging.capacity !== null && (
<Fact hint={hint}>
<WhiteBadge>
{t('tours.poi.charging.capacity')}
<span className="font-medium text-content">{charging.capacity}</span>
</WhiteBadge>
</Fact>
)}
</div>
</div>
);
}
@@ -47,6 +47,26 @@ function RetryButton({ search }: { search: TourPoiSearch }) {
);
}
/** The search sent again with the next wider corridor, while there is one. */
function WidenButton({ search, unit }: { search: TourPoiSearch; unit: Unit }) {
const { t } = useTranslation();
const wider = search.widerCorridorM;
if (wider === null) return null;
return (
<button
type="button"
onClick={search.widen}
disabled={!search.canSearch}
data-testid="tour-poi-widen"
className="inline-flex items-center gap-1 rounded-full border border-edge bg-surface-card px-2 py-0.5 font-medium text-content hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
style={fs(11.5, 'body')}
>
<Expand size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.poi.widen', { distance: formatDistance(km(wider), unit) })}
</button>
);
}
/** A warning of the search, with the way to send it again where that may help. */
function WarningNotice({ text, search }: { text: string; search?: TourPoiSearch }) {
return (
@@ -68,8 +88,8 @@ function WarningNotice({ text, search }: { text: string; search?: TourPoiSearch
* could not be searched, partly searched, cut, or empty. That the answer is
* out of date the search button says, as it is what to press then. Nothing
* found is said only when every kind was searched along the whole line, with
* the next wider corridor one press away; a failure or a gap carries its own
* retry.
* the next wider corridor one press away, as it is when no e-bike charger
* was found among other places; a failure or a gap carries its own retry.
*/
export function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit }) {
const { t } = useTranslation();
@@ -85,9 +105,14 @@ export function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: U
{ text: answer.capped && t('tours.poi.capped'), retry: false },
{ text: answer.truncated && t('tours.poi.truncated'), retry: false },
].filter((note): note is { text: string; retry: boolean } => !!note.text);
const wider = search.widerCorridorM;
// A gap wants a retry, not a wider corridor, and changed choices were not searched yet.
const empty = answer.pois.length === 0 && answer.unavailable.length === 0 && !answer.partial && !search.outdated;
const searchedAll = answer.unavailable.length === 0 && !answer.partial && !search.outdated;
const empty = answer.pois.length === 0 && searchedAll;
// BIK-07: few chargers are tagged for bikes, so none found is said, not left to look like a fault.
const noChargers =
searchedAll &&
search.categories.includes('ebike_charging') &&
!answer.pois.some((poi) => poi.category === 'ebike_charging');
return (
<>
{notes.map((note) => (
@@ -98,19 +123,16 @@ export function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: U
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.poi.empty', { distance: formatDistance(km(search.corridorM), unit) })}
</p>
{wider !== null && (
<button
type="button"
onClick={search.widen}
disabled={!search.canSearch}
data-testid="tour-poi-widen"
className="inline-flex items-center gap-1 rounded-full border border-edge bg-surface-card px-2 py-0.5 font-medium text-content hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
style={fs(11.5, 'body')}
>
<Expand size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.poi.widen', { distance: formatDistance(km(wider), unit) })}
</button>
)}
<WidenButton search={search} unit={unit} />
</div>
)}
{noChargers && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5" data-testid="tour-poi-charging-few">
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.poi.charging.fewMapped')}
</p>
{/* Nothing at all found offers the wider corridor above already. */}
{!empty && <WidenButton search={search} unit={unit} />}
</div>
)}
</>
@@ -14,7 +14,7 @@ import { usePoiInsertion, type PoiInsertionPlanner } from './usePoiInsertion';
import { useTourPoiMarkers } from './useTourPoiMarkers';
import { useTourPoiSearch } from './useTourPoiSearch';
// FE-TOUR-POI-001 to FE-TOUR-POI-027
// FE-TOUR-POI-001 to FE-TOUR-POI-030
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
@@ -668,3 +668,105 @@ describe('a search that could not ask, and its retry (PLN-05)', () => {
expect(screen.queryByTestId('tour-poi-widen')).not.toBeInTheDocument();
});
});
describe('e-bike charging along a bike tour (BIK-07)', () => {
const CHARGER: TourPoi = {
...SPRING,
id: 'node:5',
name: '',
category: 'ebike_charging',
kind: 'amenity=charging_station&bicycle=designated',
website: null,
opening_hours: null,
charging: {
bicycle: 'designated',
sockets: [{ type: 'schuko', count: 2, kw: null }],
capacity: null,
fee: false,
access: null,
},
};
const bikePlanner = (over: Partial<PoiInsertionPlanner> = {}) =>
({ ...searchPlanner({ tourType: 'bike' }), ...insertionPlanner(over) }) as unknown as TourPlannerController;
it('FE-TOUR-POI-028: offered to a bike tour beside its first three, its card shows what the point states and adds it as a stop', async () => {
const { result } = renderHook(() => useTourPoiSearch(searchPlanner({ tourType: 'bike' })));
expect(result.current.offered).toContain('ebike_charging');
expect(result.current.categories).toEqual(['drinking_water', 'bike_repair', 'food']);
act(() => result.current.toggleCategory('ebike_charging'));
await act(() => result.current.search());
expect(search.mock.calls[0]?.[1]).toMatchObject({ categories: expect.arrayContaining(['ebike_charging']) });
useTourPoiStore.getState().clear();
search.mockResolvedValue({ ...ANSWER, pois: [CHARGER] });
const planner = bikePlanner();
renderAlong(planner);
fireEvent.click(screen.getByTestId('tour-poi-search'));
const row = await screen.findByTestId('tour-poi-node:5');
// Mapped without a name, it is named by its kind.
fireEvent.click(within(row).getByRole('button', { name: /E-bike charging/ }));
const facts = within(screen.getByTestId('tour-poi-overlay')).getByTestId('tour-poi-charging');
expect(facts).toHaveTextContent('Made for e-bikes');
expect(facts).toHaveTextContent('Schuko ×2');
expect(facts).toHaveTextContent('Free');
// Nothing said about access or room is shown as nothing, not as a guess.
expect(facts).not.toHaveTextContent('Open to all');
expect(facts).not.toHaveTextContent('Charging spots');
expect(within(facts).queryByTestId('tour-poi-charging-no-socket')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('tour-poi-add'));
await waitFor(() => expect(planner.editWaypoints).toHaveBeenCalledTimes(1));
const updater = vi.mocked(planner.editWaypoints).mock.calls[0]?.[0] as (
points: TourPlannerWaypoint[]
) => TourPlannerWaypoint[];
const added = normalizePlannerWaypoints(updater(POINTS)).find((point) => point.lat === CHARGER.lat);
expect(added).toMatchObject({ lng: CHARGER.lng, name: 'E-bike charging' });
});
it('FE-TOUR-POI-029: a point that only lets bikes charge says so, and an unstated plug is dimmed, never named', async () => {
const plain: TourPoi = {
...CHARGER,
charging: { bicycle: 'yes', sockets: [], capacity: 3, fee: null, access: 'customers' },
};
search.mockResolvedValue({ ...ANSWER, pois: [plain] });
renderAlong(bikePlanner());
fireEvent.click(screen.getByTestId('tour-poi-search'));
fireEvent.click(within(await screen.findByTestId('tour-poi-node:5')).getByRole('button'));
const facts = screen.getByTestId('tour-poi-charging');
expect(facts).toHaveTextContent('E-bikes may charge');
expect(facts).not.toHaveTextContent('Made for e-bikes');
expect(within(facts).getByTestId('tour-poi-charging-no-socket')).toHaveClass('opacity-60');
expect(facts).toHaveTextContent('Plug not stated');
expect(facts).toHaveTextContent('Customers only');
expect(facts).toHaveTextContent('Charging spots3');
expect(facts).not.toHaveTextContent('Free');
expect(facts).not.toHaveTextContent('Schuko');
});
it('FE-TOUR-POI-030: chargers asked for and none found says few are mapped, with the wider corridor one press away', async () => {
render(<TourPoiSearchCard planner={searchPlanner({ tourType: 'bike' })} />);
fireEvent.click(screen.getByTestId('tour-poi-categories'));
const list = screen.getByRole('group', { name: 'What to look for' });
fireEvent.click(within(list).getByRole('button', { name: 'E-bike charging' }));
fireEvent.click(screen.getByTestId('tour-poi-search'));
// Other places found, no charger: the note and the wider corridor, not nothing found.
const note = await screen.findByTestId('tour-poi-charging-few');
expect(note).toHaveTextContent('Few charging points are mapped for e-bikes yet.');
expect(within(note).getByTestId('tour-poi-widen')).toHaveTextContent('Widen to');
expect(screen.queryByText(/Nothing found/)).not.toBeInTheDocument();
// Nothing at all: nothing found with its own wider corridor, the note beside it without a second one.
search.mockResolvedValue({ ...ANSWER, pois: [] });
fireEvent.click(screen.getByTestId('tour-poi-widen'));
expect(await screen.findByText(/Nothing found within/)).toBeInTheDocument();
expect(screen.getByTestId('tour-poi-charging-few')).toBeInTheDocument();
expect(screen.getAllByTestId('tour-poi-widen')).toHaveLength(1);
// Chargers that could not be searched are named as such, never as few mapped.
search.mockResolvedValue({ ...ANSWER, pois: [], unavailable: ['ebike_charging'] });
fireEvent.click(screen.getByTestId('tour-poi-widen'));
expect(await screen.findByText(/Could not be searched right now: E-bike charging/)).toBeInTheDocument();
expect(screen.queryByTestId('tour-poi-charging-few')).not.toBeInTheDocument();
});
});