mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user