mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71fe69c1f1 | |||
| 54baf85a2a | |||
| 3882feec5c | |||
| 6477bf5e39 | |||
| 971133a1eb | |||
| 0344db6d0c | |||
| 5d265c98de | |||
| 554d6f1b9f | |||
| f79b2020d6 | |||
| 9f0caf8539 | |||
| 7470a2c6d4 | |||
| 4ca5728072 | |||
| 2203ff3a2e | |||
| 0d7e3ceb3d | |||
| 93a508a0e6 | |||
| 0b92dcc86b | |||
| c836ee3988 | |||
| 188953158a | |||
| 20f8266439 | |||
| e5f01e570a | |||
| 2a8dbf91a8 | |||
| ec9e1a7533 | |||
| 3c823e07f4 | |||
| 9ed944c1e6 | |||
| 2955c61c86 | |||
| c97105ce61 | |||
| 290961cced | |||
| 4c594876ef | |||
| d5d2d6b25e |
@@ -16,7 +16,7 @@
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 15,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
|
||||
@@ -571,6 +571,12 @@
|
||||
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
|
||||
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Reservation form incl. hotel accommodation creation",
|
||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2971,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2969,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
@@ -24,7 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1142,
|
||||
"src/pages/TripPlannerPage.tsx": 1141,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
|
||||
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
|
||||
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
|
||||
}
|
||||
|
||||
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
|
||||
export const tourVisitsApi = {
|
||||
list: (tripId: number | string): Promise<TourVisitListResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
|
||||
update: (
|
||||
tripId: number | string,
|
||||
assignmentId: number,
|
||||
stationKey: string,
|
||||
body: TourVisitUpdateRequest
|
||||
): Promise<TourVisitListResponse> =>
|
||||
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* CW-07: a Google Maps walking route read into its stops. The server may
|
||||
* follow a short link a few hops within its own deadline, so the call waits
|
||||
* a little longer than that before it gives up.
|
||||
*/
|
||||
const WALK_IMPORT_TIMEOUT_MS = 20_000;
|
||||
|
||||
export const tourWalkImportApi = {
|
||||
preview: (
|
||||
tripId: number | string,
|
||||
body: WalkImportPreviewRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<WalkImportPreviewResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_TIMEOUT_MS })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -19,6 +19,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
|
||||
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
|
||||
'tours:changed',
|
||||
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
|
||||
'tours:visits',
|
||||
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
|
||||
@@ -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, 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, 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
|
||||
@@ -62,6 +62,12 @@ export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
|
||||
slipway: { key: 'slipway', labelKey: 'tours.poi.cat.slipway', Icon: Anchor, color: coreColor('museum') },
|
||||
boat_rental: { key: 'boat_rental', labelKey: 'tours.poi.cat.boat_rental', Icon: Sailboat, color: coreColor('cafe') },
|
||||
bike_repair: { key: 'bike_repair', labelKey: 'tours.poi.cat.bike_repair', Icon: Wrench, color: coreColor('activity') },
|
||||
// CW-05: a city walk's own, each in the colour of the explore category it comes from.
|
||||
museum: { key: 'museum', labelKey: 'tours.poi.cat.museum', Icon: Landmark, color: coreColor('museum') },
|
||||
park: { key: 'park', labelKey: 'tours.poi.cat.park', Icon: Trees, color: coreColor('nature') },
|
||||
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') },
|
||||
}
|
||||
|
||||
// A Map rather than an object, so a category named after an Object.prototype member
|
||||
|
||||
@@ -8,7 +8,8 @@ import { formatTime } from '../../utils/formatters';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
|
||||
import { movingTimeLabel } from '../Tours/tourFacts';
|
||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { WalkProgressPill } from '../Tours/walk/WalkStations';
|
||||
import { TimePill } from './planParts';
|
||||
|
||||
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
|
||||
@@ -19,6 +20,8 @@ interface DayPlanPlaceSublineProps {
|
||||
tourTimes: DayRowTourTimes | undefined;
|
||||
isTourPlace: boolean;
|
||||
timeFormat: string;
|
||||
/** CW-03: the row's day assignment, which a city walk's progress belongs to. */
|
||||
assignmentId?: number;
|
||||
}
|
||||
|
||||
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
|
||||
@@ -57,9 +60,9 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-planned-duration"
|
||||
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
||||
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${plannedTotalText(tourTimes, t)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
||||
>
|
||||
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}
|
||||
{t('tours.planner.plannedShort')}: {plannedTotalText(tourTimes, t, true)}
|
||||
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||
</span>
|
||||
</TimePill>
|
||||
@@ -90,7 +93,14 @@ function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace:
|
||||
|
||||
// Second line of a place row in the day plan: time, tour facts or planned
|
||||
// tour durations, then the clamped description.
|
||||
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
|
||||
export function DayPlanPlaceSubline({
|
||||
place,
|
||||
cat,
|
||||
tourTimes,
|
||||
isTourPlace,
|
||||
timeFormat,
|
||||
assignmentId,
|
||||
}: DayPlanPlaceSublineProps) {
|
||||
const { locale } = useTranslation();
|
||||
const dayRowTour = tourTimes?.tour;
|
||||
const description = place.description || place.address || cat?.name;
|
||||
@@ -106,6 +116,7 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
|
||||
</TimePill>
|
||||
)}
|
||||
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
|
||||
{dayRowTour && <WalkProgressPill tour={dayRowTour} assignmentId={assignmentId} />}
|
||||
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
|
||||
// and that it falls back to the plain Place row when it is off. The mocks are the
|
||||
// ones DayPlanSidebar.test.tsx renders the sidebar with.
|
||||
// CW-08: public transit between two city walks from the day plan's connector.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import {
|
||||
buildAssignment,
|
||||
buildDay,
|
||||
buildPlace,
|
||||
buildReservation,
|
||||
buildTrip,
|
||||
buildUser,
|
||||
} from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Reservation } from '../../types';
|
||||
import { tourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import DayPlanSidebar from './DayPlanSidebar';
|
||||
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
||||
const mockDayNotesState = vi.hoisted(() => ({
|
||||
@@ -56,12 +68,14 @@ vi.mock('../Map/RouteCalculator', () => ({
|
||||
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
||||
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
||||
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
||||
// One leg per waypoint gap; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
|
||||
// One leg per waypoint gap, carrying its two ends as the real one does; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints: { lat: number; lng: number }[] = []) =>
|
||||
Promise.resolve({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
|
||||
legs: waypoints.slice(1).map((to, index) => ({
|
||||
from: [waypoints[index]?.lat, waypoints[index]?.lng],
|
||||
to: [to.lat, to.lng],
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
drivingText: '10 min',
|
||||
@@ -71,6 +85,13 @@ vi.mock('../Map/RouteCalculator', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// CW-08: a walk's stops are read for the names of a transit leg's two ends.
|
||||
const mockTourDetail = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../repo/tourRepo', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
|
||||
return { ...actual, tourRepo: { ...actual.tourRepo, detail: mockTourDetail } };
|
||||
});
|
||||
|
||||
// PlaceAvatar needs IntersectionObserver
|
||||
class MockIO {
|
||||
observe = vi.fn();
|
||||
@@ -160,7 +181,14 @@ describe('DayPlanSidebar', () => {
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
|
||||
const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false };
|
||||
const times = {
|
||||
walkingMinutes: 60,
|
||||
breakMinutes: 35,
|
||||
plannedTotalMinutes: 95,
|
||||
manuallyOverridden: false,
|
||||
walk: null,
|
||||
atLeast: false,
|
||||
};
|
||||
const props = makeDefaultProps({
|
||||
days: [day],
|
||||
places: [place],
|
||||
@@ -189,6 +217,22 @@ describe('DayPlanSidebar', () => {
|
||||
);
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override');
|
||||
|
||||
// CW-02: a city walk whose stay is not entered yet plans at least its known total.
|
||||
view.rerender(
|
||||
<DayPlanSidebar
|
||||
{...{
|
||||
...props,
|
||||
plannedTourDurations: new Map([
|
||||
[place.id, { ...times, breakMinutes: null, plannedTotalMinutes: 135, atLeast: true }],
|
||||
]),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const walkTotal = screen.getByTestId('trip-plan-tour-planned-duration');
|
||||
expect(walkTotal).toHaveTextContent('Planned: ≥ 2 h 15 min');
|
||||
expect(walkTotal).toHaveAccessibleName('Planned total duration: at least 2 h 15 min');
|
||||
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
|
||||
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
||||
view.rerender(<DayPlanSidebar {...props} />);
|
||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull();
|
||||
@@ -305,3 +349,214 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// CW-08: the old town walk ends at the bridge, the harbour walk starts at the fish market.
|
||||
const TOURS_ON = [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }];
|
||||
const oldTown = buildPlace({
|
||||
id: 51,
|
||||
name: 'Old town',
|
||||
tour_place_id: 51,
|
||||
lat: 52.52,
|
||||
lng: 13.4,
|
||||
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
|
||||
});
|
||||
const harbour = buildPlace({
|
||||
id: 52,
|
||||
name: 'Harbour',
|
||||
tour_place_id: 52,
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
|
||||
});
|
||||
const cafe = buildPlace({ id: 53, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
|
||||
|
||||
function walkTour(place: { id: number; name: string }, overrides: Partial<TourListItem> = {}): TourListItem {
|
||||
return {
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'walk',
|
||||
distance: 3,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: 90,
|
||||
break_additional_minutes: null,
|
||||
planned_duration_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: true,
|
||||
caution: false,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
...overrides,
|
||||
} as TourListItem;
|
||||
}
|
||||
|
||||
function walkDayProps(extra: { reservations?: Reservation[]; withCafe?: boolean; onPlanTransitLeg?: () => void }) {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignments = {
|
||||
'10': [
|
||||
buildAssignment({
|
||||
id: 201,
|
||||
day_id: 10,
|
||||
order_index: 0,
|
||||
tour_place_id: 51,
|
||||
place: { ...oldTown, place_time: '10:00' },
|
||||
}),
|
||||
buildAssignment({
|
||||
id: 202,
|
||||
day_id: 10,
|
||||
order_index: 1,
|
||||
tour_place_id: 52,
|
||||
place: { ...harbour, place_time: '13:00' },
|
||||
}),
|
||||
...(extra.withCafe ? [buildAssignment({ id: 203, day_id: 10, order_index: 2, place: cafe })] : []),
|
||||
],
|
||||
};
|
||||
const tours = [walkTour(oldTown), walkTour(harbour)];
|
||||
return makeDefaultProps({
|
||||
days: [day],
|
||||
places: [oldTown, harbour, cafe],
|
||||
assignments,
|
||||
selectedDayId: 10,
|
||||
routeShown: true,
|
||||
tourPlaceIds: new Set([51, 52]),
|
||||
plannedTourDurations: new Map(tours.map((tour) => [tour.place_id, { ...tourPlannedTimes(tour), tour }])),
|
||||
reservations: extra.reservations ?? [],
|
||||
onPlanTransitLeg: extra.onPlanTransitLeg,
|
||||
});
|
||||
}
|
||||
|
||||
/** A journey saved from the old town walk's end to the harbour walk's start, arriving at `arrives`. */
|
||||
function savedJourney(arrives: string): Reservation {
|
||||
return {
|
||||
...buildReservation({
|
||||
id: 300,
|
||||
type: 'transit',
|
||||
title: 'Old bridge → Fish market',
|
||||
reservation_time: '2025-06-01T12:20',
|
||||
reservation_end_time: `2025-06-01T${arrives}`,
|
||||
day_id: 10,
|
||||
}),
|
||||
endpoints: [
|
||||
{
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: 'Old bridge',
|
||||
lat: 52.51,
|
||||
lng: 13.42,
|
||||
local_date: '2025-06-01',
|
||||
local_time: '12:20',
|
||||
},
|
||||
{
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: 'Fish market',
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
local_date: '2025-06-01',
|
||||
local_time: arrives,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
duration: 1800,
|
||||
transfers: 0,
|
||||
legs: [{ mode: 'TRAM', line: 'M1', duration: 1500 }],
|
||||
},
|
||||
},
|
||||
} as unknown as Reservation;
|
||||
}
|
||||
|
||||
function contextMenu() {
|
||||
return within(document.querySelector('[style*="z-index: 999999"]') as HTMLElement);
|
||||
}
|
||||
|
||||
describe('DayPlanSidebar, public transit between two city walks (CW-08)', () => {
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: TOURS_ON });
|
||||
mockTourDetail.mockImplementation((_tripId: number, placeId: number) =>
|
||||
Promise.resolve({
|
||||
tour: walkTour(placeId === 51 ? oldTown : harbour),
|
||||
waypoints:
|
||||
placeId === 51
|
||||
? [
|
||||
{ lat: 52.52, lng: 13.4, role: 'start', sequence: 0, key: 'a1', name: 'Town hall', stay_minutes: 30 },
|
||||
{ lat: 52.51, lng: 13.42, role: 'end', sequence: 1, key: 'a2', name: 'Old bridge', stay_minutes: 15 },
|
||||
]
|
||||
: [
|
||||
{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0, key: 'b1', name: 'Fish market' },
|
||||
{ lat: 52.49, lng: 13.46, role: 'end', sequence: 1, key: 'b2', name: 'Lighthouse' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('CW-08: the connector between two walks opens the search from the last stop to the first, when the first walk ends', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg })} />);
|
||||
|
||||
await user.click(await screen.findByLabelText('Change travel mode'));
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||
|
||||
// The walk's end at 10:00 plus 90 min walking and 45 min of stays; nothing is saved.
|
||||
await waitFor(() =>
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
|
||||
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(mockTourDetail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('CW-08: a leg from a walk to a place keeps the search as for any leg, and reads no stops', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, withCafe: true })} />);
|
||||
|
||||
const connectors = await screen.findAllByLabelText('Change travel mode');
|
||||
expect(connectors).toHaveLength(2);
|
||||
await user.click(connectors[1]);
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: '', lat: 52.49, lng: 13.46 },
|
||||
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
|
||||
time: null,
|
||||
});
|
||||
expect(mockTourDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CW-08: a journey saved at the walks’ ends adds no walk of nothing on either side of it', async () => {
|
||||
render(<DayPlanSidebar {...walkDayProps({ withCafe: true, reservations: [savedJourney('12:50')] })} />);
|
||||
|
||||
// The only leg left is the one from the harbour walk to the cafe; the journey covers the rest.
|
||||
await screen.findByLabelText('Change travel mode');
|
||||
expect(screen.getAllByLabelText('Change travel mode')).toHaveLength(1);
|
||||
expect(screen.queryByTestId('transit-walk-late')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CW-08: a saved journey that now arrives after the next walk starts says so, and changes nothing', () => {
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, reservations: [savedJourney('13:10')] })} />);
|
||||
|
||||
expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||
expect(onPlanTransitLeg).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,9 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallba
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
|
||||
import { type PickedPlace } from './TransitSearchPanel'
|
||||
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
|
||||
import type { PlannedTransitLeg } from './transitLeg'
|
||||
import { useTransitLegPlanner } from './useTransitLegPlanner'
|
||||
import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
|
||||
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
||||
import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias'
|
||||
import { routeLeg, type LegEnd } from '../Map/dayRouteLeg'
|
||||
@@ -161,8 +162,9 @@ export interface DayPlanSidebarProps {
|
||||
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
|
||||
* the leg's origin and destination endpoints plus the origin's departure time,
|
||||
* so "public transport" becomes an option on any connector, not just the day header.
|
||||
* Between two city walks it carries what the search says of them (CW-08).
|
||||
*/
|
||||
onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void
|
||||
onPlanTransitLeg?: (leg: PlannedTransitLeg) => void
|
||||
/** Opens the journey view for a saved transit entry (#1065). */
|
||||
onOpenTransit?: (reservation: Reservation) => void
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
@@ -789,6 +791,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
for (let i = 0; i < run.length - 1; i++) {
|
||||
const from = run[i]
|
||||
const to = run[i + 1]
|
||||
// A booking whose station is the stop itself leaves a leg that goes nowhere, as on the phone (CW-08).
|
||||
if ((!from.isPlace || !to.isPlace) && from.lat === to.lat && from.lng === to.lng) continue
|
||||
const mode = resolveLegMode(from, to, dfMode)
|
||||
tasks.push(async () => {
|
||||
const seg = await legBetween(from, to, dayId, mode)
|
||||
@@ -1405,26 +1409,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})
|
||||
}
|
||||
|
||||
// Coordinates back to names for the transit search, over every located place,
|
||||
// hotel and booking endpoint on the trip (see transitLeg.ts).
|
||||
const transitNameIndex = useMemo(
|
||||
() => buildTransitNameIndex(assignments, accommodations, reservations),
|
||||
[assignments, accommodations, reservations],
|
||||
)
|
||||
|
||||
// The extra connector-menu entry (#1281 follow-up): search public transit for this
|
||||
// leg instead of drawing a road route. Only when a handler is wired (day has dates).
|
||||
const transitLegPlanner = (dayId: number, seg?: RouteSegment) => {
|
||||
if (!onPlanTransitLeg) return undefined
|
||||
const leg = buildTransitLeg(seg, dayId, transitNameIndex, assignments, reservations)
|
||||
if (!leg) return undefined
|
||||
return () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time })
|
||||
// The extra connector-menu entry (#1281 follow-up): public transit for this leg while a handler is wired (day has
|
||||
// dates); between two city walks from the end of the one it leaves, `originId`, as two can end at one spot (CW-08).
|
||||
const transitLegs = useTransitLegPlanner({ tripId, assignments, accommodations, reservations, places, toursEnabled,
|
||||
onPlan: onPlanTransitLeg, tourOf: placeId => props.plannedTourDurations?.get(placeId)?.tour })
|
||||
const transitLegPlanner = (dayId: number, seg?: RouteSegment, originId?: number) => {
|
||||
const leg = transitLegs.legFor(dayId, seg)
|
||||
return leg ? () => transitLegs.plan(dayId, leg, originId) : undefined
|
||||
}
|
||||
|
||||
// Open the mode menu at the clicked connector: every route profile, the optional
|
||||
// "public transport" entry, plus a "use day default" entry that clears the override.
|
||||
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
|
||||
ctxMenu.open(e, legModeMenu(mode => setLegMode(assignmentId, dayId, mode), transitLegPlanner(dayId, seg)))
|
||||
ctxMenu.open(e, legModeMenu(m => setLegMode(assignmentId, dayId, m), transitLegPlanner(dayId, seg, assignmentId)))
|
||||
}
|
||||
|
||||
// Set the mode of the leg ENTERING this stop (a booking arrival or the morning
|
||||
@@ -2033,7 +2030,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
|
||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} />
|
||||
{assignment.notes && (
|
||||
// Day-specific note on this stop (#2163) — one muted
|
||||
// caption line so the timeline shows the note exists
|
||||
@@ -2359,6 +2356,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<WalkLateLine late={transitLegs.lateBefore(day, res, merged.slice(idx + 1))} />
|
||||
</div>
|
||||
{transitMeta && (() => {
|
||||
const expanded = expandedTransitIds.has(res.id)
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { isCarrierTransport } from '../../utils/dayMerge'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { WalkLateLine, WalkTransitNote } from '../Tours/walk/WalkTransitNote'
|
||||
import { lateForWalk, type WalkLate, type WalkTransitContext } from '../Tours/walk/walkTransit'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
@@ -314,7 +316,7 @@ function LineBadge({ leg }: { leg: TransitLeg }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: {
|
||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, late, t }: {
|
||||
it: TransitItinerary
|
||||
tzFrom: string
|
||||
tzTo: string
|
||||
@@ -323,6 +325,8 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
||||
onToggle: () => void
|
||||
onAdd: () => void
|
||||
adding: boolean
|
||||
/** CW-08: the next city walk this connection arrives too late for. */
|
||||
late: WalkLate | null
|
||||
t: (k: string, p?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
|
||||
@@ -352,6 +356,7 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<WalkLateLine late={late} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
@@ -460,9 +465,11 @@ interface TransitSearchPanelProps {
|
||||
initialTo?: PickedPlace | null
|
||||
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
|
||||
initialTime?: string | null
|
||||
/** CW-08: the two city walks the leg runs between; said only while the search is for their day. */
|
||||
walk?: WalkTransitContext | null
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null, walk = null }: TransitSearchPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -470,7 +477,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
|
||||
const [to, setTo] = useState<PickedPlace | null>(initialTo)
|
||||
const [time, setTime] = useState(initialTime || '09:00')
|
||||
const walkHere = walk?.dayId === day.id ? walk : null
|
||||
// Between two walks the time is when the first one ends, never one made up: unknown, it is chosen.
|
||||
const [time, setTime] = useState(initialTime || (walkHere ? '' : '09:00'))
|
||||
const [arriveBy, setArriveBy] = useState(false)
|
||||
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
|
||||
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
|
||||
@@ -629,6 +638,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
|
||||
const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
|
||||
const canSearch = !!from && !!to && !!time && !loading
|
||||
|
||||
const segBtn = (active: boolean): React.CSSProperties => ({
|
||||
padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
|
||||
@@ -639,6 +649,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
|
||||
<WalkTransitNote walk={walkHere} />
|
||||
{/* from / to — stacked tight on mobile, swap button on desktop only */}
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
|
||||
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
|
||||
@@ -707,9 +718,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={search}
|
||||
disabled={!from || !to || loading}
|
||||
disabled={!canSearch}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: canSearch ? 'pointer' : 'default', fontFamily: 'inherit', opacity: canSearch ? 1 : 0.55, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||
>
|
||||
<Search size={14} strokeWidth={2.2} />
|
||||
{loading ? t('transit.searching') : t('transit.search')}
|
||||
@@ -743,6 +754,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
|
||||
onAdd={() => addItinerary(it, idx)}
|
||||
adding={addingIdx === idx}
|
||||
late={lateForWalk(walkHere, day.date ?? null, { date: dateYMDInTz(it.endTime, tzTo), time: timeHHmmInTz(it.endTime, tzTo) })}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// CW-08: the transit search between two city walks, opened from the day plan's
|
||||
// connector with the first walk's end and the next one's start. The mocks are the
|
||||
// ones TransitSearchPanel.test.tsx renders the panel with.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { buildDay, buildPlace, buildUser } from '../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { WalkTransitContext } from '../Tours/walk/walkTransit';
|
||||
import TransitSearchPanel from './TransitSearchPanel';
|
||||
|
||||
const { transitApiMock } = vi.hoisted(() => ({ transitApiMock: { geocode: vi.fn(), plan: vi.fn() } }));
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as object;
|
||||
return { ...actual, transitApi: transitApiMock };
|
||||
});
|
||||
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) }));
|
||||
|
||||
const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' });
|
||||
const OLD_BRIDGE = { name: 'Old bridge', lat: 52.51, lng: 13.42 };
|
||||
const FISH_MARKET = { name: 'Fish market', lat: 52.5, lng: 13.45 };
|
||||
const WALK: WalkTransitContext = {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
};
|
||||
|
||||
// Berlin, summer time (UTC+2): a tram that departs 12:20 and arrives at `arrives` (UTC).
|
||||
function itinerary(arrives: string) {
|
||||
const at = (time: string) => ({ lat: 52.505, lng: 13.43, time, scheduledTime: null, track: null });
|
||||
return {
|
||||
startTime: '2025-06-01T10:20:00Z',
|
||||
endTime: `2025-06-01T${arrives}:00Z`,
|
||||
duration: 1800,
|
||||
transfers: 0,
|
||||
walkSeconds: 240,
|
||||
legs: [
|
||||
{
|
||||
mode: 'TRAM',
|
||||
from: { ...at('2025-06-01T10:20:00Z'), name: 'START' },
|
||||
to: { ...at(`2025-06-01T${arrives}:00Z`), name: 'END' },
|
||||
duration: 1800,
|
||||
distance: null,
|
||||
headsign: 'Harbour',
|
||||
line: 'M1',
|
||||
lineColor: null,
|
||||
lineTextColor: null,
|
||||
agency: 'BVG',
|
||||
intermediateStops: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(walk: WalkTransitContext | null, initialTime: string | null = walk?.ends ?? null) {
|
||||
const onAdd = vi.fn().mockResolvedValue({});
|
||||
render(
|
||||
<TransitSearchPanel
|
||||
day={day}
|
||||
days={[day]}
|
||||
places={[buildPlace({ id: 1, name: 'Old town', lat: 52.52, lng: 13.4 })]}
|
||||
onAdd={onAdd}
|
||||
initialFrom={OLD_BRIDGE}
|
||||
initialTo={FISH_MARKET}
|
||||
initialTime={initialTime}
|
||||
walk={walk}
|
||||
/>
|
||||
);
|
||||
return { onAdd };
|
||||
}
|
||||
|
||||
const timeField = () => screen.getByPlaceholderText('00:00') as HTMLInputElement;
|
||||
const searchButton = () => screen.getByRole('button', { name: /^Search$/ });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as never);
|
||||
});
|
||||
|
||||
describe('TransitSearchPanel between two city walks (CW-08)', () => {
|
||||
it('says when the first walk ends and the next starts, and departs from the last stop then', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||
renderPanel(WALK);
|
||||
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||
const [from, to] = screen.getAllByPlaceholderText('Search stop or station…') as HTMLInputElement[];
|
||||
expect(from?.value).toBe('Old bridge');
|
||||
expect(to?.value).toBe('Fish market');
|
||||
expect(timeField().value).toBe('12:15');
|
||||
|
||||
await user.click(searchButton());
|
||||
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||
// 12:15 in Berlin (UTC+2), from the walk's end and to the next one's start.
|
||||
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({
|
||||
from: '52.51,13.42',
|
||||
to: '52.5,13.45',
|
||||
time: '2025-06-01T10:15:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a connection that arrives after the next walk starts, and only that one', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('11:10'), itinerary('10:50')] });
|
||||
renderPanel(WALK);
|
||||
|
||||
await user.click(searchButton());
|
||||
await screen.findByText(/12:20\D+13:10/);
|
||||
const late = screen.getAllByTestId('transit-walk-late');
|
||||
expect(late).toHaveLength(1);
|
||||
expect(late[0]).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||
expect(screen.getByText(/12:20\D+13:10/).closest('button')).toContainElement(late[0] ?? null);
|
||||
});
|
||||
|
||||
it('says an end that leaves a time out is the earliest', () => {
|
||||
renderPanel({ ...WALK, endsAtLeast: true });
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'Old town ends at 12:15 at the earliest; not all of its times are known yet.'
|
||||
);
|
||||
});
|
||||
|
||||
it('says a walk that ends after midnight does so, and makes up no time on this day', () => {
|
||||
renderPanel({ ...WALK, ends: null, endsNextDay: true, endsAtLeast: true });
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'Old town only ends after midnight, so choose a departure time.'
|
||||
);
|
||||
expect(timeField().value).toBe('');
|
||||
});
|
||||
|
||||
it('makes up no time without an end, and waits for one to be chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||
renderPanel({ ...WALK, ends: null, nextStarts: null });
|
||||
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'When Old town ends is not known yet, so choose a departure time.'
|
||||
);
|
||||
expect(screen.queryByTestId('transit-walk-next')).not.toBeInTheDocument();
|
||||
expect(timeField().value).toBe('');
|
||||
expect(searchButton()).toBeDisabled();
|
||||
|
||||
await user.type(timeField(), '1230');
|
||||
await user.tab();
|
||||
expect(searchButton()).toBeEnabled();
|
||||
await user.click(searchButton());
|
||||
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({ time: '2025-06-01T10:30:00.000Z' });
|
||||
});
|
||||
|
||||
it('on another day than the walks, speaks of them no more and departs at the usual time', () => {
|
||||
renderPanel({ ...WALK, dayId: 11 }, null);
|
||||
expect(screen.queryByTestId('transit-walk-end')).not.toBeInTheDocument();
|
||||
expect(timeField().value).toBe('09:00');
|
||||
});
|
||||
|
||||
it('adds a connection only once it is opened and taken, between the two stops', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('10:50')] });
|
||||
const { onAdd } = renderPanel(WALK);
|
||||
|
||||
await user.click(searchButton());
|
||||
await user.click(await screen.findByText(/12:20\D+12:50/));
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
await user.click(await screen.findByRole('button', { name: 'Add to day' }));
|
||||
|
||||
await waitFor(() => expect(onAdd).toHaveBeenCalledTimes(1));
|
||||
const payload = onAdd.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({ type: 'transit', title: 'Old bridge → Fish market', day_id: 10 });
|
||||
expect(payload.endpoints.map((point: { name: string }) => point.name)).toEqual(['Old bridge', 'Fish market']);
|
||||
});
|
||||
});
|
||||
@@ -519,6 +519,15 @@ describe('TransportModal', () => {
|
||||
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-023b: a leg between two city walks opens the search with what it says of them (CW-08)', () => {
|
||||
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as Day[];
|
||||
const walk = { dayId: 10, fromWalk: 'Old town', toWalk: 'Harbour', ends: '12:15', endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' };
|
||||
const transitPrefill = { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: { name: 'Fish market', lat: 52.5, lng: 13.45 }, time: '12:15', walk };
|
||||
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated transitPrefill={transitPrefill} places={[]} accommodations={[]} />);
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => {
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01' }),
|
||||
|
||||
@@ -19,7 +19,8 @@ import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
|
||||
import TransitSearchPanel from './TransitSearchPanel'
|
||||
import type { TransitPrefill } from './transitLeg'
|
||||
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
|
||||
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell'
|
||||
import { INPUT, READONLY_BOX, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
|
||||
@@ -70,7 +71,7 @@ interface TransportModalProps {
|
||||
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
|
||||
tripHasDates?: boolean
|
||||
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
|
||||
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
|
||||
transitPrefill?: TransitPrefill | null
|
||||
/** Trip members + guests, for the traveler picker (#1517). */
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
@@ -304,6 +305,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
initialFrom={transitPrefill?.from ?? null}
|
||||
initialTo={transitPrefill?.to ?? null}
|
||||
initialTime={transitPrefill?.time ?? null}
|
||||
walk={transitPrefill?.walk ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,14 +83,12 @@ export function stayFormFrom<P>(
|
||||
}
|
||||
|
||||
/**
|
||||
* The places a stay can be at (no GPX tracks, but always the one already picked),
|
||||
* The places a stay can be at (no GPX tracks or tours, but always the one already picked),
|
||||
* narrowed to a category when one is chosen.
|
||||
*/
|
||||
export function stayPlaceChoices<P extends { id: number; route_geometry?: string | null; category_id?: number | null }>(
|
||||
places: P[],
|
||||
placeId: number | string | null | undefined,
|
||||
categoryId: number | null
|
||||
): P[] {
|
||||
export function stayPlaceChoices<
|
||||
P extends { id: number; route_geometry?: string | null; tour_place_id?: number | null; category_id?: number | null },
|
||||
>(places: P[], placeId: number | string | null | undefined, categoryId: number | null): P[] {
|
||||
const offered = stayPlaces<P>(places, placeId);
|
||||
return categoryId != null ? offered.filter((p) => p.category_id === categoryId) : offered;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
|
||||
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
|
||||
|
||||
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-006
|
||||
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-007
|
||||
|
||||
const visit = (id: number, dayId: number, name: string, lat: number, lng: number, placeTime?: string) =>
|
||||
({ id, day_id: dayId, place: { id, name, lat, lng, place_time: placeTime ?? null } }) as unknown as Assignment
|
||||
@@ -69,4 +69,11 @@ describe('buildTransitLeg', () => {
|
||||
expect(buildTransitLeg(undefined, 1, names, assignments, [])).toBeNull()
|
||||
expect(buildTransitLeg({ from: [1, 1] } as unknown as RouteSegment, 1, names, assignments, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITLEG-007: a leg leaving a loop tour does not depart when the tour starts (CW-08)', () => {
|
||||
// A tour's place sits at its start; a loop ends there too, but its time is when it starts.
|
||||
const loop = { ...visit(31, 3, 'Old town loop', 52.52, 13.4, '10:00'), tour_place_id: 31 } as Assignment
|
||||
const tours = { '3': [loop, visit(32, 3, 'Harbour', 52.5, 13.45)] }
|
||||
expect(buildTransitLeg(seg([52.52, 13.4], [52.5, 13.45]), 3, buildTransitNameIndex(tours, [], []), tours, [])?.time).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
|
||||
import type { WalkTransitContext } from '../Tours/walk/walkTransit'
|
||||
import type { PickedPlace } from './TransitSearchPanel'
|
||||
|
||||
/** What the transit search is opened with for one leg of a day. */
|
||||
@@ -8,6 +9,20 @@ export interface TransitLeg {
|
||||
time: string | null
|
||||
}
|
||||
|
||||
/** A leg as a view hands it on to open the search: its day and, between two city walks, what it says of them (CW-08). */
|
||||
export interface PlannedTransitLeg extends TransitLeg {
|
||||
dayId: number
|
||||
walk?: WalkTransitContext
|
||||
}
|
||||
|
||||
/** What the search is seeded with: a leg, or the ends of a journey whose route is changed. */
|
||||
export interface TransitPrefill {
|
||||
from?: PickedPlace | null
|
||||
to?: PickedPlace | null
|
||||
time?: string | null
|
||||
walk?: WalkTransitContext | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A connector's endpoints carry only coordinates (RouteSegment.from/to, both
|
||||
* [lat, lng]); resolve them back to the NAMES the transit search shows by indexing
|
||||
@@ -48,7 +63,9 @@ function originDepartureTime(
|
||||
assignments: Record<string, Assignment[]>,
|
||||
reservations: Reservation[],
|
||||
): string | null {
|
||||
for (const a of assignments[String(dayId)] ?? []) {
|
||||
// A tour's place is where it starts and its time when it starts: a leg leaving a loop is no departure then.
|
||||
const stops = (assignments[String(dayId)] ?? []).filter(a => a.tour_place_id == null || a.tour_place_id !== a.place?.id)
|
||||
for (const a of stops) {
|
||||
if (a.place?.lat != null && a.place?.lng != null && `${a.place.lat},${a.place.lng}` === originKey) return a.place.place_time ?? null
|
||||
}
|
||||
for (const r of reservations) {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAssignment, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import type { RouteSegment } from '../../types';
|
||||
import type { MergedItem } from '../../utils/dayMerge';
|
||||
import { useTransitLegPlanner, type TransitLegPlannerDeps } from './useTransitLegPlanner';
|
||||
|
||||
// CW-08: the transit leg behind the desktop day plan and the phone timeline.
|
||||
|
||||
vi.mock('../../repo/tourRepo', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
|
||||
return { ...actual, tourRepo: { ...actual.tourRepo, detail: vi.fn() } };
|
||||
});
|
||||
|
||||
const oldTown = buildPlace({
|
||||
id: 51,
|
||||
name: 'Old town',
|
||||
lat: 52.52,
|
||||
lng: 13.4,
|
||||
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
|
||||
});
|
||||
const harbour = buildPlace({
|
||||
id: 52,
|
||||
name: 'Harbour',
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
|
||||
});
|
||||
const museum = buildPlace({ id: 53, name: 'Museum', lat: 52.47, lng: 13.48, place_time: '9:05' });
|
||||
const cafe = buildPlace({ id: 54, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
|
||||
|
||||
const walkA = buildAssignment({
|
||||
id: 201,
|
||||
day_id: 10,
|
||||
order_index: 0,
|
||||
tour_place_id: 51,
|
||||
place: { ...oldTown, place_time: '10:00' },
|
||||
});
|
||||
const walkB = buildAssignment({
|
||||
id: 202,
|
||||
day_id: 10,
|
||||
order_index: 1,
|
||||
tour_place_id: 52,
|
||||
place: { ...harbour, place_time: '13:00' },
|
||||
});
|
||||
const assignments = {
|
||||
'10': [walkB, walkA],
|
||||
'11': [
|
||||
buildAssignment({ id: 301, day_id: 11, order_index: 0, place: museum }),
|
||||
buildAssignment({ id: 302, day_id: 11, order_index: 1, place: cafe }),
|
||||
],
|
||||
};
|
||||
|
||||
function walk(place: { id: number; name: string }): TourListItem {
|
||||
return {
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'walk',
|
||||
duration: 90,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
} as TourListItem;
|
||||
}
|
||||
const tours = new Map([walk(oldTown), walk(harbour)].map((tour) => [tour.place_id, tour]));
|
||||
|
||||
const BETWEEN_WALKS = { from: [52.51, 13.42], to: [52.5, 13.45] } as unknown as RouteSegment;
|
||||
const MUSEUM_TO_CAFE = { from: [52.47, 13.48], to: [52.48, 13.47] } as unknown as RouteSegment;
|
||||
|
||||
function setup(overrides: Partial<TransitLegPlannerDeps> = {}) {
|
||||
const onPlan = vi.fn();
|
||||
const deps: TransitLegPlannerDeps = {
|
||||
tripId: 1,
|
||||
assignments,
|
||||
accommodations: [],
|
||||
reservations: [],
|
||||
places: [oldTown, harbour, museum, cafe],
|
||||
toursEnabled: true,
|
||||
tourOf: (placeId) => tours.get(placeId),
|
||||
onPlan,
|
||||
...overrides,
|
||||
};
|
||||
const { result, unmount } = renderHook(() => useTransitLegPlanner(deps), { wrapper: TranslationProvider });
|
||||
return { onPlan, result, unmount };
|
||||
}
|
||||
|
||||
const detail = (placeId: number, names: [string, string]) => ({
|
||||
tour: tours.get(placeId),
|
||||
waypoints: names.map((name, sequence) => ({
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
role: 'via',
|
||||
sequence,
|
||||
key: `${placeId}-${sequence}`,
|
||||
name,
|
||||
})),
|
||||
});
|
||||
|
||||
/** Walk reads that answer only once let go, and what each was asked with. */
|
||||
function heldReads() {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
gate.then(() => detail(placeId, ['First', 'Last']) as never)
|
||||
);
|
||||
const signals = () => vi.mocked(tourRepo.detail).mock.calls.map((call) => call[2]?.aborted);
|
||||
// A turn of the event loop lets every read and what follows it settle.
|
||||
const settle = async () => {
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
return { signals, settle };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(tourRepo.detail).mockReset();
|
||||
});
|
||||
|
||||
describe('useTransitLegPlanner', () => {
|
||||
it('offers no leg without a way to open the search, and the leg as for any connector with one', () => {
|
||||
expect(setup({ onPlan: undefined }).result.current.legFor(11, MUSEUM_TO_CAFE)).toBeNull();
|
||||
expect(setup().result.current.legFor(11, MUSEUM_TO_CAFE)).toEqual({
|
||||
from: { name: 'Museum', lat: 52.47, lng: 13.48 },
|
||||
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
|
||||
time: '09:05',
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the search for an ordinary leg at once, with nothing said of walks', () => {
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(11, MUSEUM_TO_CAFE);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(11, leg, 301);
|
||||
expect(onPlan.mock.calls[0]?.[0]).toStrictEqual({ dayId: 11, ...leg });
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('between two walks, opens it from the last stop to the first once both are read, when the first one ends', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
Promise.resolve(
|
||||
detail(placeId, placeId === 51 ? ['Town hall', 'Old bridge'] : ['Fish market', 'Lighthouse']) as never
|
||||
)
|
||||
);
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
|
||||
expect(onPlan).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(onPlan).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
|
||||
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(vi.mocked(tourRepo.detail).mock.calls.map((call) => call[1])).toEqual([51, 52]);
|
||||
});
|
||||
|
||||
it('still opens it where a walk cannot be read, naming that end after its walk', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
placeId === 51
|
||||
? Promise.reject(new Error('Tour not available offline'))
|
||||
: // Route ends without keys stand in for stops this device does not hold.
|
||||
Promise.resolve({
|
||||
tour: tours.get(52),
|
||||
waypoints: [{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0 }],
|
||||
} as never)
|
||||
);
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
|
||||
await waitFor(() => expect(onPlan).toHaveBeenCalledTimes(1));
|
||||
expect(onPlan.mock.calls[0]?.[0]).toMatchObject({
|
||||
from: { name: 'End of Old town' },
|
||||
to: { name: 'Start of Harbour' },
|
||||
time: '12:15',
|
||||
});
|
||||
});
|
||||
|
||||
it('with tours off, treats the leg between two walks as any other', () => {
|
||||
const { onPlan, result } = setup({ toursEnabled: false });
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
expect(onPlan).toHaveBeenCalledWith({ dayId: 10, ...leg });
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens nothing without a way to open the search', () => {
|
||||
const { result } = setup({ onPlan: undefined });
|
||||
const leg = { from: { name: '', lat: 52.51, lng: 13.42 }, to: { name: '', lat: 52.5, lng: 13.45 }, time: null };
|
||||
expect(() => result.current.plan(10, leg, walkA.id)).not.toThrow();
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens only the leg asked for last, and drops the walks still being read for an earlier one', async () => {
|
||||
const reads = heldReads();
|
||||
const { onPlan, result } = setup();
|
||||
const between = result.current.legFor(10, BETWEEN_WALKS);
|
||||
const ordinary = result.current.legFor(11, MUSEUM_TO_CAFE);
|
||||
if (!between || !ordinary) throw new Error('no leg');
|
||||
|
||||
result.current.plan(10, between, walkA.id);
|
||||
result.current.plan(10, between, walkA.id);
|
||||
result.current.plan(11, ordinary, 301);
|
||||
expect(reads.signals()).toEqual([true, true, true, true]);
|
||||
await reads.settle();
|
||||
|
||||
expect(onPlan).toHaveBeenCalledTimes(1);
|
||||
expect(onPlan).toHaveBeenCalledWith({ dayId: 11, ...ordinary });
|
||||
});
|
||||
|
||||
it('opens nothing once the view is gone before the walks are read', async () => {
|
||||
const reads = heldReads();
|
||||
const { onPlan, result, unmount } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
expect(reads.signals()).toEqual([false, false]);
|
||||
unmount();
|
||||
expect(reads.signals()).toEqual([true, true]);
|
||||
await reads.settle();
|
||||
|
||||
expect(onPlan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says when a saved journey now arrives after the walk it leads to starts, while tours are on', () => {
|
||||
const journey = buildReservation({ id: 300, type: 'transit', reservation_end_time: '2025-06-01T13:10' });
|
||||
const following = [{ type: 'place', sortKey: 1, data: walkB }] as MergedItem[];
|
||||
const day = { date: '2025-06-01' };
|
||||
expect(setup().result.current.lateBefore(day, journey, following)).toEqual({ walk: 'Harbour', time: '13:00' });
|
||||
expect(setup({ toursEnabled: false }).result.current.lateBefore(day, journey, following)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation, RouteSegment } from '../../types';
|
||||
import type { MergedItem } from '../../utils/dayMerge';
|
||||
import { projectDayItinerary } from '../Map/dayTourProjection';
|
||||
import { readWalkStations } from '../Tours/walk/walkStationsRead';
|
||||
import { journeyLate, walkPairAt, walkTransitLeg, type TourOf, type WalkLate } from '../Tours/walk/walkTransit';
|
||||
import { buildTransitLeg, buildTransitNameIndex, type PlannedTransitLeg, type TransitLeg } from './transitLeg';
|
||||
|
||||
export interface TransitLegPlannerDeps {
|
||||
tripId: number | string;
|
||||
assignments: AssignmentsMap;
|
||||
accommodations: Accommodation[];
|
||||
reservations: Reservation[];
|
||||
places: Place[];
|
||||
toursEnabled: boolean;
|
||||
tourOf: TourOf;
|
||||
/** Opens the search for a leg; without it no leg offers one, as on a trip without dates. */
|
||||
onPlan: ((leg: PlannedTransitLeg) => void) | undefined;
|
||||
}
|
||||
|
||||
/** A walk's stops, or null where this device cannot read them. */
|
||||
async function stopsOrNull(tripId: number | string, placeId: number, signal: AbortSignal) {
|
||||
try {
|
||||
return await readWalkStations(tripId, placeId, signal);
|
||||
} catch {
|
||||
// They only name the leg's two ends: without them it opens named after the walks.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public transit for one leg of a day (#2398), behind the desktop day plan and
|
||||
* the phone timeline alike: the leg's two ends and the time its origin is
|
||||
* planned for. Between two city walks (CW-08) it runs from the first walk's
|
||||
* last stop to the next one's first, departing when the first one ends, and a
|
||||
* saved journey that now arrives after the next walk starts says so. Nothing
|
||||
* is saved here: the search opens, and only the connection picked there is
|
||||
* added to the day. Only the last leg asked for opens it, and none after the
|
||||
* view is gone.
|
||||
*/
|
||||
export function useTransitLegPlanner({
|
||||
tripId,
|
||||
assignments,
|
||||
accommodations,
|
||||
reservations,
|
||||
places,
|
||||
toursEnabled,
|
||||
tourOf,
|
||||
onPlan,
|
||||
}: TransitLegPlannerDeps) {
|
||||
const { t } = useTranslation();
|
||||
// Coordinates back to names, over every located place, hotel and booking endpoint of the trip.
|
||||
const names = useMemo(
|
||||
() => buildTransitNameIndex(assignments, accommodations, reservations),
|
||||
[assignments, accommodations, reservations]
|
||||
);
|
||||
|
||||
const legFor = useCallback(
|
||||
(dayId: number, seg?: RouteSegment): TransitLeg | null =>
|
||||
onPlan ? buildTransitLeg(seg, dayId, names, assignments, reservations) : null,
|
||||
[onPlan, names, assignments, reservations]
|
||||
);
|
||||
|
||||
// The walks' stops being read for the leg asked for last; a later leg, or leaving the view, drops them.
|
||||
const pending = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => pending.current?.abort(), []);
|
||||
|
||||
/** Opens the search for a leg, told by the entry it leaves where the connector carries one. */
|
||||
const plan = useCallback(
|
||||
(dayId: number, leg: TransitLeg, originId?: number) => {
|
||||
if (!onPlan) return;
|
||||
pending.current?.abort();
|
||||
const day = [...(assignments[String(dayId)] ?? [])].sort((a, b) => a.order_index - b.order_index);
|
||||
const pair = toursEnabled ? walkPairAt(projectDayItinerary(day, true, places), leg, tourOf, originId) : null;
|
||||
if (!pair) {
|
||||
onPlan({ dayId, ...leg });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
pending.current = controller;
|
||||
// The stops name the ends; read from this device's copy when offline, so the search waits only for them.
|
||||
void Promise.all([
|
||||
stopsOrNull(tripId, pair.first.item.placeId, controller.signal),
|
||||
stopsOrNull(tripId, pair.next.item.placeId, controller.signal),
|
||||
]).then(([first, next]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
onPlan({ dayId, ...walkTransitLeg({ leg, dayId, pair, stops: { first, next }, places, t }) });
|
||||
});
|
||||
},
|
||||
[onPlan, assignments, toursEnabled, places, tourOf, tripId, t]
|
||||
);
|
||||
|
||||
/** A saved journey on a day that arrives after the walk it leads to starts; null otherwise. */
|
||||
const lateBefore = useCallback(
|
||||
(day: Pick<Day, 'date'>, reservation: Reservation, following: readonly MergedItem[]): WalkLate | null =>
|
||||
toursEnabled ? journeyLate({ reservation, following, date: day.date ?? null, tourOf }) : null,
|
||||
[toursEnabled, tourOf]
|
||||
);
|
||||
|
||||
return { legFor, plan, lateBefore };
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { paddleBench } from '../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useTourVisitStore } from '../../store/tourVisitStore';
|
||||
import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
|
||||
@@ -117,6 +118,78 @@ describe('PAD-09 the check bench on a tour row', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CW-02 a city walk's time on its day row", () => {
|
||||
const walk: TourListItem = {
|
||||
...tour,
|
||||
tour_type: 'walk',
|
||||
duration: 45,
|
||||
break_additional_minutes: 15,
|
||||
max_hiking_difficulty: null,
|
||||
station_count: 3,
|
||||
station_stay_minutes: 120,
|
||||
station_stays_unknown: 0,
|
||||
};
|
||||
|
||||
it('shows the walking time and the stays as the planned total, its breaks left out', () => {
|
||||
render(<TourDayRowPills tour={walk} />);
|
||||
const time = screen.getByTestId('trip-plan-tour-time');
|
||||
expect(time).toHaveTextContent('2 h 45 min');
|
||||
expect(time).toHaveAccessibleName('Walking: 45 min, Stays: 2 h, Planned total duration: 2 h 45 min');
|
||||
});
|
||||
|
||||
it('says the total is at least what is known while a stay is not entered', () => {
|
||||
render(<TourDayRowPills tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} />);
|
||||
const time = screen.getByTestId('trip-plan-tour-time');
|
||||
expect(time).toHaveTextContent('≥ 2 h 15 min');
|
||||
expect(time).toHaveAccessibleName(
|
||||
'Walking: 45 min, Stays: at least 1 h 30 min, Planned total duration: at least 2 h 15 min'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the same total among the facts of a phone row, and no breaks', () => {
|
||||
render(<TourDayRowFacts tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} mobile />);
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('≥ 2 h 15 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking: 45 min');
|
||||
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CW-03 a city walk's progress among the facts of a day row", () => {
|
||||
const walk: TourListItem = { ...tour, tour_type: 'walk', max_hiking_difficulty: null, station_count: 5 };
|
||||
|
||||
beforeEach(() =>
|
||||
useTourVisitStore.setState({
|
||||
tripId: '4',
|
||||
byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'skipped' } },
|
||||
})
|
||||
);
|
||||
|
||||
it.each([false, true])('shows the stops visited on that day, a tap naming them whole (mobile=%s)', (mobile) => {
|
||||
const openRow = vi.fn();
|
||||
render(
|
||||
<div onClick={openRow}>
|
||||
<TourDayRowFacts tour={walk} assignmentId={9} mobile={mobile} />
|
||||
</div>
|
||||
);
|
||||
const progress = screen.getByTestId('plan-walk-progress');
|
||||
expect(progress).toHaveTextContent('1/5');
|
||||
expect(progress).toHaveAccessibleName('1 of 5 visited');
|
||||
fireEvent.click(progress);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('1 of 5 visited');
|
||||
expect(openRow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows none without a day, or before a stop of that day is marked', () => {
|
||||
render(
|
||||
<>
|
||||
<TourDayRowFacts tour={walk} mobile />
|
||||
<TourDayRowFacts tour={walk} assignmentId={10} mobile />
|
||||
</>
|
||||
);
|
||||
expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the compact facts of a tour list row', () => {
|
||||
const testIds = () =>
|
||||
[...document.querySelectorAll('[data-testid^="plan-tour"], [data-testid="trip-plan-tour-time"]')].map((el) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { ArrowUp, CheckCheck, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, useId, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -10,7 +10,9 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
import { paddleCheckFact } from './paddle/paddleCheckModel';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { movingTimeLabel } from './tourFacts';
|
||||
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
|
||||
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
|
||||
import { useWalkProgress } from './walk/walkProgress';
|
||||
import { walkStaysText } from './walk/walkTimeFields';
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -163,27 +165,24 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
const times = tourPlannedTimes(tour);
|
||||
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
|
||||
const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes);
|
||||
const total = plannedTotalText(times, t);
|
||||
const staysText = times.walk ? walkStaysText(times.walk, t) : null;
|
||||
const timeParts = [
|
||||
times.walkingMinutes != null
|
||||
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
: null,
|
||||
walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null,
|
||||
times.breakMinutes
|
||||
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
|
||||
: null,
|
||||
times.plannedTotalMinutes != null
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
// CW-02: a city walk's stays, which its total adds to the walking time.
|
||||
staysText ? `${t('tours.walk.stays')}: ${staysText}` : null,
|
||||
total
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${total}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
: null,
|
||||
].filter((part): part is string => part !== null);
|
||||
const check = paddleCheckOf(tour, t);
|
||||
const time = shownMinutes != null && (
|
||||
<TourDayRowPill
|
||||
key="time"
|
||||
icon={Clock}
|
||||
value={formatPlannedTourDuration(shownMinutes)}
|
||||
label={timeParts.join(', ')}
|
||||
testId="trip-plan-tour-time"
|
||||
/>
|
||||
const shown = plannedTotalText(times, t, true) ?? walking;
|
||||
const time = shown != null && (
|
||||
<TourDayRowPill key="time" icon={Clock} value={shown} label={timeParts.join(', ')} testId="trip-plan-tour-time" />
|
||||
);
|
||||
const distance = tour.distance != null && (
|
||||
<TourDayRowPill
|
||||
@@ -209,9 +208,19 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
|
||||
return <>{tour.tour_type === 'kayak' ? [time, bench, distance] : [time, distance, ascent]}</>;
|
||||
}
|
||||
|
||||
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
|
||||
export default function TourDayRowFacts({
|
||||
tour,
|
||||
assignmentId,
|
||||
mobile = false,
|
||||
}: {
|
||||
tour: TourListItem;
|
||||
/** The row's day assignment, for a city walk's progress on that day (CW-03). */
|
||||
assignmentId?: number;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
const progress = useWalkProgress(tour, assignmentId);
|
||||
const times = tourPlannedTimes(tour);
|
||||
const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = [];
|
||||
const add = (icon: LucideIcon, value: string, label: string, testId: string) =>
|
||||
@@ -234,10 +243,11 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
t('tours.planner.breaksAdditional'),
|
||||
'trip-plan-tour-breaks'
|
||||
);
|
||||
if (times.plannedTotalMinutes != null)
|
||||
const total = plannedTotalText(times, t, true);
|
||||
if (total)
|
||||
add(
|
||||
Clock,
|
||||
formatPlannedTourDuration(times.plannedTotalMinutes),
|
||||
total,
|
||||
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
|
||||
'trip-plan-tour-planned-duration'
|
||||
);
|
||||
@@ -248,6 +258,15 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
{facts.map((fact) => (
|
||||
<Fact key={fact.testId} {...fact} label={`${fact.label}: ${fact.value}`} mobile={mobile} />
|
||||
))}
|
||||
{progress && (
|
||||
<Fact
|
||||
icon={CheckCheck}
|
||||
value={`${progress.visited}/${progress.total}`}
|
||||
label={t('tours.walk.progress', progress)}
|
||||
testId="plan-walk-progress"
|
||||
mobile={mobile}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import { paddleBench } from '../../../tests/helpers/paddleCheck'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import TourDetailDialog from './TourDetailDialog'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { tourRepo } from '../../repo/tourRepo'
|
||||
|
||||
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
|
||||
@@ -60,6 +61,11 @@ beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
|
||||
})
|
||||
|
||||
// A repository answer one test stubs never reaches the tests after it.
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('TourDetailDialog', () => {
|
||||
it('keeps desktop detail non-modal and returns focus to its Tour row on Escape', async () => {
|
||||
const opener = document.createElement('button')
|
||||
@@ -142,6 +148,85 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CW-02: shows a city walk its walking time, its stays and their total, and no breaks left from another type', async () => {
|
||||
const walk = {
|
||||
...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 45, break_additional_minutes: 15,
|
||||
station_count: 3, station_stay_minutes: 120, station_stays_unknown: 0,
|
||||
}
|
||||
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [] })
|
||||
const view = render(<TourDetailDialog {...defaultProps} tour={walk} />)
|
||||
|
||||
// 45 min of walking, a museum of 90 min and a cafe of 30 min, as the day row says it too.
|
||||
expect(screen.getByLabelText('Walking: 45 min, Stays: 2 h, Planned: 2 h 45 min')).toHaveTextContent('2 h 45 min')
|
||||
expect(screen.getByText('Stays')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Breaks')).not.toBeInTheDocument()
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps} tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} />)
|
||||
expect(screen.getByLabelText('Walking: 45 min, Stays: at least 1 h 30 min, Planned: at least 2 h 15 min'))
|
||||
.toHaveTextContent('≥ 2 h 15 min')
|
||||
// On a phone's three narrow columns the longer values wrap rather than being cut.
|
||||
const stays = screen.getByText('Stays').closest('dt')?.nextElementSibling
|
||||
expect(stays).toHaveTextContent('at least 1 h 30 min')
|
||||
expect(stays).toHaveClass('break-words')
|
||||
expect(stays).not.toHaveClass('truncate')
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps}
|
||||
tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1, planned_duration_minutes: 240 }} />)
|
||||
expect(screen.getByLabelText(
|
||||
'Walking: 45 min, Stays: at least 1 h 30 min, Planned (Manual override): 4 h, Computed: at least 2 h 15 min'
|
||||
)).toHaveTextContent('4 h')
|
||||
expect(screen.getByText('Computed')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('walk-stations')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CW-04: a walk opened from a day it is on keeps that day for its times; opened otherwise, a day can be picked', async () => {
|
||||
const walk = { ...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 20, station_count: 1 }
|
||||
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0, key: 'stop-01', name: 'Museum', stay_minutes: 30, opening_hours: 'Mo-Fr 10:00-18:00' },
|
||||
] })
|
||||
// 2026-10-19 is a Monday: the museum opens at 10:00.
|
||||
const days = [buildDay({ id: 7, date: '2026-10-19' }), buildDay({ id: 8, date: '2026-10-20' })]
|
||||
const assignments = {
|
||||
'7': [buildAssignment({ id: 9, day_id: 7, place_id: 42, assignment_time: '09:00', place })],
|
||||
'8': [buildAssignment({ id: 10, day_id: 8, place_id: 42, assignment_time: '11:00', place })],
|
||||
}
|
||||
const view = render(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} selectedDayId={7} />)
|
||||
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 09:00$/)
|
||||
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('walk-station-conflict'))
|
||||
.toHaveTextContent('Museum is closed at 09:00, the stay would last until 09:30; open 10:00 to 18:00')
|
||||
expect(screen.getByText('Mark visited')).toBeInTheDocument()
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} />)
|
||||
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 09:00$/)
|
||||
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('walk-day-picker'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Day 2, .*start 11:00$/ }))
|
||||
expect(await screen.findByText('Arrival about 11:00, stay until 11:30')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Open 10:00 to 18:00/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CW-11: a walk saved with its stops only says once that its way is planned in the planner, and shows no measures yet', async () => {
|
||||
const pending = {
|
||||
...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, distance: null, elevation_gain: null, elevation_loss: null,
|
||||
station_count: 2, route_pending: true,
|
||||
}
|
||||
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: pending, waypoints: [] })
|
||||
const view = render(<TourDetailDialog {...defaultProps} tour={pending} place={{ ...place, route_geometry: null }} />)
|
||||
|
||||
expect(screen.getByText(
|
||||
'Saved with its stops only. The way between them is planned when the walk is opened in the planner and saved.'
|
||||
)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Distance')).not.toBeInTheDocument()
|
||||
expect(await screen.findByTestId('walk-stations')).toBeInTheDocument()
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps} tour={{ ...pending, route_pending: false }} />)
|
||||
expect(screen.queryByText(/Saved with its stops only/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', has_waypoints: true, duration: 50 }} />)
|
||||
|
||||
|
||||
@@ -58,12 +58,17 @@ import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField }
|
||||
import {
|
||||
formatPlannedTourDuration,
|
||||
hikeSourceBadgeLabel,
|
||||
plannedTotalText,
|
||||
tourPlannedTimes,
|
||||
tourSource,
|
||||
tourWebsitePresentation,
|
||||
type TourPlannedTimes,
|
||||
} from './tourPresentation';
|
||||
import { useTourDayAssignment } from './useTourDayAssignment';
|
||||
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions';
|
||||
import { WalkRoutePendingHint } from './walk/WalkRoutePending';
|
||||
import { WalkStationsCard } from './walk/WalkStations';
|
||||
import { walkTimeFields } from './walk/walkTimeFields';
|
||||
|
||||
interface TourDetailDialogProps extends TourPermissionProps {
|
||||
tour: TourListItem;
|
||||
@@ -166,19 +171,28 @@ function placeStart(place: Place): { lat: number; lng: number } | null {
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */
|
||||
/**
|
||||
* The parts of the time a tour takes, as fields: walking, breaks, the planned
|
||||
* total; a city walk its walking time, its stays and their total, in the
|
||||
* planner's wording (CW-02).
|
||||
*/
|
||||
function tourTimeFields(
|
||||
times: ReturnType<typeof tourPlannedTimes>,
|
||||
times: TourPlannedTimes,
|
||||
t: Translate,
|
||||
tour: Pick<TourListItem, 'tour_type'>
|
||||
): TourMetricField[] {
|
||||
const walking = { label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t) };
|
||||
if (times.walk) {
|
||||
return walkTimeFields({
|
||||
walking,
|
||||
walkingMinutes: times.walkingMinutes,
|
||||
stays: times.walk,
|
||||
manualMinutes: times.manuallyOverridden ? times.plannedTotalMinutes : null,
|
||||
t,
|
||||
});
|
||||
}
|
||||
return [
|
||||
times.walkingMinutes != null
|
||||
? {
|
||||
label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t),
|
||||
value: formatPlannedTourDuration(times.walkingMinutes),
|
||||
}
|
||||
: null,
|
||||
times.walkingMinutes != null ? { ...walking, value: formatPlannedTourDuration(times.walkingMinutes) } : null,
|
||||
times.breakMinutes
|
||||
? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) }
|
||||
: null,
|
||||
@@ -201,19 +215,23 @@ function TourHeadPills({
|
||||
tour,
|
||||
trackColor,
|
||||
timeFields,
|
||||
shownMinutes,
|
||||
times,
|
||||
metrics,
|
||||
}: {
|
||||
tour: TourListItem;
|
||||
trackColor: string;
|
||||
timeFields: TourMetricField[];
|
||||
shownMinutes: number | null;
|
||||
/** The planned total where there is one, as the day row shows it, otherwise the walking time. */
|
||||
times: TourPlannedTimes;
|
||||
/** Measured from the line as it is now, so the head says what the fields below say. */
|
||||
metrics: { distanceKm: number | null; gain: number | null; loss: number | null };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const timeTooltip = timeFields.map((field) => `${field.label}: ${field.value}`).join(', ');
|
||||
const shownTime =
|
||||
plannedTotalText(times, t, true) ??
|
||||
(times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes));
|
||||
const source = tourSource(tour);
|
||||
// The import this tour was planned from, named while it is still in the trip.
|
||||
const derivedFromName = useTripStore((state) =>
|
||||
@@ -257,11 +275,11 @@ function TourHeadPills({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shownMinutes != null && (
|
||||
{shownTime != null && (
|
||||
<Tooltip label={timeTooltip}>
|
||||
<span aria-label={timeTooltip} className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Clock size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
{formatPlannedTourDuration(shownMinutes)}
|
||||
{shownTime}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -369,6 +387,79 @@ function useTourNameEditor(place: Place, onUpdatePlace?: TourDetailDialogProps['
|
||||
return { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName };
|
||||
}
|
||||
|
||||
/** The tour's name in the head; `onUpdatePlace` is given only to someone who may rename it, in place. */
|
||||
function TourNameHeading({
|
||||
place,
|
||||
onUpdatePlace,
|
||||
}: {
|
||||
place: Place;
|
||||
onUpdatePlace?: TourDetailDialogProps['onUpdatePlace'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor(
|
||||
place,
|
||||
onUpdatePlace
|
||||
);
|
||||
if (onUpdatePlace && editingName) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void saveName();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setNameDraft(place.name);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
aria-label={t('places.formName')}
|
||||
className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
|
||||
style={fs(17, 'subtitle')}
|
||||
/>
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveName()}
|
||||
disabled={savingName}
|
||||
aria-label={t('common.save')}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2
|
||||
className="m-0 min-w-0 break-words font-bold leading-snug tracking-[-0.01em] text-content"
|
||||
style={fs(17, 'subtitle')}
|
||||
>
|
||||
{place.name}
|
||||
</h2>
|
||||
{onUpdatePlace && (
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNameDraft(place.name);
|
||||
setEditingName(true);
|
||||
}}
|
||||
aria-label={t('common.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourDetailDialog({
|
||||
tour,
|
||||
place,
|
||||
@@ -405,10 +496,6 @@ export default function TourDetailDialog({
|
||||
const [colorOpen, setColorOpen] = useState(false);
|
||||
const [navigationOpen, setNavigationOpen] = useState(false);
|
||||
const navigationAnchorRef = useRef<HTMLSpanElement>(null);
|
||||
const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor(
|
||||
place,
|
||||
canEdit ? onUpdatePlace : undefined
|
||||
);
|
||||
useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget);
|
||||
|
||||
const heightsHidden = heightsUnknown(tour);
|
||||
@@ -453,66 +540,12 @@ export default function TourDetailDialog({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{canEdit && editingName ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void saveName();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setNameDraft(place.name);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
aria-label={t('places.formName')}
|
||||
className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
|
||||
style={fs(17, 'subtitle')}
|
||||
/>
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveName()}
|
||||
disabled={savingName}
|
||||
aria-label={t('common.save')}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2
|
||||
className="m-0 min-w-0 break-words font-bold leading-snug tracking-[-0.01em] text-content"
|
||||
style={fs(17, 'subtitle')}
|
||||
>
|
||||
{place.name}
|
||||
</h2>
|
||||
{canEdit && onUpdatePlace && (
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNameDraft(place.name);
|
||||
setEditingName(true);
|
||||
}}
|
||||
aria-label={t('common.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TourNameHeading place={place} onUpdatePlace={canEdit ? onUpdatePlace : undefined} />
|
||||
<TourHeadPills
|
||||
tour={tour}
|
||||
trackColor={trackColor}
|
||||
timeFields={timeFields}
|
||||
shownMinutes={times.plannedTotalMinutes ?? times.walkingMinutes}
|
||||
times={times}
|
||||
metrics={{
|
||||
distanceKm: analysis?.distanceKm ?? tour.distance,
|
||||
gain: analysis ? analysis.gain : tour.elevation_gain,
|
||||
@@ -643,6 +676,7 @@ export default function TourDetailDialog({
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
{tour.route_pending && <WalkRoutePendingHint />}
|
||||
{analysis && (
|
||||
<TourDetailMeasures
|
||||
analysis={analysis}
|
||||
@@ -652,6 +686,17 @@ export default function TourDetailDialog({
|
||||
placeId={place.id}
|
||||
/>
|
||||
)}
|
||||
{tour.tour_type === 'walk' && (
|
||||
<WalkStationsCard
|
||||
tour={tour}
|
||||
tripId={place.trip_id}
|
||||
assignment={assignment}
|
||||
canMark={canAssign}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
route={analysis?.routeCoordinates ?? null}
|
||||
/>
|
||||
)}
|
||||
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
|
||||
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
|
||||
|
||||
|
||||
@@ -65,11 +65,17 @@ function sumBadge(
|
||||
): ScopeBadge | null {
|
||||
if (sum.value === null) return null;
|
||||
const partial = sum.known < sum.of;
|
||||
const value = partial ? `≥ ${format(sum.value)}` : format(sum.value);
|
||||
const label = partial
|
||||
? `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`
|
||||
: `${name}: ${value}`;
|
||||
return { key, icon, value, label };
|
||||
const value = partial || sum.atLeast ? `≥ ${format(sum.value)}` : format(sum.value);
|
||||
if (partial)
|
||||
return {
|
||||
key,
|
||||
icon,
|
||||
value,
|
||||
label: `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`,
|
||||
};
|
||||
// CW-02: known for every tour, but a city walk's stay is not entered yet.
|
||||
const said = sum.atLeast ? t('tours.walk.totalAtLeast', { time: format(sum.value) }) : value;
|
||||
return { key, icon, value, label: `${name}: ${said}` };
|
||||
}
|
||||
|
||||
function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance>[1], t: Translate): ScopeBadge[] {
|
||||
@@ -92,7 +98,12 @@ function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance
|
||||
t
|
||||
),
|
||||
sumBadge(
|
||||
{ key: 'planned', icon: CalendarClock, name: t('tours.scope.planned') },
|
||||
// A city walk plans its stays, not breaks (CW-02).
|
||||
{
|
||||
key: 'planned',
|
||||
icon: CalendarClock,
|
||||
name: t(scope.type === 'walk' ? 'tours.scope.plannedWithStays' : 'tours.scope.planned'),
|
||||
},
|
||||
scope.plannedMinutes,
|
||||
formatPlannedTourDuration,
|
||||
t
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../shared/DialogShell';
|
||||
import { TourDayRowPill } from '../TourDayRowFacts';
|
||||
import { RailFacts, TourNotice, type TourMetricField } from '../tourParts';
|
||||
import { formatPlannedTourDuration, tourPlannedTimes } from '../tourPresentation';
|
||||
import { plannedTotalText, tourPlannedTimes } from '../tourPresentation';
|
||||
import { type TourLibrary } from './useTourLibrary';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -30,14 +30,17 @@ const ALL = 'all';
|
||||
|
||||
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
|
||||
function measureBadges(tour: TourLibraryItem, unit: Unit, t: Translate) {
|
||||
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
|
||||
const times = tourPlannedTimes(tour);
|
||||
const total = plannedTotalText(times, t);
|
||||
const badge = plannedTotalText(times, t, true);
|
||||
return [
|
||||
minutes != null && {
|
||||
key: 'time',
|
||||
icon: Clock,
|
||||
value: formatPlannedTourDuration(minutes),
|
||||
label: `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(minutes)}`,
|
||||
},
|
||||
total != null &&
|
||||
badge != null && {
|
||||
key: 'time',
|
||||
icon: Clock,
|
||||
value: badge,
|
||||
label: `${t('tours.planner.plannedTotalDuration')}: ${total}`,
|
||||
},
|
||||
tour.distance != null && {
|
||||
key: 'distance',
|
||||
icon: Ruler,
|
||||
@@ -180,13 +183,13 @@ function ListFilters({ library }: { library: TourLibrary }) {
|
||||
|
||||
/** The facts of the chosen tour as the preview shows them, each only where the tour has it. */
|
||||
function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] {
|
||||
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
|
||||
const total = plannedTotalText(tourPlannedTimes(tour), t);
|
||||
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
|
||||
return [
|
||||
fact(true, t('tours.library.fromTrip'), () => tour.trip.title),
|
||||
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
|
||||
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
|
||||
fact(minutes != null, t('tours.planner.plannedTotalDuration'), () => formatPlannedTourDuration(minutes ?? 0)),
|
||||
fact(total != null, t('tours.planner.plannedTotalDuration'), () => total ?? ''),
|
||||
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
|
||||
formatElevation(tour.elevation_gain ?? 0, unit)
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { tourLibraryRepo } from '../../../repo/tourLibraryRepo';
|
||||
import { TourLibraryDialog } from './TourLibraryDialog';
|
||||
import { useTourLibrary } from './useTourLibrary';
|
||||
|
||||
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-008
|
||||
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-009
|
||||
|
||||
vi.mock('../../Map/MapViewAuto', () => ({
|
||||
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
|
||||
@@ -189,6 +189,31 @@ describe('TourLibraryDialog', () => {
|
||||
expect(screen.queryByTestId('tour-library-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-009: plans a city walk with its stays, at least that much while a stay is not entered (CW-02)', async () => {
|
||||
const walk = item({
|
||||
place_id: 13,
|
||||
name: 'Old town',
|
||||
tour_type: 'walk',
|
||||
duration: 45,
|
||||
break_additional_minutes: 15,
|
||||
station_count: 3,
|
||||
station_stay_minutes: 90,
|
||||
station_stays_unknown: 1,
|
||||
});
|
||||
list.mockResolvedValue({ ...LIST, tours: [walk] });
|
||||
detail.mockResolvedValue({ tour: walk, line: [], waypoint_count: 3 });
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByTestId('tour-rail-library'));
|
||||
const row = await screen.findByTestId('tour-library-row-13');
|
||||
const time = within(row).getByTestId('tour-library-time');
|
||||
expect(time).toHaveTextContent('≥ 2 h 15 min');
|
||||
expect(time).toHaveAccessibleName('Planned total duration: at least 2 h 15 min');
|
||||
fireEvent.click(row);
|
||||
expect(
|
||||
await within(screen.getByTestId('tour-library-preview')).findByText('at least 2 h 15 min')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-008: says when the other trips have no tours', async () => {
|
||||
list.mockResolvedValue({ tours: [], trips: [], truncated: false });
|
||||
render(<Harness />);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { StationDialog } from './StationDialog';
|
||||
import { stationMarkNote, stationRingClass } from './stationMarks';
|
||||
import { StationPills, StationShield } from './stationParts';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
const cathedral: TourPlannerWaypoint = {
|
||||
id: 'p1',
|
||||
lat: 48.1,
|
||||
lng: 11.5,
|
||||
role: 'via',
|
||||
key: 'stop-cathedral',
|
||||
name: 'Cathedral',
|
||||
stayMinutes: 30,
|
||||
importance: 'must',
|
||||
protected: true,
|
||||
openingHours: 'Mo-Su 09:00-18:00',
|
||||
};
|
||||
|
||||
function open(point: TourPlannerWaypoint, fallbackName?: string) {
|
||||
const onSave = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<StationDialog point={point} fallbackName={fallbackName} onSave={onSave} onClose={onClose} />);
|
||||
return { onSave, onClose };
|
||||
}
|
||||
|
||||
const stayField = () => screen.getByLabelText('Stay in minutes');
|
||||
const nameField = () => screen.getByLabelText('Label');
|
||||
|
||||
describe('StationDialog (CW-02, CW-03, CW-10)', () => {
|
||||
it('starts from the station as it is: its name, stay, importance and protection', () => {
|
||||
open(cathedral);
|
||||
expect(screen.getByTestId('station-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Cathedral' })).toBeInTheDocument();
|
||||
expect(nameField()).toHaveValue('Cathedral');
|
||||
expect(stayField()).toHaveValue('30');
|
||||
expect(screen.getByRole('button', { name: 'Must see' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Protected' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('A change proposed later keeps it in the walk.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a station without a name by the row it stands in, or as unnamed', () => {
|
||||
open({ id: 'p2', lat: 48.11, lng: 11.51, role: 'via' }, 'Stop 2');
|
||||
expect(screen.getByRole('heading', { name: 'Stop 2' })).toBeInTheDocument();
|
||||
expect(nameField()).toHaveAttribute('placeholder', 'Stop 2');
|
||||
expect(stayField()).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Regular' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Protected' })).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.change(nameField(), { target: { value: 'Fountain' } });
|
||||
expect(screen.getByRole('heading', { name: 'Fountain' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls a station without a name and without a row name an unnamed stop', () => {
|
||||
open({ id: 'p3', lat: 48.12, lng: 11.52, role: 'end' });
|
||||
expect(screen.getByRole('heading', { name: 'Unnamed stop' })).toBeInTheDocument();
|
||||
expect(nameField()).toHaveAttribute('placeholder', 'What is here');
|
||||
});
|
||||
|
||||
it('takes whole minutes up to a day only, and says so', () => {
|
||||
const { onSave } = open(cathedral);
|
||||
for (const typed of ['abc', '2000', '12.5', '-3']) {
|
||||
fireEvent.change(stayField(), { target: { value: typed } });
|
||||
expect(screen.getByText('Enter whole minutes, up to a day.')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('station-save')).toBeDisabled();
|
||||
}
|
||||
fireEvent.submit(screen.getByTestId('station-dialog').closest('form') as HTMLFormElement);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
fireEvent.change(stayField(), { target: { value: '1440' } });
|
||||
expect(screen.queryByText('Enter whole minutes, up to a day.')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Empty means not known yet; 0 for a point you only pass.')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('station-save')).toBeEnabled();
|
||||
// A submit of the form saves too, and keeps the page where it is.
|
||||
const submitted = fireEvent.submit(screen.getByTestId('station-dialog').closest('form') as HTMLFormElement);
|
||||
expect(submitted).toBe(false);
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ key: 'stop-cathedral', stayMinutes: 1440 })
|
||||
);
|
||||
});
|
||||
|
||||
it('saves the edited station with its key and opening hours, a stay left empty as not known', () => {
|
||||
const { onSave, onClose } = open(cathedral);
|
||||
fireEvent.change(nameField(), { target: { value: ' Old cathedral ' } });
|
||||
fireEvent.change(stayField(), { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Optional' }));
|
||||
expect(screen.getByText('Can be left out when time runs short.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Protected' }));
|
||||
fireEvent.click(screen.getByTestId('station-save'));
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith({
|
||||
id: 'p1',
|
||||
lat: 48.1,
|
||||
lng: 11.5,
|
||||
role: 'via',
|
||||
key: 'stop-cathedral',
|
||||
openingHours: 'Mo-Su 09:00-18:00',
|
||||
name: 'Old cathedral',
|
||||
importance: 'optional',
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves 0 as a point passed through, and normal importance and a blank name as none', () => {
|
||||
const { onSave } = open(cathedral);
|
||||
fireEvent.change(nameField(), { target: { value: ' ' } });
|
||||
fireEvent.change(stayField(), { target: { value: ' 0 ' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Regular' }));
|
||||
expect(screen.getByText('Visited as the walk goes.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('station-save'));
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith({
|
||||
id: 'p1',
|
||||
lat: 48.1,
|
||||
lng: 11.5,
|
||||
role: 'via',
|
||||
key: 'stop-cathedral',
|
||||
openingHours: 'Mo-Su 09:00-18:00',
|
||||
stayMinutes: 0,
|
||||
protected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('cancelling closes the dialog and saves nothing', () => {
|
||||
const { onSave, onClose } = open(cathedral);
|
||||
fireEvent.change(nameField(), { target: { value: 'Changed' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StationPills (CW-02)', () => {
|
||||
const pills = (point: Partial<TourPlannerWaypoint>) =>
|
||||
render(<StationPills point={{ id: 'p', lat: 0, lng: 0, role: 'via', ...point }} />);
|
||||
|
||||
it('says nothing of a stay nobody entered', () => {
|
||||
pills({ importance: 'must', protected: true });
|
||||
expect(screen.queryByTestId('station-pills')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a point passed through, and a stay entered as its time alone', () => {
|
||||
pills({ stayMinutes: 0 });
|
||||
expect(screen.getByTestId('station-pills')).toHaveTextContent('Passing through');
|
||||
});
|
||||
|
||||
it('shows the stay and nothing of the importance or the protection, which are on the number', () => {
|
||||
pills({ stayMinutes: 45, importance: 'must', protected: true });
|
||||
const row = screen.getByTestId('station-pills');
|
||||
expect(row).toHaveTextContent('45');
|
||||
expect(row).not.toHaveTextContent('Must see');
|
||||
expect(row).not.toHaveTextContent('Protected');
|
||||
});
|
||||
});
|
||||
|
||||
describe("a station's marks on its number (CW-03, CW-10)", () => {
|
||||
const t = (key: string) =>
|
||||
({
|
||||
'tours.walk.importance.must': 'Must see',
|
||||
'tours.walk.importance.optional': 'Optional',
|
||||
'tours.walk.station.protected': 'Protected',
|
||||
})[key] ?? key;
|
||||
|
||||
it('rings a must-see in full, an optional stop dashed, and a normal one not at all', () => {
|
||||
expect(stationRingClass('must')).toContain('outline-warning');
|
||||
expect(stationRingClass('optional')).toContain('outline-dashed');
|
||||
expect(stationRingClass(null)).toBe('');
|
||||
expect(stationRingClass(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('names the importance and the protection for the label, and nothing for a plain station', () => {
|
||||
expect(stationMarkNote({ importance: 'must', protected: true }, t)).toBe('Must see, Protected');
|
||||
expect(stationMarkNote({ importance: 'optional' }, t)).toBe('Optional');
|
||||
expect(stationMarkNote({ protected: true }, t)).toBe('Protected');
|
||||
expect(stationMarkNote({}, t)).toBeNull();
|
||||
});
|
||||
|
||||
it('draws a protection as a shield at the corner of the number', () => {
|
||||
const { container } = render(<StationShield />);
|
||||
expect(container.querySelector('[data-station-protected="true"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { StationImportance } from '@trek/shared';
|
||||
import { MapPin, ShieldCheck } from 'lucide-react';
|
||||
import { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import { EditorField, INPUT, LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell';
|
||||
import { parseStay, withStationFields } from './stationModel';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
type Importance = StationImportance | 'normal';
|
||||
const IMPORTANCES: readonly Importance[] = ['normal', 'optional', 'must'];
|
||||
|
||||
/**
|
||||
* CW-02, CW-03, CW-10: one station of a city walk, as a dialog: its name, how
|
||||
* long it is stayed at (empty is unknown, 0 a point passed through), how much
|
||||
* it matters and whether a proposed change must leave it. Saving is one step
|
||||
* of the history; nothing here plans the route again.
|
||||
*/
|
||||
export function StationDialog({
|
||||
point,
|
||||
fallbackName,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
point: TourPlannerWaypoint;
|
||||
/** The name the row shows while the station has none of its own. */
|
||||
fallbackName?: string;
|
||||
onSave: (next: TourPlannerWaypoint) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const nameId = useId();
|
||||
const stayId = useId();
|
||||
const [name, setName] = useState(point.name ?? '');
|
||||
const [stay, setStay] = useState(point.stayMinutes == null ? '' : String(point.stayMinutes));
|
||||
const [importance, setImportance] = useState<Importance>(point.importance ?? 'normal');
|
||||
const [protectedStation, setProtectedStation] = useState(point.protected === true);
|
||||
const stayMinutes = parseStay(stay);
|
||||
const stayInvalid = stayMinutes === undefined;
|
||||
|
||||
// The footer sits outside the dialog's form: its button saves by its own click, and a submit of the form stays here.
|
||||
const save = (event?: { preventDefault: () => void }) => {
|
||||
event?.preventDefault();
|
||||
if (stayInvalid) return;
|
||||
onSave(
|
||||
withStationFields(point, {
|
||||
name,
|
||||
stayMinutes,
|
||||
importance: importance === 'normal' ? null : importance,
|
||||
protected: protectedStation,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
onSubmit={save}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<MapPin size={16} strokeWidth={2} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
eyebrow={t('tours.walk.station.eyebrow')}
|
||||
title={name.trim() || fallbackName || t('tours.walk.station.unnamed')}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={() => save()} disabled={stayInvalid} data-testid="station-save">
|
||||
{t('common.save')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4" data-testid="station-dialog">
|
||||
<EditorField label={t('tours.walk.station.name')} htmlFor={nameId}>
|
||||
<input
|
||||
id={nameId}
|
||||
className={INPUT}
|
||||
value={name}
|
||||
maxLength={200}
|
||||
placeholder={fallbackName ?? t('tours.walk.station.namePlaceholder')}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label={t('tours.walk.station.stay')}
|
||||
htmlFor={stayId}
|
||||
hint={t('tours.walk.station.stayHint')}
|
||||
error={stayInvalid ? t('tours.walk.station.stayInvalid') : undefined}
|
||||
>
|
||||
<input
|
||||
id={stayId}
|
||||
className={INPUT}
|
||||
inputMode="numeric"
|
||||
value={stay}
|
||||
placeholder={t('tours.walk.station.stayUnknown')}
|
||||
onChange={(event) => setStay(event.target.value)}
|
||||
/>
|
||||
</EditorField>
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.walk.station.importance')}</span>
|
||||
<Segmented<Importance>
|
||||
value={importance}
|
||||
onChange={setImportance}
|
||||
label={t('tours.walk.station.importance')}
|
||||
fill
|
||||
options={IMPORTANCES.map((value) => ({ value, label: t(`tours.walk.importance.${value}`) }))}
|
||||
/>
|
||||
<span className="mt-1.5 block text-content-faint" style={fs(11, 'body')}>
|
||||
{t(`tours.walk.importanceHint.${importance}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck size={15} strokeWidth={2} className="mt-0.5 flex-none text-content-faint" />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t('tours.walk.station.protected')}
|
||||
</span>
|
||||
<span className="leading-snug text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.walk.station.protectedHint')}
|
||||
</span>
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
on={protectedStation}
|
||||
onToggle={() => setProtectedStation((value) => !value)}
|
||||
label={t('tours.walk.station.protected')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,20 @@ vi.mock('../library/useTourLibrary', () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock('../library/TourLibraryDialog', () => ({ TourLibraryDialog: () => <div data-testid="library-dialog" /> }));
|
||||
const walk = vi.hoisted(() => ({ show: vi.fn(), options: null as unknown }));
|
||||
vi.mock('../walk/useWalkImport', () => ({
|
||||
useWalkImport: (options: unknown) => {
|
||||
walk.options = options;
|
||||
return { show: walk.show };
|
||||
},
|
||||
}));
|
||||
vi.mock('../walk/WalkImportDialog', () => ({
|
||||
WalkImportDialog: ({ hasUnsavedChanges }: { hasUnsavedChanges: boolean }) => (
|
||||
<div data-testid="walk-dialog" data-unsaved={String(hasUnsavedChanges)} />
|
||||
),
|
||||
}));
|
||||
|
||||
// FE-TOUR-ADD-001 to FE-TOUR-ADD-005
|
||||
// FE-TOUR-ADD-001 to FE-TOUR-ADD-006
|
||||
|
||||
function planner(mode: 'neutral' | 'new-draft' = 'new-draft', overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -21,6 +33,7 @@ function planner(mode: 'neutral' | 'new-draft' = 'new-draft', overrides: Record<
|
||||
hasUnsavedChanges: false,
|
||||
openTour: vi.fn(),
|
||||
startNewTour: vi.fn(),
|
||||
startDerivedTour: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as Parameters<typeof TourAddMenu>[0]['planner'];
|
||||
}
|
||||
@@ -32,7 +45,7 @@ const open = () => fireEvent.click(screen.getByTestId('tour-rail-add'));
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('TourAddMenu (LIB-02)', () => {
|
||||
it('FE-TOUR-ADD-001: one button opens the three ways to add a tour, in order', () => {
|
||||
it('FE-TOUR-ADD-001: one button opens the four ways to add a tour, in order', () => {
|
||||
render(<TourAddMenu planner={planner()} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
|
||||
const button = screen.getByTestId('tour-rail-add');
|
||||
expect(button).toHaveAccessibleName('Add tour');
|
||||
@@ -42,6 +55,7 @@ describe('TourAddMenu (LIB-02)', () => {
|
||||
expect(entries().map((entry) => entry.textContent)).toEqual([
|
||||
'Plan new tour',
|
||||
'Import GPX',
|
||||
'Import walk from Google Maps',
|
||||
'Take over a tour from another trip',
|
||||
]);
|
||||
});
|
||||
@@ -65,7 +79,11 @@ describe('TourAddMenu (LIB-02)', () => {
|
||||
it('FE-TOUR-ADD-003: with no tour open the map is already planning one, so that entry is left out', () => {
|
||||
render(<TourAddMenu planner={planner('neutral')} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
|
||||
open();
|
||||
expect(entries().map((entry) => entry.textContent)).toEqual(['Import GPX', 'Take over a tour from another trip']);
|
||||
expect(entries().map((entry) => entry.textContent)).toEqual([
|
||||
'Import GPX',
|
||||
'Import walk from Google Maps',
|
||||
'Take over a tour from another trip',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-ADD-004: a tour taken over comes in, and opens unless the editor holds unsaved work', async () => {
|
||||
@@ -84,13 +102,25 @@ describe('TourAddMenu (LIB-02)', () => {
|
||||
expect(screen.getByTestId('library-dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-ADD-005: waits while saving or importing, and is not there with nothing to offer', () => {
|
||||
it('FE-TOUR-ADD-005: waits while saving or importing, and offers a walk from Google Maps with nothing else to offer', () => {
|
||||
const { rerender } = render(<TourAddMenu planner={planner()} gpxImport={gpxImport(true)} disabled={false} />);
|
||||
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
|
||||
rerender(<TourAddMenu planner={planner()} gpxImport={gpxImport()} disabled />);
|
||||
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
|
||||
rerender(<TourAddMenu planner={planner('neutral')} disabled={false} />);
|
||||
expect(screen.queryByTestId('tour-rail-add')).toBeNull();
|
||||
open();
|
||||
expect(entries().map((entry) => entry.textContent)).toEqual(['Import walk from Google Maps']);
|
||||
expect(screen.queryByTestId('library-dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-ADD-006: the walk entry opens its dialog, which knows about unsaved work in the planner (CW-07)', () => {
|
||||
const p = planner('new-draft', { hasUnsavedChanges: true });
|
||||
render(<TourAddMenu planner={p} gpxImport={gpxImport()} disabled={false} />);
|
||||
expect((walk.options as { planner: unknown }).planner).toBe(p);
|
||||
expect(screen.getByTestId('walk-dialog')).toHaveAttribute('data-unsaved', 'true');
|
||||
open();
|
||||
fireEvent.click(screen.getByText('Import walk from Google Maps'));
|
||||
expect(walk.show).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { FileDown, Library, Loader2, PenLine, Plus } from 'lucide-react';
|
||||
import { FileDown, Footprints, Library, Loader2, PenLine, Plus } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { PopoverItem } from '../../Packing/PackingPopover';
|
||||
@@ -9,6 +9,8 @@ import { usePopoverList } from '../../shared/usePopoverList';
|
||||
import { TourLibraryDialog } from '../library/TourLibraryDialog';
|
||||
import { useTourLibrary } from '../library/useTourLibrary';
|
||||
import type { TourGpxImport } from '../useTourGpxImport';
|
||||
import { WalkImportDialog } from '../walk/WalkImportDialog';
|
||||
import { useWalkImport } from '../walk/useWalkImport';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
interface AddEntry {
|
||||
@@ -21,9 +23,12 @@ interface AddEntry {
|
||||
/**
|
||||
* LIB-02: the tours rail's one way to add a tour, as a menu: plan a new one
|
||||
* (while another is open; with none open the map is already planning one),
|
||||
* import GPX files, or take one over from another trip, whose dialog opens
|
||||
* from here. A tour taken over comes into the trip and opens in the editor,
|
||||
* unless the editor holds unsaved work.
|
||||
* import GPX files, read a city walk from a Google Maps walking route (CW-07),
|
||||
* or take one over from another trip. The walk and the take-over open their
|
||||
* dialogs from here. A tour taken over comes into the trip and opens in the
|
||||
* editor, unless the editor holds unsaved work; a walk read from Google Maps
|
||||
* opens in the planner as a new draft, after a warning when that replaces
|
||||
* unsaved work.
|
||||
*/
|
||||
export function TourAddMenu({
|
||||
planner,
|
||||
@@ -31,14 +36,17 @@ export function TourAddMenu({
|
||||
onTakenOver,
|
||||
disabled,
|
||||
}: {
|
||||
planner: Pick<TourPlannerController, 'tripId' | 'mode' | 'hasUnsavedChanges' | 'openTour' | 'startNewTour'>;
|
||||
planner: Pick<
|
||||
TourPlannerController,
|
||||
'tripId' | 'mode' | 'hasUnsavedChanges' | 'openTour' | 'startNewTour' | 'startDerivedTour'
|
||||
>;
|
||||
gpxImport?: TourGpxImport;
|
||||
/** Where a tour of another trip goes into this one; without it that entry is left out. */
|
||||
onTakenOver?: (tour: TourListItem) => Promise<void>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const list = usePopoverList(0, { estimatedHeight: 140 });
|
||||
const list = usePopoverList(0, { estimatedHeight: 176 });
|
||||
const { open, toggle, close, triggerRef } = list;
|
||||
const library = useTourLibrary({
|
||||
tripId: Number(planner.tripId),
|
||||
@@ -47,6 +55,7 @@ export function TourAddMenu({
|
||||
if (!planner.hasUnsavedChanges) void planner.openTour(tour);
|
||||
},
|
||||
});
|
||||
const walkImport = useWalkImport({ planner });
|
||||
const importing = gpxImport?.importing ?? false;
|
||||
|
||||
const entries: AddEntry[] = [];
|
||||
@@ -66,6 +75,12 @@ export function TourAddMenu({
|
||||
run: gpxImport.pick,
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
key: 'walk',
|
||||
icon: <Footprints size={14} strokeWidth={2} />,
|
||||
label: t('tours.walkImport.menu'),
|
||||
run: walkImport.show,
|
||||
});
|
||||
if (onTakenOver) {
|
||||
entries.push({
|
||||
key: 'library',
|
||||
@@ -74,7 +89,6 @@ export function TourAddMenu({
|
||||
run: library.show,
|
||||
});
|
||||
}
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
// Run inside the click that picked it: the file picker opens only from a user's own tap.
|
||||
const pick = (entry: AddEntry) => {
|
||||
@@ -106,6 +120,7 @@ export function TourAddMenu({
|
||||
<PopoverItem key={entry.key} icon={entry.icon} label={entry.label} onClick={() => pick(entry)} />
|
||||
))}
|
||||
</PopoverListPanel>
|
||||
<WalkImportDialog walkImport={walkImport} hasUnsavedChanges={planner.hasUnsavedChanges} />
|
||||
{onTakenOver && <TourLibraryDialog library={library} />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -79,6 +79,7 @@ export function TourNameField({ planner, locked }: TourFieldProps) {
|
||||
hike: t('tours.planner.namePlaceholder'),
|
||||
bike: t('tours.planner.namePlaceholder.bike'),
|
||||
kayak: t('tours.planner.namePlaceholder.kayak'),
|
||||
walk: t('tours.planner.namePlaceholder.walk'),
|
||||
}[planner.tourType]
|
||||
}
|
||||
className={INPUT}
|
||||
@@ -157,31 +158,34 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
|
||||
const manual = planner.plannedDurationMinutes != null;
|
||||
return (
|
||||
<div data-testid="tour-planner-time-grid" className="grid grid-cols-2 gap-3">
|
||||
<div className="min-w-0">
|
||||
<FieldLabel
|
||||
htmlFor="tour-planner-breaks"
|
||||
aside={
|
||||
<InfoDot
|
||||
label={t('tours.planner.breaksAdditionalInfoLabel')}
|
||||
description={t('tours.planner.breaksAdditionalInfo')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t('tours.planner.breaksShort')}
|
||||
</FieldLabel>
|
||||
<TourDurationInput
|
||||
id="tour-planner-breaks"
|
||||
label={t('tours.planner.breaksAdditional')}
|
||||
value={planner.breakAdditionalMinutes}
|
||||
invalid={planner.breakAdditionalInvalid}
|
||||
errorId="tour-planner-breaks-error"
|
||||
locked={locked}
|
||||
onCommit={planner.setBreakAdditionalMinutes}
|
||||
/>
|
||||
{planner.breakAdditionalInvalid && (
|
||||
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
|
||||
)}
|
||||
</div>
|
||||
{/* CW-02: a city walk stops at its stations; their stays take the place of breaks. */}
|
||||
{planner.tourType !== 'walk' && (
|
||||
<div className="min-w-0">
|
||||
<FieldLabel
|
||||
htmlFor="tour-planner-breaks"
|
||||
aside={
|
||||
<InfoDot
|
||||
label={t('tours.planner.breaksAdditionalInfoLabel')}
|
||||
description={t('tours.planner.breaksAdditionalInfo')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t('tours.planner.breaksShort')}
|
||||
</FieldLabel>
|
||||
<TourDurationInput
|
||||
id="tour-planner-breaks"
|
||||
label={t('tours.planner.breaksAdditional')}
|
||||
value={planner.breakAdditionalMinutes}
|
||||
invalid={planner.breakAdditionalInvalid}
|
||||
errorId="tour-planner-breaks-error"
|
||||
locked={locked}
|
||||
onCommit={planner.setBreakAdditionalMinutes}
|
||||
/>
|
||||
{planner.breakAdditionalInvalid && (
|
||||
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<FieldLabel
|
||||
htmlFor="tour-planner-planned-duration"
|
||||
|
||||
@@ -129,6 +129,28 @@ describe('TourPlannerToursRail', () => {
|
||||
expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CW-02: shows a walk its walking time, its stays and their total, a total entered by hand beside the computed one', () => {
|
||||
const walk = {
|
||||
mode: { type: 'new-draft' as const }, tourType: 'walk' as const,
|
||||
walkingDurationMinutes: 45, plannedTotalMinutes: 135,
|
||||
stationStays: { walkingMinutes: 45, stayMinutes: 90, unknownStays: 1, stations: 3, totalMinutes: 135, partial: true },
|
||||
}
|
||||
const facts = () => screen.getAllByRole('definition').map(value => [value.previousElementSibling?.textContent, value.textContent])
|
||||
const view = render(<TourPlannerToursRail planner={planner(walk)} tours={[tour]} {...railProps} />)
|
||||
expect(facts()).toEqual(expect.arrayContaining([
|
||||
[expect.stringMatching(/^Est\. walking time/), '45 min'],
|
||||
['Stays', 'at least 1 h 30 min'],
|
||||
['Planned', 'at least 2 h 15 min'],
|
||||
]))
|
||||
expect(screen.queryByText('Breaks')).not.toBeInTheDocument()
|
||||
|
||||
view.rerender(<TourPlannerToursRail planner={planner({ ...walk, plannedDurationMinutes: 240, plannedTotalMinutes: 240 })} tours={[tour]} {...railProps} />)
|
||||
expect(facts()).toEqual(expect.arrayContaining([
|
||||
['Planned (Manual override)', '4 h'],
|
||||
['Computed', 'at least 2 h 15 min'],
|
||||
]))
|
||||
})
|
||||
|
||||
it('disables saved-tour switching while a Save is pending', () => {
|
||||
const controller = planner({
|
||||
isSaving: true,
|
||||
@@ -749,7 +771,7 @@ describe('TourPlannerRail', () => {
|
||||
expect(controller.cancelNewTour).toHaveBeenCalledOnce()
|
||||
|
||||
rerender(<TourPlannerRail planner={controller} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard changes and start new tour' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard and start' }))
|
||||
expect(controller.startNewTour).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
@@ -879,6 +901,17 @@ describe('TourPlannerRail', () => {
|
||||
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('TOUR-PLANNER-RAIL-022: an unsaved walk read from Google Maps says so, and a saved one no longer does (CW-07)', () => {
|
||||
const notice = 'From a Google Maps walking route. TREK planned the way between the stops itself.'
|
||||
const { rerender } = render(<TourPlannerRail planner={planner({ draftSource: 'google_maps', hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
|
||||
expect(screen.getByText(notice)).toBeInTheDocument()
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({ draftSource: 'google_maps', editingPlaceId: tour.place_id, mode: { type: 'edit-saved', placeId: tour.place_id } })} />)
|
||||
expect(screen.queryByText(notice)).toBeNull()
|
||||
rerender(<TourPlannerRail planner={planner({ hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
|
||||
expect(screen.queryByText(notice)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the paddle checklist in the rails (PAD-06)', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { BarButton, SoftPill } from '../../Planner/planParts'
|
||||
import TourListRow from '../TourListRow'
|
||||
import ElevationProfile from '../../shared/ElevationProfile'
|
||||
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
|
||||
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
|
||||
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection, type TourMetricField } from '../tourParts'
|
||||
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
|
||||
import { TourDetailsSection, TourNameField } from './TourEditorFields'
|
||||
import { crossesLake } from '../paddle/lakeStyleAdvice'
|
||||
@@ -41,11 +41,14 @@ import { type AssignTourToDay, useTourDayAssignment } from '../useTourDayAssignm
|
||||
import WandererMark from '../wanderer/WandererMark'
|
||||
import { TourGpxExportBarButton } from '../TourGpxExportButton'
|
||||
import { TourRemoteChangeNotice, TourRevisionConflictDialog } from './TourRevisionNotice'
|
||||
import { WalkImportDraftNotice } from '../walk/WalkImportDialog'
|
||||
import { WalkRoutePendingNotice } from '../walk/WalkRoutePending'
|
||||
import TourListFilterMenu, { TourFilterEmpty } from '../TourListFilterMenu'
|
||||
import { TourScopeSummary } from '../TourScopeSummary'
|
||||
import { useTourListFilters } from '../useTourListFilters'
|
||||
import { TourPoiSearchCard } from '../poi/TourPoiSearchCard'
|
||||
import { BikeWaysCard } from '../bike/BikeWaysCard'
|
||||
import { walkTimeFields } from '../walk/walkTimeFields'
|
||||
|
||||
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
|
||||
dirty: 'tours.planner.status.dirty',
|
||||
@@ -437,6 +440,8 @@ export function TourPlannerRail({ planner,
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
|
||||
<TourRemoteChangeNotice planner={planner} />
|
||||
<WalkImportDraftNotice planner={planner} />
|
||||
<WalkRoutePendingNotice planner={planner} />
|
||||
<TourNameField planner={planner} locked={locked} />
|
||||
|
||||
<TourTypeFields planner={planner} locked={locked} />
|
||||
@@ -612,6 +617,7 @@ const ESTIMATE_KEYS = {
|
||||
hike: { labelKey: 'tours.planner.walkingEstimated', shortKey: 'tours.planner.walkingEstimatedShort', infoLabelKey: 'tours.planner.walkingEstimatedInfoLabel', infoKey: 'tours.planner.walkingEstimatedInfo' },
|
||||
bike: { labelKey: 'tours.bike.timeEstimated', shortKey: 'tours.bike.timeEstimatedShort', infoLabelKey: 'tours.bike.timeEstimatedInfoLabel', infoKey: 'tours.bike.timeEstimatedInfo' },
|
||||
kayak: { labelKey: 'tours.paddle.timeEstimated', shortKey: 'tours.paddle.timeEstimatedShort', infoLabelKey: 'tours.paddle.timeEstimatedInfoLabel', infoKey: 'tours.paddle.timeEstimatedInfo' },
|
||||
walk: { labelKey: 'tours.walk.timeEstimated', shortKey: 'tours.walk.timeEstimatedShort', infoLabelKey: 'tours.walk.timeEstimatedInfoLabel', infoKey: 'tours.walk.timeEstimatedInfo' },
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -642,14 +648,25 @@ function UnreachableNotice({ planner }: { planner: TourPlannerController }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** The time facts of the overview: the estimate (walking, riding or paddling), the breaks and the planned total. */
|
||||
function plannerTimeFields(planner: TourPlannerController, t: Translate) {
|
||||
/**
|
||||
* The time facts of the overview: the estimate (walking, riding or paddling),
|
||||
* the breaks and the planned total; a city walk its walking time, its stays and
|
||||
* their total in the wording the tour detail uses too (CW-02).
|
||||
*/
|
||||
function plannerTimeFields(planner: TourPlannerController, t: Translate): TourMetricField[] {
|
||||
const keys = ESTIMATE_KEYS[planner.tourType]
|
||||
const estimate = {
|
||||
label: t(keys.labelKey),
|
||||
shortLabel: t(keys.shortKey),
|
||||
info: { label: t(keys.infoLabelKey), description: t(keys.infoKey) },
|
||||
}
|
||||
if (planner.stationStays) {
|
||||
return walkTimeFields({ walking: estimate, walkingMinutes: planner.walkingDurationMinutes, stays: planner.stationStays,
|
||||
manualMinutes: planner.plannedDurationMinutes, t })
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: t(keys.labelKey),
|
||||
shortLabel: t(keys.shortKey),
|
||||
info: { label: t(keys.infoLabelKey), description: t(keys.infoKey) },
|
||||
...estimate,
|
||||
value: planner.walkingDurationMinutes == null
|
||||
? t('tours.planner.walkingTimeUnknown')
|
||||
: formatPlannedTourDuration(planner.walkingDurationMinutes),
|
||||
@@ -660,7 +677,9 @@ function plannerTimeFields(planner: TourPlannerController, t: Translate) {
|
||||
},
|
||||
{
|
||||
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
|
||||
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
|
||||
value: planner.plannedTotalMinutes == null
|
||||
? t('tours.planner.walkingTimeUnknown')
|
||||
: formatPlannedTourDuration(planner.plannedTotalMinutes),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -44,6 +44,23 @@ describe('TourTypeFields (PAD-01)', () => {
|
||||
expect(field).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('CW-01: a city walk is a type of its own, on foot from stop to stop, with no fields of a bike or a boat', () => {
|
||||
const hike = planner({ tourType: 'hike', isPaddle: false });
|
||||
const { unmount } = render(<TourTypeFields planner={hike} locked={false} />);
|
||||
fireEvent.click(screen.getByTestId('tour-type-select'));
|
||||
const option = screen.getByTestId('tour-type-option-walk');
|
||||
expect(option).toHaveTextContent('City / sightseeing walk');
|
||||
expect(option).toHaveTextContent('On foot from stop to stop, without a hiking limit');
|
||||
fireEvent.click(option);
|
||||
expect(hike.setTourType).toHaveBeenCalledWith('walk');
|
||||
unmount();
|
||||
|
||||
render(<TourTypeFields planner={planner({ tourType: 'walk', isPaddle: false })} locked={false} />);
|
||||
expect(screen.getByTestId('tour-type-select')).toHaveTextContent('City / sightseeing walk');
|
||||
expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-planner-bike-fields')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('BIK-01 to BIK-04: a bike tour names its profile like a boat, with what the profile asks for', () => {
|
||||
const bike = planner({ tourType: 'bike', isPaddle: false, isBike: true, bikeProfile: 'gravel' });
|
||||
render(<TourTypeFields planner={bike} locked={false} />);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-006: shaping points and the water router's answers in the list (P2A-03, P2A-05, PAD-11).
|
||||
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-011: shaping points, the water router's answers
|
||||
// and a city walk's stops in the list, with their arrivals and opening hours on a day of the walk
|
||||
// (P2A-03, P2A-05, PAD-11, CW-01, CW-02, CW-04).
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { PaddleRouteLeg } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import type { PaddleTourRoute } from './paddleRouting';
|
||||
import TourWaypointList from './TourWaypointList';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
@@ -171,4 +175,161 @@ describe('TourWaypointList', () => {
|
||||
await userEvent.click(screen.getByTestId('tour-planner-route-on-water'));
|
||||
expect(controller.routeDrawnLegsOnWater).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-007: a city walk names its stops, shows their stay and edits one stop from its menu', async () => {
|
||||
const cathedral = {
|
||||
...STOP,
|
||||
key: 'stop-cathedral',
|
||||
name: 'Cathedral',
|
||||
stayMinutes: 30,
|
||||
importance: 'must' as const,
|
||||
};
|
||||
const controller = planner({
|
||||
tourType: 'walk',
|
||||
waypoints: [START, SHAPE, cathedral, END],
|
||||
selectedWaypointId: 'b',
|
||||
});
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
expect(within(at(rows, 2)).getByTestId('tour-planner-waypoint-name')).toHaveTextContent('Cathedral');
|
||||
// The stay goes under the name; a must-see rings the number instead of a pill beside it.
|
||||
expect(within(at(rows, 2)).getByTestId('station-pills')).toHaveTextContent('30 min');
|
||||
expect(within(at(rows, 2)).getByTestId('station-pills')).not.toHaveTextContent('Must see');
|
||||
const mustSee = within(at(rows, 2)).getByLabelText('Waypoint 2, Must see');
|
||||
expect(mustSee.className).toContain('outline-warning');
|
||||
// A stop without a stay entered says nothing of it, and a shaping point is no stop.
|
||||
expect(within(at(rows, 0)).queryByTestId('station-pills')).not.toBeInTheDocument();
|
||||
expect(within(at(rows, 1)).queryByTestId('station-pills')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(within(at(rows, 2)).getByRole('button', { name: 'More actions' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit stop' }));
|
||||
const stay = screen.getByLabelText('Stay in minutes');
|
||||
await userEvent.clear(stay);
|
||||
await userEvent.type(stay, '45');
|
||||
await userEvent.click(screen.getByTestId('station-save'));
|
||||
expect(screen.queryByTestId('station-dialog')).not.toBeInTheDocument();
|
||||
expect(controller.editWaypoints).toHaveBeenCalledTimes(1);
|
||||
const update = vi.mocked(controller.editWaypoints).mock.calls[0]?.[0] as (
|
||||
points: TourPlannerController['waypoints']
|
||||
) => TourPlannerController['waypoints'];
|
||||
expect(update([START, SHAPE, cathedral, END])).toEqual([START, SHAPE, { ...cathedral, stayMinutes: 45 }, END]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-007b: a hike has no stop pills and no stop to edit', async () => {
|
||||
const controller = planner({ selectedWaypointId: 'b' });
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
expect(screen.queryByTestId('station-pills')).not.toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 2)).getByRole('button', { name: 'More actions' })
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'Edit stop' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-009: a stop taken in along the tour carries its kind of place at the corner of its number (PLN-03)', () => {
|
||||
const controller = planner({ waypoints: [START, { ...STOP, poiCategory: 'restaurant' as const }, END] });
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
const number = within(at(rows, 1)).getByLabelText('Waypoint 2, Places to eat');
|
||||
expect(number.querySelector('[data-waypoint-poi="restaurant"]')).toBeInTheDocument();
|
||||
// A point set by hand has no kind to show.
|
||||
expect(within(at(rows, 0)).queryByLabelText(/Places to eat/)).not.toBeInTheDocument();
|
||||
expect(document.querySelectorAll('[data-waypoint-poi]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-008: the way back of a loop walk is no stop: no pills and no stop to edit', async () => {
|
||||
const back = { ...START, id: 'back', role: 'end' as const };
|
||||
const controller = planner({
|
||||
tourType: 'walk',
|
||||
isLoop: true,
|
||||
waypoints: [{ ...START, stayMinutes: 10 }, STOP, back],
|
||||
selectedWaypointId: 'back',
|
||||
});
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
expect(within(at(rows, 0)).getByTestId('station-pills')).toBeInTheDocument();
|
||||
expect(within(at(rows, 2)).queryByTestId('station-pills')).not.toBeInTheDocument();
|
||||
await userEvent.click(within(at(rows, 2)).getByRole('button', { name: 'More actions' }));
|
||||
expect(screen.queryByRole('button', { name: 'Edit stop' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('a city walk on a day of the plan (CW-02, CW-04)', () => {
|
||||
const lenbachhaus = {
|
||||
...START,
|
||||
key: 'stop-lenbachhaus',
|
||||
name: 'Lenbachhaus',
|
||||
stayMinutes: 60,
|
||||
openingHours: 'Mo-Fr 10:00-18:00',
|
||||
};
|
||||
const cafe = { ...STOP, name: 'Cafe', stayMinutes: 30 };
|
||||
const ROUTE: Array<[number, number]> = [
|
||||
[49.1, 12.1],
|
||||
[49.08, 12.11],
|
||||
[49.05, 12.12],
|
||||
[49.0, 12.1],
|
||||
];
|
||||
|
||||
/** A walk being edited, its route planned for the points as they are; on Monday 19 October from 17:20. */
|
||||
function walkPlanner(overrides: Partial<TourPlannerController> = {}) {
|
||||
return planner({
|
||||
tourType: 'walk',
|
||||
editingPlaceId: 42,
|
||||
waypoints: [lenbachhaus, SHAPE, cafe, END],
|
||||
route: ROUTE,
|
||||
routeCurrent: true,
|
||||
walkingDurationMinutes: 40,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const day = buildDay({ id: 7, date: '2026-10-19' });
|
||||
const entry = buildAssignment({
|
||||
day_id: 7,
|
||||
place_id: 42,
|
||||
assignment_time: '17:20',
|
||||
place: buildPlace({ id: 42 }),
|
||||
});
|
||||
seedStore(useTripStore, { days: [day], assignments: { '7': [entry] } });
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-009: names the day above the stops and the conflict at the stop it is about', () => {
|
||||
render(<TourWaypointList planner={walkPlanner()} canEdit isSaving={false} />);
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 17:20$/);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
const first = within(at(rows, 0)).getByTestId('walk-station-schedule');
|
||||
expect(within(first).getByText('Arrival about 17:20, stay until 18:20')).toBeInTheDocument();
|
||||
expect(within(first).getByTestId('walk-station-conflict')).toHaveTextContent(
|
||||
'Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)'
|
||||
);
|
||||
// Beside the row's select button, never inside it.
|
||||
expect(within(at(rows, 0)).getByRole('button', { current: false, name: /Lenbachhaus/ })).not.toContainElement(
|
||||
first
|
||||
);
|
||||
expect(within(at(rows, 1)).queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
|
||||
expect(within(at(rows, 2)).getByTestId('walk-station-schedule')).not.toHaveTextContent('closes');
|
||||
expect(screen.getAllByTestId('walk-station-conflict')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-010: shows no arrival while the route is not planned for the points as they are', () => {
|
||||
render(<TourWaypointList planner={walkPlanner({ routeCurrent: false })} canEdit isSaving={false} />);
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, Mon, Oct 19(, 2026)?, start 17:20$/);
|
||||
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-011: gives the way back of a loop no arrival of its own, and a hike no day line', () => {
|
||||
const back = { ...START, id: 'back', role: 'end' as const };
|
||||
const loop = walkPlanner({ isLoop: true, waypoints: [lenbachhaus, STOP, back], route: ROUTE.slice(0, 3) });
|
||||
const { unmount } = render(<TourWaypointList planner={loop} canEdit isSaving={false} />);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
expect(within(at(rows, 0)).getByTestId('walk-station-schedule')).toBeInTheDocument();
|
||||
expect(within(at(rows, 1)).getByTestId('walk-station-schedule')).toBeInTheDocument();
|
||||
expect(within(at(rows, 2)).queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<TourWaypointList planner={walkPlanner({ tourType: 'hike' })} canEdit isSaving={false} />);
|
||||
expect(screen.queryByTestId('walk-day-line')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
paddleLayoutOfPoints,
|
||||
type PaddleLayout,
|
||||
type PaddleRegionLoadState,
|
||||
type PaddleRole,
|
||||
type TourFixedLeg,
|
||||
} from '@trek/shared';
|
||||
import { paddleLayoutOfPoints, type PaddleRole, type TourPoiCategory } from '@trek/shared';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownUp,
|
||||
@@ -18,41 +12,44 @@ import {
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Spline,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
Unlink,
|
||||
Waves,
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useState, type MouseEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import {
|
||||
useTourFerrataCrossingStore,
|
||||
type TourFerrataCrossing,
|
||||
type TourWaypointFerrata,
|
||||
} from '../../../store/tourFerrataCrossingStore';
|
||||
import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph';
|
||||
import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
|
||||
import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad';
|
||||
import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow';
|
||||
import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
||||
import { AutoPointPill } from '../paddle/PaddleLegParts';
|
||||
import { PaddleRolePill } from '../paddle/PaddleWaypointParts';
|
||||
import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
|
||||
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
|
||||
import { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting';
|
||||
import { usePortageRouting } from '../paddle/usePortageRouting';
|
||||
import { RailCard } from '../tourParts';
|
||||
import { StationScheduleRow } from '../walk/StationSchedule';
|
||||
import { usePlannerWalkSchedule } from '../walk/useWalkSchedule';
|
||||
import { WalkDayLine } from '../walk/WalkDayLine';
|
||||
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
|
||||
import type { PaddleLegFailure } from './paddleRouting';
|
||||
import { lineLengthM } from './tourFixedLegs';
|
||||
import { StationDialog } from './StationDialog';
|
||||
import { stationMarkNote, stationRingClass } from './stationMarks';
|
||||
import { StationPills, StationShield } from './stationParts';
|
||||
import { TripPlacePicker } from './TripPlacePicker';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
import { useTripPlaceWaypoints } from './useTripPlaceWaypoints';
|
||||
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
|
||||
import { WaypointLegRows } from './WaypointLegRows';
|
||||
|
||||
type DropTarget = { id: string; placement: 'before' | 'after' };
|
||||
|
||||
@@ -137,37 +134,6 @@ function RouteToolButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg drawn by hand, as a badge on that leg in the dashed look the map draws
|
||||
* it in: no router planned it, and the badge says so. Its length is the line
|
||||
* the user drew, the only measure it has.
|
||||
*/
|
||||
function FreeLegRow({ leg }: { leg: TourFixedLeg }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||
// Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two".
|
||||
const line = leg.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]);
|
||||
const distance = formatDistance(lineLengthM(line) / 1000, unit ?? 'metric');
|
||||
return (
|
||||
<li data-testid="tour-planner-free-leg" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<Tooltip label={t('tours.planner.freeLegInfo')}>
|
||||
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full border border-dashed border-edge bg-surface-card pe-1 ps-2">
|
||||
<Spline size={13} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{t('tours.planner.freeLegBadge')}
|
||||
</span>
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{distance}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The quiet hint and first-use guidance before a route exists. */
|
||||
function WaypointEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
@@ -183,54 +149,57 @@ function WaypointEmptyState() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A via ferrata the route climbs between two waypoints, as a badge on that leg:
|
||||
* the ladder, its name, and its grade and length as chips inside, in the
|
||||
* ferrata violet the map draws it in.
|
||||
*/
|
||||
function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||
const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric');
|
||||
/** PLN-03: the kind of place a point was found as, its icon at the number's corner in its colour on the map. */
|
||||
function PoiCornerIcon({ category }: { category: TourPoiCategory }) {
|
||||
const look = TOUR_POI_LOOKS[category];
|
||||
return (
|
||||
<li
|
||||
data-testid="tour-planner-ferrata-leg"
|
||||
aria-label={t('tours.ferrata.crossingInfo', { length, name: crossing.name })}
|
||||
className="flex min-w-0 py-0.5 ps-[34px]"
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-waypoint-poi={category}
|
||||
className="absolute -bottom-1 -start-1 grid h-4 w-4 place-items-center rounded-full ring-2 ring-[color:var(--bg-card)]"
|
||||
style={{ background: `color-mix(in srgb, ${look.color} 22%, var(--bg-card))`, color: look.color }}
|
||||
>
|
||||
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1 ps-1 shadow-sm ring-1 ring-inset ring-[color:var(--map-ferrata)]">
|
||||
<FerrataGlyph size={18} className="flex-none" />
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{crossing.name}
|
||||
</span>
|
||||
{crossing.grade && (
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-semibold leading-[18px] text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{crossing.grade}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{length}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<look.Icon size={9} strokeWidth={2.6} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A city walk station's own marks on its number (CW-03, CW-10); none on any other point. */
|
||||
type StationMarks = Pick<WaypointPoint, 'importance' | 'protected'> | null;
|
||||
|
||||
/**
|
||||
* What a waypoint's number says besides the number: the via ferrata it stands
|
||||
* at an end of, or else the kind of place it was found as along the tour (a
|
||||
* ferrata's end says more about the point, so it wins), and a station's
|
||||
* importance and protection.
|
||||
*/
|
||||
function numberNote(
|
||||
ferrata: TourWaypointFerrata | null,
|
||||
poiCategory: TourPoiCategory | null,
|
||||
station: StationMarks,
|
||||
t: Translate
|
||||
): { note: string | null; poi: TourPoiCategory | null } {
|
||||
const marks = station ? stationMarkNote(station, t) : null;
|
||||
const join = (first: string | null) => [first, marks].filter(Boolean).join(', ') || null;
|
||||
if (ferrata) return { note: join(t('tours.ferrata.waypointAtEnd', { name: ferrata.name })), poi: null };
|
||||
if (poiCategory) return { note: join(t(TOUR_POI_LOOKS[poiCategory].labelKey)), poi: poiCategory };
|
||||
return { note: join(null), poi: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* A waypoint's number, as the map pins show it. At the foot or the top of a via
|
||||
* ferrata it takes a violet ring and the ferrata's ladder at its corner, named
|
||||
* by the tooltip.
|
||||
* by the tooltip. A point taken in from the places along the tour (PLN-03)
|
||||
* carries that kind's icon at the same corner, in its colour on the map. A
|
||||
* city walk's station shows how much it matters as a ring around the number
|
||||
* and its protection as a shield at the top corner.
|
||||
*/
|
||||
function WaypointNumber({
|
||||
stop,
|
||||
selected,
|
||||
ferrata,
|
||||
poiCategory = null,
|
||||
station = null,
|
||||
loopEnd = false,
|
||||
shaping = false,
|
||||
}: {
|
||||
@@ -238,6 +207,10 @@ function WaypointNumber({
|
||||
stop: number;
|
||||
selected: boolean;
|
||||
ferrata: TourWaypointFerrata | null;
|
||||
/** PLN-03: the kind of place the point was found as along the tour. */
|
||||
poiCategory?: TourPoiCategory | null;
|
||||
/** CW-03, CW-10: a station's importance and protection, drawn on the number; only a city walk's points carry them. */
|
||||
station?: StationMarks;
|
||||
/** The way back to the start of a loop: shown as the return, not as one more stop. */
|
||||
loopEnd?: boolean;
|
||||
/** P2A-03: a shaping point, a small dot as on the map. */
|
||||
@@ -269,19 +242,22 @@ function WaypointNumber({
|
||||
);
|
||||
}
|
||||
const label = t('tours.planner.waypointLabel', { n: stop });
|
||||
const { note, poi } = numberNote(ferrata, poiCategory, station, t);
|
||||
const number = (
|
||||
<span
|
||||
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
|
||||
aria-label={[label, note].filter(Boolean).join(', ')}
|
||||
data-waypoint-number={stop}
|
||||
data-ferrata-end={ferrata ? 'true' : undefined}
|
||||
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
|
||||
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''} ${stationRingClass(station?.importance)}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{stop}
|
||||
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
|
||||
{poi && <PoiCornerIcon category={poi} />}
|
||||
{station?.protected && <StationShield />}
|
||||
</span>
|
||||
);
|
||||
if (!ferrata) return number;
|
||||
if (!ferrata) return note ? <Tooltip label={note}>{number}</Tooltip> : number;
|
||||
const grade = ferrata.grade ? ` (${ferrata.grade})` : '';
|
||||
return <Tooltip label={`${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}${grade}`}>{number}</Tooltip>;
|
||||
}
|
||||
@@ -297,6 +273,7 @@ function WaypointRowLabel({
|
||||
loopEnd,
|
||||
paddleRole,
|
||||
linked = false,
|
||||
station = false,
|
||||
}: {
|
||||
point: TourPlannerController['waypoints'][number];
|
||||
placeName: string | undefined;
|
||||
@@ -305,10 +282,12 @@ function WaypointRowLabel({
|
||||
paddleRole?: PaddleRole | null;
|
||||
/** PR1-10: the point stands for this place of the trip, named after it. */
|
||||
linked?: boolean;
|
||||
/** CW-01: a city walk's station, which shows its stay (its importance and protection are on its number). */
|
||||
station?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
|
||||
const name = loopEnd ? t('tours.planner.loopEnd') : placeName;
|
||||
const name = loopEnd ? t('tours.planner.loopEnd') : (point.name ?? placeName);
|
||||
return (
|
||||
<>
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5">
|
||||
@@ -356,6 +335,8 @@ function WaypointRowLabel({
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{/* Under the coordinates: a station's stay, the one mark of it that is not on its number. */}
|
||||
{station && !loopEnd && <StationPills point={point} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -451,6 +432,7 @@ function WaypointRowActions({
|
||||
selected,
|
||||
isSaving,
|
||||
onUnlink,
|
||||
stationName,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: WaypointPoint;
|
||||
@@ -460,11 +442,21 @@ function WaypointRowActions({
|
||||
isSaving: boolean;
|
||||
/** PR1-10: given for a point that stands for a place; the place itself stays. */
|
||||
onUnlink?: () => void;
|
||||
/** CW-01: the name the row shows, for a station without one of its own. */
|
||||
stationName?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useContextMenu();
|
||||
const paddle = usePaddleWaypointActions(planner);
|
||||
const [stationOpen, setStationOpen] = useState(false);
|
||||
const station = planner.tourType === 'walk' && !point.shaping && !(planner.isLoop && index === lastIndex);
|
||||
const items: MenuItems = [
|
||||
...(station
|
||||
? [
|
||||
{ label: t('tours.walk.station.edit'), icon: SquarePen, onClick: () => setStationOpen(true) },
|
||||
{ divider: true } as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t('tours.planner.moveUp'),
|
||||
icon: ArrowUp,
|
||||
@@ -505,57 +497,21 @@ function WaypointRowActions({
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</WaypointAction>
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
{stationOpen && (
|
||||
<StationDialog
|
||||
point={point}
|
||||
fallbackName={stationName}
|
||||
onClose={() => setStationOpen(false)}
|
||||
onSave={(next) => {
|
||||
planner.editWaypoints((current) => current.map((item) => (item.id === next.id ? next : item)));
|
||||
setStationOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a paddle tour shows under a waypoint: the portage that starts there,
|
||||
* and the lock at it with its times.
|
||||
*/
|
||||
function PaddleRowExtras({
|
||||
planner,
|
||||
point,
|
||||
index,
|
||||
layout,
|
||||
editable,
|
||||
routing,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: TourPlannerController['waypoints'][number];
|
||||
index: number;
|
||||
layout: PaddleLayout;
|
||||
editable: boolean;
|
||||
routing: ReturnType<typeof usePortageRouting>;
|
||||
}) {
|
||||
const paddle = usePaddleWaypointActions(planner);
|
||||
const way = usePortageWay(planner.waypoints, routing, index);
|
||||
const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index);
|
||||
const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined;
|
||||
return (
|
||||
<>
|
||||
{point.carryNext && <PendingPortageRow disabled={!editable} onRemove={() => paddle.togglePortage(point.id)} />}
|
||||
{portage && (
|
||||
<PortageLegRow
|
||||
lengthM={portage.toM - portage.fromM}
|
||||
extraMinutes={portage.extraMinutes}
|
||||
disabled={!editable}
|
||||
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
|
||||
way={point.auto ? undefined : way}
|
||||
/>
|
||||
)}
|
||||
{point.lock && (
|
||||
<LockRow
|
||||
lock={point.lock}
|
||||
bypassed={!!lock?.bypassed}
|
||||
disabled={!editable}
|
||||
onChange={(next) => paddle.setLock(point.id, next)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row under the last waypoint that turns on "set points" for the map the
|
||||
* planner shows, the same switch as the one in the map's layer pill; while it
|
||||
@@ -669,100 +625,6 @@ function WaypointListTools({ planner, isSaving }: { planner: TourPlannerControll
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg the router could not plan. Where it lacked the water data of the
|
||||
* region (PAD-11C), the row follows the data instead: loading or asked for,
|
||||
* the leg waits calmly; declined, failed or not to be had, it says why.
|
||||
*/
|
||||
function LegFailure({
|
||||
code,
|
||||
load,
|
||||
disabled,
|
||||
onDrawStraight,
|
||||
onRetry,
|
||||
onLoadRetry,
|
||||
}: {
|
||||
code: PaddleLegFailure;
|
||||
load: PaddleRegionLoadState | undefined;
|
||||
disabled: boolean;
|
||||
onDrawStraight: () => void;
|
||||
onRetry: () => void;
|
||||
onLoadRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (isRegionLoadPending(load)) {
|
||||
return <PaddleRegionLoadRow state={load} disabled={disabled} onDrawStraight={onDrawStraight} />;
|
||||
}
|
||||
return (
|
||||
<PaddleLegFailureRow
|
||||
code={code}
|
||||
disabled={disabled}
|
||||
onDrawStraight={onDrawStraight}
|
||||
onRetry={onRetry}
|
||||
message={regionLoadFailure(load, t)}
|
||||
onLoadRetry={load?.state === 'failed' ? onLoadRetry : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows under a waypoint for the leg that starts there: the via ferrata it
|
||||
* crosses, a line drawn by hand, and on a paddle tour what the router found on
|
||||
* the way (PAD-11) or why it could not plan it, plus the portage and lock rows.
|
||||
*/
|
||||
function WaypointLegRows({
|
||||
planner,
|
||||
point,
|
||||
index,
|
||||
crossings = [],
|
||||
routedLegs,
|
||||
paddleLayout,
|
||||
editable,
|
||||
portageRouting,
|
||||
regionLoad,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: WaypointPoint;
|
||||
index: number;
|
||||
crossings?: TourFerrataCrossing[];
|
||||
routedLegs: TourPlannerController['paddleRoute'] | null;
|
||||
paddleLayout: ReturnType<typeof paddleLayoutOfPoints> | null;
|
||||
editable: boolean;
|
||||
portageRouting: ReturnType<typeof usePortageRouting>;
|
||||
regionLoad: ReturnType<typeof usePaddleRegionLoad>;
|
||||
}) {
|
||||
const failure = routedLegs?.failures.find((candidate) => candidate.index === index);
|
||||
return (
|
||||
<>
|
||||
{crossings.map((crossing) => (
|
||||
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
||||
))}
|
||||
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} />}
|
||||
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
|
||||
{failure && (
|
||||
<LegFailure
|
||||
code={failure.code}
|
||||
load={regionLoad.byLeg.get(index)}
|
||||
disabled={!editable}
|
||||
onDrawStraight={() => planner.toggleWaypointFreeLeg(point.id)}
|
||||
onRetry={() => planner.recomputeLeg(point.id)}
|
||||
onLoadRetry={regionLoad.retry}
|
||||
/>
|
||||
)}
|
||||
{paddleLayout && (
|
||||
<PaddleRowExtras
|
||||
planner={planner}
|
||||
point={point}
|
||||
index={index}
|
||||
layout={paddleLayout}
|
||||
editable={editable}
|
||||
routing={portageRouting}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourWaypointList({
|
||||
planner,
|
||||
canEdit,
|
||||
@@ -777,6 +639,8 @@ export default function TourWaypointList({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tripPlaces = useTripPlaceWaypoints(planner, tourPlaceIds);
|
||||
// CW-02, CW-04: the arrivals and opening hours of a city walk on one of its days; hints only, nothing saved.
|
||||
const walk = usePlannerWalkSchedule(planner, tripPlaces.linkedPlaceOf);
|
||||
const [placesOpen, setPlacesOpen] = useState(false);
|
||||
const addRows = canEdit && (
|
||||
<>
|
||||
@@ -847,12 +711,15 @@ export default function TourWaypointList({
|
||||
|
||||
return card(
|
||||
<>
|
||||
<WalkDayLine {...walk} className="mb-2" />
|
||||
<ol data-touch-drag className="m-0 -mx-1.5 flex list-none flex-col gap-px p-0">
|
||||
{planner.waypoints.map((point, index) => {
|
||||
const selected = planner.selectedWaypointId === point.id;
|
||||
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
|
||||
const loopEnd = planner.isLoop && index === lastIndex;
|
||||
const linkedPlace = tripPlaces.linkedPlaceOf(point);
|
||||
const placeName = linkedPlace?.name ?? placeNames[point.id];
|
||||
const stop = stopNumbers.get(point.id) ?? index + 1;
|
||||
return (
|
||||
<Fragment key={point.id}>
|
||||
<li
|
||||
@@ -909,9 +776,11 @@ export default function TourWaypointList({
|
||||
<GripVertical size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<WaypointNumber
|
||||
stop={stopNumbers.get(point.id) ?? index + 1}
|
||||
stop={stop}
|
||||
selected={selected}
|
||||
ferrata={ferrataEnds[point.id] ?? null}
|
||||
poiCategory={point.poiCategory ?? null}
|
||||
station={point}
|
||||
loopEnd={loopEnd}
|
||||
shaping={!!point.shaping}
|
||||
/>
|
||||
@@ -923,10 +792,11 @@ export default function TourWaypointList({
|
||||
>
|
||||
<WaypointRowLabel
|
||||
point={point}
|
||||
placeName={linkedPlace?.name ?? placeNames[point.id]}
|
||||
placeName={placeName}
|
||||
loopEnd={loopEnd}
|
||||
paddleRole={paddleRoleOf(point.id)}
|
||||
linked={!!linkedPlace}
|
||||
station={planner.tourType === 'walk' && !point.shaping}
|
||||
/>
|
||||
</button>
|
||||
{canEdit && (
|
||||
@@ -938,9 +808,11 @@ export default function TourWaypointList({
|
||||
selected={selected}
|
||||
isSaving={isSaving}
|
||||
onUnlink={point.linkedPlaceId ? () => tripPlaces.unlink(point.id) : undefined}
|
||||
stationName={placeName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<StationScheduleRow schedule={walk.schedule} point={point} placeName={placeName} number={stop} />
|
||||
</li>
|
||||
<WaypointLegRows
|
||||
planner={planner}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { PaddleLayout, PaddleRegionLoadState, TourFixedLeg } from '@trek/shared';
|
||||
import { Spline } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import type { TourFerrataCrossing } from '../../../store/tourFerrataCrossingStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
|
||||
import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad';
|
||||
import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow';
|
||||
import { LockRow, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
||||
import type { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
|
||||
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
|
||||
import { usePortageWay, type usePortageRouting } from '../paddle/usePortageRouting';
|
||||
import type { PaddleLegFailure } from './paddleRouting';
|
||||
import { lineLengthM } from './tourFixedLegs';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
type WaypointPoint = TourPlannerController['waypoints'][number];
|
||||
|
||||
/**
|
||||
* A leg drawn by hand, as a badge on that leg in the dashed look the map draws
|
||||
* it in: no router planned it, and the badge says so. Its length is the line
|
||||
* the user drew, the only measure it has.
|
||||
*/
|
||||
function FreeLegRow({ leg }: { leg: TourFixedLeg }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||
// Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two".
|
||||
const line = leg.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]);
|
||||
const distance = formatDistance(lineLengthM(line) / 1000, unit ?? 'metric');
|
||||
return (
|
||||
<li data-testid="tour-planner-free-leg" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<Tooltip label={t('tours.planner.freeLegInfo')}>
|
||||
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full border border-dashed border-edge bg-surface-card pe-1 ps-2">
|
||||
<Spline size={13} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{t('tours.planner.freeLegBadge')}
|
||||
</span>
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{distance}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A via ferrata the route climbs between two waypoints, as a badge on that leg:
|
||||
* the ladder, its name, and its grade and length as chips inside, in the
|
||||
* ferrata violet the map draws it in.
|
||||
*/
|
||||
function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||
const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric');
|
||||
return (
|
||||
<li
|
||||
data-testid="tour-planner-ferrata-leg"
|
||||
aria-label={t('tours.ferrata.crossingInfo', { length, name: crossing.name })}
|
||||
className="flex min-w-0 py-0.5 ps-[34px]"
|
||||
>
|
||||
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1 ps-1 shadow-sm ring-1 ring-inset ring-[color:var(--map-ferrata)]">
|
||||
<FerrataGlyph size={18} className="flex-none" />
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{crossing.name}
|
||||
</span>
|
||||
{crossing.grade && (
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-semibold leading-[18px] text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{crossing.grade}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{length}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a paddle tour shows under a waypoint: the portage that starts there,
|
||||
* and the lock at it with its times.
|
||||
*/
|
||||
function PaddleRowExtras({
|
||||
planner,
|
||||
point,
|
||||
index,
|
||||
layout,
|
||||
editable,
|
||||
routing,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: TourPlannerController['waypoints'][number];
|
||||
index: number;
|
||||
layout: PaddleLayout;
|
||||
editable: boolean;
|
||||
routing: ReturnType<typeof usePortageRouting>;
|
||||
}) {
|
||||
const paddle = usePaddleWaypointActions(planner);
|
||||
const way = usePortageWay(planner.waypoints, routing, index);
|
||||
const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index);
|
||||
const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined;
|
||||
return (
|
||||
<>
|
||||
{point.carryNext && <PendingPortageRow disabled={!editable} onRemove={() => paddle.togglePortage(point.id)} />}
|
||||
{portage && (
|
||||
<PortageLegRow
|
||||
lengthM={portage.toM - portage.fromM}
|
||||
extraMinutes={portage.extraMinutes}
|
||||
disabled={!editable}
|
||||
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
|
||||
way={point.auto ? undefined : way}
|
||||
/>
|
||||
)}
|
||||
{point.lock && (
|
||||
<LockRow
|
||||
lock={point.lock}
|
||||
bypassed={!!lock?.bypassed}
|
||||
disabled={!editable}
|
||||
onChange={(next) => paddle.setLock(point.id, next)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg the router could not plan. Where it lacked the water data of the
|
||||
* region (PAD-11C), the row follows the data instead: loading or asked for,
|
||||
* the leg waits calmly; declined, failed or not to be had, it says why.
|
||||
*/
|
||||
function LegFailure({
|
||||
code,
|
||||
load,
|
||||
disabled,
|
||||
onDrawStraight,
|
||||
onRetry,
|
||||
onLoadRetry,
|
||||
}: {
|
||||
code: PaddleLegFailure;
|
||||
load: PaddleRegionLoadState | undefined;
|
||||
disabled: boolean;
|
||||
onDrawStraight: () => void;
|
||||
onRetry: () => void;
|
||||
onLoadRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (isRegionLoadPending(load)) {
|
||||
return <PaddleRegionLoadRow state={load} disabled={disabled} onDrawStraight={onDrawStraight} />;
|
||||
}
|
||||
return (
|
||||
<PaddleLegFailureRow
|
||||
code={code}
|
||||
disabled={disabled}
|
||||
onDrawStraight={onDrawStraight}
|
||||
onRetry={onRetry}
|
||||
message={regionLoadFailure(load, t)}
|
||||
onLoadRetry={load?.state === 'failed' ? onLoadRetry : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows under a waypoint for the leg that starts there: the via ferrata it
|
||||
* crosses, a line drawn by hand, and on a paddle tour what the router found on
|
||||
* the way (PAD-11) or why it could not plan it, plus the portage and lock rows.
|
||||
*/
|
||||
export function WaypointLegRows({
|
||||
planner,
|
||||
point,
|
||||
index,
|
||||
crossings = [],
|
||||
routedLegs,
|
||||
paddleLayout,
|
||||
editable,
|
||||
portageRouting,
|
||||
regionLoad,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: WaypointPoint;
|
||||
index: number;
|
||||
crossings?: TourFerrataCrossing[];
|
||||
routedLegs: TourPlannerController['paddleRoute'] | null;
|
||||
paddleLayout: PaddleLayout | null;
|
||||
editable: boolean;
|
||||
portageRouting: ReturnType<typeof usePortageRouting>;
|
||||
regionLoad: ReturnType<typeof usePaddleRegionLoad>;
|
||||
}) {
|
||||
const failure = routedLegs?.failures.find((candidate) => candidate.index === index);
|
||||
return (
|
||||
<>
|
||||
{crossings.map((crossing) => (
|
||||
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
||||
))}
|
||||
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} />}
|
||||
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
|
||||
{failure && (
|
||||
<LegFailure
|
||||
code={failure.code}
|
||||
load={regionLoad.byLeg.get(index)}
|
||||
disabled={!editable}
|
||||
onDrawStraight={() => planner.toggleWaypointFreeLeg(point.id)}
|
||||
onRetry={() => planner.recomputeLeg(point.id)}
|
||||
onLoadRetry={regionLoad.retry}
|
||||
/>
|
||||
)}
|
||||
{paddleLayout && (
|
||||
<PaddleRowExtras
|
||||
planner={planner}
|
||||
point={point}
|
||||
index={index}
|
||||
layout={paddleLayout}
|
||||
editable={editable}
|
||||
routing={portageRouting}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { useTranslation } from '../../../i18n';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/**
|
||||
* CW-03: how much a station matters, as a ring around its number: a full ring
|
||||
* in the warning colour for a must-see, a dashed muted one for an optional
|
||||
* stop, none for a normal one.
|
||||
*/
|
||||
export function stationRingClass(importance: TourPlannerWaypoint['importance']): string {
|
||||
if (importance === 'must') return 'outline outline-2 outline-offset-1 outline-warning';
|
||||
if (importance === 'optional') return 'outline outline-2 outline-offset-1 outline-dashed outline-content-faint';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** CW-03, CW-10: what the ring and the shield on a station's number say, for its label and tooltip. */
|
||||
export function stationMarkNote(
|
||||
point: Pick<TourPlannerWaypoint, 'importance' | 'protected'>,
|
||||
t: Translate
|
||||
): string | null {
|
||||
const parts = [
|
||||
point.importance ? t(`tours.walk.importance.${point.importance}`) : null,
|
||||
point.protected ? t('tours.walk.station.protected') : null,
|
||||
].filter((part): part is string => part !== null);
|
||||
return parts.length ? parts.join(', ') : null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// FE-WALK-TIMES-001 to FE-WALK-TIMES-006: a city walk's walking time and stays kept apart (CW-02).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseStay, walkTimes, walkTimesOf, withStationFields } from './stationModel';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
const cathedral: TourPlannerWaypoint = { id: 'a', lat: 48.1, lng: 11.5, role: 'start', stayMinutes: 30 };
|
||||
const steer: TourPlannerWaypoint = { id: 's', lat: 48.105, lng: 11.505, role: 'via', shaping: true, stayMinutes: 99 };
|
||||
const market: TourPlannerWaypoint = { id: 'b', lat: 48.11, lng: 11.51, role: 'via', stayMinutes: 0 };
|
||||
const park: TourPlannerWaypoint = { id: 'c', lat: 48.13, lng: 11.53, role: 'end' };
|
||||
|
||||
describe('walkTimes', () => {
|
||||
it('FE-WALK-TIMES-001: adds the stays entered to the walking time and says what is not known', () => {
|
||||
expect(walkTimes([cathedral, steer, market, park], 45)).toEqual({
|
||||
walkingMinutes: 45,
|
||||
stayMinutes: 30,
|
||||
unknownStays: 1,
|
||||
stations: 3,
|
||||
totalMinutes: 75,
|
||||
partial: true,
|
||||
});
|
||||
expect(walkTimes([cathedral, market], 20)).toMatchObject({ stations: 2, totalMinutes: 50, partial: false });
|
||||
});
|
||||
|
||||
it("FE-WALK-TIMES-002: the way back of a loop is its start again, so the start's stay counts once", () => {
|
||||
const back: TourPlannerWaypoint = { ...cathedral, id: 'back', role: 'end' };
|
||||
expect(walkTimes([cathedral, market, { ...park, role: 'via' }, back], 45)).toEqual({
|
||||
walkingMinutes: 45,
|
||||
stayMinutes: 30,
|
||||
unknownStays: 1,
|
||||
stations: 3,
|
||||
totalMinutes: 75,
|
||||
partial: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-WALK-TIMES-003: 45 min of walking, a museum of 90 min and a cafe of 30 min are 2 h 45 min', () => {
|
||||
const museum: TourPlannerWaypoint = { id: 'm', lat: 48.12, lng: 11.52, role: 'via', stayMinutes: 90 };
|
||||
const cafe: TourPlannerWaypoint = { id: 'k', lat: 48.13, lng: 11.53, role: 'end', stayMinutes: 30 };
|
||||
const start: TourPlannerWaypoint = { id: 's', lat: 48.1, lng: 11.5, role: 'start', stayMinutes: 0 };
|
||||
expect(walkTimes([start, museum, cafe], 45)).toMatchObject({ totalMinutes: 165, partial: false, stations: 3 });
|
||||
});
|
||||
|
||||
it('FE-WALK-TIMES-004: without a walking time the stays entered are a part of the whole, and nothing known is no total', () => {
|
||||
expect(walkTimes([cathedral, market], null)).toMatchObject({ totalMinutes: 30, partial: true });
|
||||
expect(walkTimesOf({ walkingMinutes: null, stayMinutes: 0, unknownStays: 2, stations: 2 })).toEqual({
|
||||
walkingMinutes: null,
|
||||
stayMinutes: 0,
|
||||
unknownStays: 2,
|
||||
stations: 2,
|
||||
totalMinutes: null,
|
||||
partial: false,
|
||||
});
|
||||
expect(walkTimesOf({ walkingMinutes: 40, stayMinutes: 0, unknownStays: 0, stations: 2 })).toMatchObject({
|
||||
totalMinutes: 40,
|
||||
partial: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the station dialog model', () => {
|
||||
it('FE-WALK-TIMES-005: reads a stay as typed: empty is unknown, whole minutes up to a day are taken, the rest is not', () => {
|
||||
expect(parseStay('')).toBeNull();
|
||||
expect(parseStay(' ')).toBeNull();
|
||||
expect(parseStay('0')).toBe(0);
|
||||
expect(parseStay(' 45 ')).toBe(45);
|
||||
expect(parseStay('1440')).toBe(1440);
|
||||
for (const text of ['1441', '12.5', '-3', 'abc', '12345']) expect(parseStay(text)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-WALK-TIMES-006: puts the edited fields on the point, each left out where it is empty', () => {
|
||||
const point: TourPlannerWaypoint = { ...cathedral, key: 'stop-a', openingHours: 'Mo-Su 09:00-18:00' };
|
||||
expect(
|
||||
withStationFields(point, { name: ' Dom ', stayMinutes: 0, importance: 'optional', protected: false })
|
||||
).toEqual({
|
||||
id: 'a',
|
||||
lat: 48.1,
|
||||
lng: 11.5,
|
||||
role: 'start',
|
||||
key: 'stop-a',
|
||||
openingHours: 'Mo-Su 09:00-18:00',
|
||||
name: 'Dom',
|
||||
stayMinutes: 0,
|
||||
importance: 'optional',
|
||||
});
|
||||
expect(withStationFields(point, { name: ' ', stayMinutes: null, importance: null, protected: true })).toEqual({
|
||||
id: 'a',
|
||||
lat: 48.1,
|
||||
lng: 11.5,
|
||||
role: 'start',
|
||||
key: 'stop-a',
|
||||
openingHours: 'Mo-Su 09:00-18:00',
|
||||
protected: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { walkStationsOf } from '@trek/shared';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
/**
|
||||
* CW-02: a city walk's time, walking and staying kept apart. While no stay is
|
||||
* entered at all, the walk is planned as its walking time alone and nothing
|
||||
* calls the stays unknown. Once some are entered, one left empty is unknown,
|
||||
* never nothing: the total then counts only what is known and says it is at
|
||||
* least that much. A point passed through is a stay of 0, entered on purpose.
|
||||
*/
|
||||
export interface WalkTimes {
|
||||
walkingMinutes: number | null;
|
||||
/** The stays entered, summed. */
|
||||
stayMinutes: number;
|
||||
/** Stations whose stay nobody entered. */
|
||||
unknownStays: number;
|
||||
/** The walk's stations, so that some stays unknown and all of them unknown read apart. */
|
||||
stations: number;
|
||||
/** Walking and the known stays; the known stays alone while the walking time is not known, null while nothing is. */
|
||||
totalMinutes: number | null;
|
||||
/** The total leaves out a stay or the walking time that is not known. */
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
/** The times of a walk from its parts: the walking time, the stays entered, how many are unknown and of how many. */
|
||||
export function walkTimesOf(parts: Omit<WalkTimes, 'totalMinutes' | 'partial'>): WalkTimes {
|
||||
const { walkingMinutes, stayMinutes, unknownStays, stations } = parts;
|
||||
// Without a walking time the stays entered are still part of the whole: at least that much, never nothing.
|
||||
const totalMinutes = walkingMinutes !== null ? walkingMinutes + stayMinutes : stayMinutes > 0 ? stayMinutes : null;
|
||||
return {
|
||||
...parts,
|
||||
totalMinutes,
|
||||
partial: totalMinutes !== null && (someStaysUnknown({ unknownStays, stations }) || walkingMinutes === null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Some stays entered and some not: none entered at all means none planned, not unknown. */
|
||||
export function someStaysUnknown({ unknownStays, stations }: Pick<WalkTimes, 'unknownStays' | 'stations'>): boolean {
|
||||
return unknownStays > 0 && unknownStays < stations;
|
||||
}
|
||||
|
||||
export function walkTimes(points: readonly TourPlannerWaypoint[], walkingMinutes: number | null): WalkTimes {
|
||||
// A shaping point is no station, and the way back of a loop is its start again: its stay counts once.
|
||||
const stations = walkStationsOf(points);
|
||||
return walkTimesOf({
|
||||
walkingMinutes,
|
||||
stayMinutes: stations.reduce((sum, station) => sum + (station.stayMinutes ?? 0), 0),
|
||||
unknownStays: stations.filter((station) => station.stayMinutes == null).length,
|
||||
stations: stations.length,
|
||||
});
|
||||
}
|
||||
|
||||
/** What the station dialog edits; the rest of the point stays as it is. */
|
||||
export type StationFields = Pick<TourPlannerWaypoint, 'name' | 'stayMinutes' | 'importance' | 'protected'>;
|
||||
|
||||
/** The point with the fields as edited, each one left out where it is empty. */
|
||||
export function withStationFields(point: TourPlannerWaypoint, fields: StationFields): TourPlannerWaypoint {
|
||||
const { name: _name, stayMinutes: _stay, importance: _importance, protected: _protected, ...rest } = point;
|
||||
const name = fields.name?.trim();
|
||||
return {
|
||||
...rest,
|
||||
...(name ? { name } : {}),
|
||||
...(fields.stayMinutes != null ? { stayMinutes: fields.stayMinutes } : {}),
|
||||
...(fields.importance ? { importance: fields.importance } : {}),
|
||||
...(fields.protected ? { protected: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A stay as typed: empty is unknown, a whole number from 0 to a day is minutes, anything else is not taken. */
|
||||
export function parseStay(text: string): number | null | undefined {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === '') return null;
|
||||
if (!/^\d{1,4}$/.test(trimmed)) return undefined;
|
||||
const minutes = Number(trimmed);
|
||||
return minutes <= 24 * 60 ? minutes : undefined;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Clock, ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
/**
|
||||
* CW-02: a city walk station's stay, where one is entered, as a quiet pill
|
||||
* under its name. A stay nobody entered says nothing. How much the station
|
||||
* matters and whether it is protected are drawn on its number instead
|
||||
* (`stationRingClass`, `StationShield`).
|
||||
*/
|
||||
export function StationPills({ point }: { point: Pick<TourPlannerWaypoint, 'stayMinutes'> }) {
|
||||
const { t } = useTranslation();
|
||||
if (point.stayMinutes == null) return null;
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1" data-testid="station-pills">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{point.stayMinutes === 0 ? t('tours.walk.station.passThrough') : formatPlannedTourDuration(point.stayMinutes)}
|
||||
</SoftPill>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** CW-10: a protected station's shield, at the top corner of its number. */
|
||||
export function StationShield() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-station-protected="true"
|
||||
className="absolute -end-1.5 -top-1.5 grid h-3.5 w-3.5 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm"
|
||||
>
|
||||
<ShieldCheck size={9} strokeWidth={2.4} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import type { PaddleFieldValues } from './usePaddleFields';
|
||||
|
||||
/** CW-07: where a draft's points were read from outside the trip, said in the planner until it is saved. */
|
||||
export type DraftSource = 'google_maps';
|
||||
|
||||
/**
|
||||
* The tour planner's draft as it is kept in local storage between visits, and
|
||||
* the check a stored draft has to pass before the planner takes it back: a
|
||||
@@ -30,6 +33,8 @@ export interface DraftPayload {
|
||||
editingPlaceId?: number | null;
|
||||
/** The import a new tour is being planned from; carried until it is saved. */
|
||||
derivedFromPlaceId?: number | null;
|
||||
/** CW-07: a draft loaded from a Google Maps walk keeps saying so across a reload. */
|
||||
draftSource?: DraftSource | null;
|
||||
tourType?: PlannableTourType;
|
||||
paddle?: PaddleFieldValues;
|
||||
/** P2C-03: the revision of the tour the draft edits, as it was loaded. */
|
||||
@@ -58,6 +63,10 @@ function optionalPlaceId(value: number | null | undefined): boolean {
|
||||
return value == null || (Number.isInteger(value) && value > 0);
|
||||
}
|
||||
|
||||
function optionalSource(value: unknown): boolean {
|
||||
return value == null || value === 'google_maps';
|
||||
}
|
||||
|
||||
function optionalDifficulty(value: number | null | undefined): boolean {
|
||||
return value == null || (Number.isInteger(value) && value >= 1 && value <= 6);
|
||||
}
|
||||
@@ -97,6 +106,7 @@ export function validDraft(value: unknown): value is DraftPayload {
|
||||
optionalMinutes(draft.breakAdditionalMinutes) &&
|
||||
optionalPlaceId(draft.editingPlaceId) &&
|
||||
optionalPlaceId(draft.derivedFromPlaceId) &&
|
||||
optionalSource(draft.draftSource) &&
|
||||
optionalPlaceId(draft.baseRevision) &&
|
||||
validRouteChoices(draft) &&
|
||||
Array.isArray(draft.waypoints) &&
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@trek/shared';
|
||||
import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry';
|
||||
import { hikingTimeMinutes } from '../hikingTime';
|
||||
import { walkTimesOf, type WalkTimes } from './stationModel';
|
||||
|
||||
/** The editable fields of a saved tour as the planner opens them, each with its empty value. */
|
||||
export function openedTourFields(tour: TourListItem) {
|
||||
@@ -52,6 +53,11 @@ export interface TourPlannerFactsInput {
|
||||
/** A paddle tour's estimate on the water; null for a hike. */
|
||||
paddleEstimate: { waterMinutes: number | null; estimateMinutes: number | null } | null;
|
||||
routeAnalysis: RouteGeometryAnalysis | null;
|
||||
/**
|
||||
* CW-02: a city walk's stays as far as known, and of how many stations; null
|
||||
* for any other type, which takes its breaks.
|
||||
*/
|
||||
stays?: Pick<WalkTimes, 'stayMinutes' | 'unknownStays' | 'stations'> | null;
|
||||
/** The router's own time, seconds: a bike tour's riding time; stands in for a hike until the heights are in. */
|
||||
durationSeconds: number | null;
|
||||
}
|
||||
@@ -69,12 +75,23 @@ function invalidMinutes(value: number | null): boolean {
|
||||
function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | null): number | null {
|
||||
const { paddleEstimate, routeAnalysis } = input;
|
||||
if (paddleEstimate) return paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes;
|
||||
if (input.tourType === 'bike') return routerMinutes;
|
||||
// A bike tour is timed by the router for its profile, a city walk by the router on foot.
|
||||
if (input.tourType === 'bike' || input.tourType === 'walk') return routerMinutes;
|
||||
return routeAnalysis?.gain != null && routeAnalysis.loss != null
|
||||
? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss)
|
||||
: routerMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02: a city walk's times, the total its overview shows. Its stops are its
|
||||
* stays and it has no breaks on top, so nothing counts twice; the stays entered
|
||||
* count even while the walking time is not known. Null for any other type.
|
||||
*/
|
||||
function walkTimesFor(input: TourPlannerFactsInput, walkingMinutes: number | null): WalkTimes | null {
|
||||
if (input.tourType !== 'walk') return null;
|
||||
return walkTimesOf({ stayMinutes: 0, unknownStays: 0, stations: 0, ...input.stays, walkingMinutes });
|
||||
}
|
||||
|
||||
/** What the type adds to a save: a hike its routing limit, a bike tour its profile, a paddle tour its boat, water and speed. */
|
||||
function typeFieldsOf(input: TourPlannerFactsInput) {
|
||||
const isBike = input.tourType === 'bike';
|
||||
@@ -92,16 +109,17 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
|
||||
hiking_preferences: null,
|
||||
};
|
||||
}
|
||||
const isHike = input.tourType === 'hike';
|
||||
return {
|
||||
tour_type: input.tourType,
|
||||
max_hiking_difficulty: isBike ? null : input.maxHikingDifficulty,
|
||||
max_hiking_difficulty: isHike ? input.maxHikingDifficulty : null,
|
||||
bike_profile: isBike ? input.bikeProfile : null,
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
hiking_preferences: isBike ? null : input.hikingPreferences,
|
||||
hiking_preferences: isHike ? input.hikingPreferences : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,22 +132,28 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
|
||||
const { durationSeconds, breakAdditionalMinutes, plannedDurationMinutes } = input;
|
||||
const routerMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60);
|
||||
const walkingDurationMinutes = movingMinutes(input, routerMinutes);
|
||||
const automaticPlannedTotalMinutes =
|
||||
walkingDurationMinutes === null ? null : walkingDurationMinutes + (breakAdditionalMinutes ?? 0);
|
||||
const walk = walkTimesFor(input, walkingDurationMinutes);
|
||||
const withBreaks = walkingDurationMinutes === null ? null : walkingDurationMinutes + (breakAdditionalMinutes ?? 0);
|
||||
const automaticPlannedTotalMinutes = walk ? walk.totalMinutes : withBreaks;
|
||||
const { boatType, waterContext } = input.paddle;
|
||||
return {
|
||||
isPaddle,
|
||||
websiteInvalid: input.website.trim().length > 0 && !tourWebsiteSchema.safeParse(input.website).success,
|
||||
plannedDurationInvalid: invalidMinutes(plannedDurationMinutes),
|
||||
breakAdditionalInvalid: invalidMinutes(breakAdditionalMinutes),
|
||||
// A city walk sends no breaks (CW-02), so one left from another type cannot hold its save up.
|
||||
breakAdditionalInvalid: input.tourType !== 'walk' && invalidMinutes(breakAdditionalMinutes),
|
||||
paddleIncomplete: isPaddle && (boatType === null || waterContext === null),
|
||||
walkingDurationMinutes,
|
||||
/** CW-02: a city walk's walking time, stays and total; null for any other type. */
|
||||
stationStays: walk,
|
||||
automaticPlannedTotalMinutes,
|
||||
plannedTotalMinutes: plannedDurationMinutes ?? automaticPlannedTotalMinutes,
|
||||
plannedTotalInvalid:
|
||||
plannedDurationMinutes === null &&
|
||||
automaticPlannedTotalMinutes !== null &&
|
||||
automaticPlannedTotalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES,
|
||||
// A city walk's stops are its stays: it saves no breaks, so a cafe never counts as a stay and a break at once.
|
||||
savedBreakAdditionalMinutes: input.tourType === 'walk' ? null : breakAdditionalMinutes,
|
||||
// The router's own time stands in for a hike without heights; a paddle tour has only its speed.
|
||||
savedDurationSeconds:
|
||||
walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60,
|
||||
|
||||
@@ -88,6 +88,58 @@ describe('tourPlannerFacts', () => {
|
||||
expect(unknown.walkingDurationMinutes).toBeNull();
|
||||
expect(unknown.savedDurationSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it('CW-02: times a city walk by the router on foot, its stays on top in place of breaks, and saves no hiking limit', () => {
|
||||
const analysis = { distanceKm: 4, gain: 300, loss: 300 } as TourPlannerFactsInput['routeAnalysis'];
|
||||
const walk = hike({
|
||||
tourType: 'walk',
|
||||
routeAnalysis: analysis,
|
||||
durationSeconds: 3_000,
|
||||
breakAdditionalMinutes: 40,
|
||||
stays: { stayMinutes: 75, unknownStays: 0, stations: 2 },
|
||||
hikingPreferences: { avoid_steps: true },
|
||||
});
|
||||
const facts = tourPlannerFacts(walk);
|
||||
expect(facts.walkingDurationMinutes).toBe(50);
|
||||
expect(facts.automaticPlannedTotalMinutes).toBe(125);
|
||||
expect(facts.plannedTotalMinutes).toBe(125);
|
||||
expect(facts.savedDurationSeconds).toBe(3_000);
|
||||
expect(facts.typeFields).toMatchObject({
|
||||
tour_type: 'walk',
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: null,
|
||||
bike_profile: null,
|
||||
boat_type: null,
|
||||
});
|
||||
expect(facts.stationStays).toMatchObject({
|
||||
walkingMinutes: 50,
|
||||
stayMinutes: 75,
|
||||
totalMinutes: 125,
|
||||
partial: false,
|
||||
});
|
||||
// Stays nobody entered count for nothing; a total typed by hand still wins.
|
||||
expect(tourPlannerFacts({ ...walk, stays: null }).automaticPlannedTotalMinutes).toBe(50);
|
||||
expect(tourPlannerFacts({ ...walk, plannedDurationMinutes: 180 }).plannedTotalMinutes).toBe(180);
|
||||
expect(tourPlannerFacts({ ...walk, stays: null, durationSeconds: null }).automaticPlannedTotalMinutes).toBeNull();
|
||||
});
|
||||
|
||||
it('CW-02: a walk whose walking time is not known plans at least the stays entered, as its overview says', () => {
|
||||
const walk = hike({
|
||||
tourType: 'walk',
|
||||
durationSeconds: null,
|
||||
stays: { stayMinutes: 120, unknownStays: 1, stations: 3 },
|
||||
});
|
||||
const facts = tourPlannerFacts(walk);
|
||||
expect(facts.walkingDurationMinutes).toBeNull();
|
||||
expect(facts.plannedTotalMinutes).toBe(120);
|
||||
expect(facts.stationStays).toMatchObject({ walkingMinutes: null, totalMinutes: 120, partial: true });
|
||||
expect(facts.plannedTotalInvalid).toBe(false);
|
||||
// Stays past the longest plan a tour may have hold the save up as the walking time would.
|
||||
expect(
|
||||
tourPlannerFacts({ ...walk, stays: { stayMinutes: 1_500, unknownStays: 0, stations: 2 } }).plannedTotalInvalid
|
||||
).toBe(true);
|
||||
expect(tourPlannerFacts(hike({ durationSeconds: 600 })).stationStays).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePlannerHistory', () => {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-005: the editor's waypoint model, shaping points and drawn legs (P2A-03, PAD-11).
|
||||
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-010: the editor's waypoint model, shaping points, drawn legs
|
||||
// and a city walk's stations with the keys a save answers (P2A-03, PAD-11, CW-01 to CW-04).
|
||||
import type { TourWaypoint } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizePlannerWaypoints,
|
||||
plannerWaypointsOf,
|
||||
routeExtrasKey,
|
||||
routeInputsKey,
|
||||
sameWaypointSnapshot,
|
||||
savedWaypointsOf,
|
||||
tourWaypointKey,
|
||||
withDrawnLegsRouted,
|
||||
withSavedKeys,
|
||||
withShaping,
|
||||
withShapingPoint,
|
||||
type TourPlannerWaypoint,
|
||||
@@ -80,4 +87,140 @@ describe('tourPlannerWaypoints', () => {
|
||||
expect(sameWaypointSnapshot([A, B, C], [A, B, C])).toBe(true);
|
||||
expect(normalizePlannerWaypoints([C, A, B]).map((point) => point.role)).toEqual(['start', 'via', 'end']);
|
||||
});
|
||||
|
||||
const SAVED: TourWaypoint[] = [
|
||||
{
|
||||
lat: 48.1,
|
||||
lng: 11.5,
|
||||
role: 'start',
|
||||
sequence: 0,
|
||||
key: 'stop-cathedral',
|
||||
name: 'Cathedral',
|
||||
stay_minutes: 30,
|
||||
importance: 'must',
|
||||
protected: true,
|
||||
opening_hours: 'Mo-Su 09:00-18:00',
|
||||
},
|
||||
{ lat: 48.105, lng: 11.505, role: 'via', sequence: 1, key: 'shape-1', shaping: true },
|
||||
{ lat: 48.11, lng: 11.51, role: 'end', sequence: 2, key: 'stop-market', stay_minutes: 0 },
|
||||
];
|
||||
const ids = () => {
|
||||
let next = 0;
|
||||
return () => `p${++next}`;
|
||||
};
|
||||
|
||||
it("FE-TOUR-WAYPOINTS-006: a saved walk's stations come into the editor with their key, name, stay, importance and hours", () => {
|
||||
const points = plannerWaypointsOf(SAVED, ids());
|
||||
expect(points).toEqual([
|
||||
{
|
||||
id: 'p1',
|
||||
lat: 48.1,
|
||||
lng: 11.5,
|
||||
role: 'start',
|
||||
key: 'stop-cathedral',
|
||||
name: 'Cathedral',
|
||||
stayMinutes: 30,
|
||||
importance: 'must',
|
||||
protected: true,
|
||||
openingHours: 'Mo-Su 09:00-18:00',
|
||||
},
|
||||
{ id: 'p2', lat: 48.105, lng: 11.505, role: 'via', key: 'shape-1', shaping: true },
|
||||
// A stay of 0 is a point passed through, kept as such.
|
||||
{ id: 'p3', lat: 48.11, lng: 11.51, role: 'end', key: 'stop-market', stayMinutes: 0 },
|
||||
]);
|
||||
expect(savedWaypointsOf(points, 'walk')).toEqual(SAVED);
|
||||
});
|
||||
|
||||
it("FE-TOUR-WAYPOINTS-007: another type sends a station's key, name and hours, but no stay, importance or protection", () => {
|
||||
const points = plannerWaypointsOf(SAVED, ids());
|
||||
const plain = [
|
||||
{
|
||||
lat: 48.1,
|
||||
lng: 11.5,
|
||||
role: 'start',
|
||||
sequence: 0,
|
||||
key: 'stop-cathedral',
|
||||
name: 'Cathedral',
|
||||
opening_hours: 'Mo-Su 09:00-18:00',
|
||||
},
|
||||
{ lat: 48.105, lng: 11.505, role: 'via', sequence: 1, key: 'shape-1', shaping: true },
|
||||
{ lat: 48.11, lng: 11.51, role: 'end', sequence: 2, key: 'stop-market' },
|
||||
];
|
||||
expect(savedWaypointsOf(points, 'hike')).toEqual(plain);
|
||||
expect(savedWaypointsOf(points)).toEqual(plain);
|
||||
});
|
||||
|
||||
it("FE-TOUR-WAYPOINTS-008: a station's own fields make another step of the history, but no other route", () => {
|
||||
const edits: Array<Partial<TourPlannerWaypoint>> = [
|
||||
{ name: 'Cathedral' },
|
||||
{ stayMinutes: 0 },
|
||||
{ importance: 'optional' },
|
||||
{ protected: true },
|
||||
{ openingHours: 'Mo-Fr 10:00-17:00' },
|
||||
];
|
||||
for (const edit of edits) {
|
||||
const edited = [A, { ...B, ...edit }, C];
|
||||
expect(sameWaypointSnapshot([A, B, C], edited)).toBe(false);
|
||||
expect(tourWaypointKey(edited)).toBe(tourWaypointKey([A, B, C]));
|
||||
expect(routeExtrasKey(edited)).toBe(routeExtrasKey([A, B, C]));
|
||||
}
|
||||
// The key a save answered is no change of the draft at all.
|
||||
expect(sameWaypointSnapshot([A, B, C], [A, { ...B, key: 'stop-b' }, C])).toBe(true);
|
||||
expect(routeExtrasKey([A, { ...B, linkedPlaceId: 4 }, C])).not.toBe(routeExtrasKey([A, B, C]));
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINTS-009: the keys a save answered go onto the points by position, and nothing new answers the same list', () => {
|
||||
const points = [A, { ...B, key: 'stop-b' }, C];
|
||||
const keyed = withSavedKeys(points, [
|
||||
{ lat: A.lat, lng: A.lng, role: 'start', sequence: 0, key: 'stop-a' },
|
||||
{ lat: B.lat, lng: B.lng, role: 'via', sequence: 1, key: 'stop-b' },
|
||||
{ lat: C.lat, lng: C.lng, role: 'end', sequence: 2, key: 'stop-c' },
|
||||
]);
|
||||
expect(keyed.map((point) => point.key)).toEqual(['stop-a', 'stop-b', 'stop-c']);
|
||||
expect(keyed[1]).toBe(points[1]);
|
||||
expect(keyed.map((point) => point.id)).toEqual(['a', 'b', 'c']);
|
||||
|
||||
expect(
|
||||
withSavedKeys(
|
||||
keyed,
|
||||
keyed.map((point, sequence) => ({ ...point, sequence }))
|
||||
)
|
||||
).toBe(keyed);
|
||||
// An answer without keys, as an offline save gives, or of another length leaves the points as they are.
|
||||
expect(withSavedKeys(points, savedWaypointsOf([A, B, C]))).toBe(points);
|
||||
expect(withSavedKeys(points, [])).toBe(points);
|
||||
});
|
||||
|
||||
it("FE-TOUR-WAYPOINTS-010: what a route is planned from leaves a station's own fields and its key out, and nothing else", () => {
|
||||
const stations: Array<Partial<TourPlannerWaypoint>> = [
|
||||
{ name: 'Cathedral' },
|
||||
{ stayMinutes: 45 },
|
||||
{ stayMinutes: 0 },
|
||||
{ importance: 'must' },
|
||||
{ protected: true },
|
||||
{ openingHours: 'Mo-Su 09:00-18:00' },
|
||||
{ key: 'stop-b' },
|
||||
];
|
||||
for (const edit of stations) expect(routeInputsKey([A, { ...B, ...edit }, C])).toBe(routeInputsKey([A, B, C]));
|
||||
|
||||
const routed: Array<Partial<TourPlannerWaypoint>> = [
|
||||
{ lat: 49.011 },
|
||||
{ lng: 12.011 },
|
||||
{ shaping: true },
|
||||
{ lock: { wait_minutes: 10, transit_minutes: null } },
|
||||
{
|
||||
fixedLeg: {
|
||||
kind: 'free',
|
||||
geometry: [
|
||||
[49.01, 12.01],
|
||||
[49.02, 12.02],
|
||||
],
|
||||
},
|
||||
},
|
||||
{ linkedPlaceId: 4 },
|
||||
{ id: 'other' },
|
||||
];
|
||||
for (const edit of routed) expect(routeInputsKey([A, { ...B, ...edit }, C])).not.toBe(routeInputsKey([A, B, C]));
|
||||
expect(routeInputsKey([A, C])).not.toBe(routeInputsKey([A, B, C]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { PaddleLock, TourFixedLeg, TourWaypoint, TourWaypointRole } from '@trek/shared';
|
||||
import type {
|
||||
PaddleLock,
|
||||
PlannableTourType,
|
||||
StationImportance,
|
||||
TourFixedLeg,
|
||||
TourPoiCategory,
|
||||
TourWaypoint,
|
||||
TourWaypointRole,
|
||||
} from '@trek/shared';
|
||||
import type { AutoBase } from './paddleRouting';
|
||||
import { keepJoinedFixedLegs } from './tourFixedLegs';
|
||||
import { alignFreeLegs } from './tourRouteActions';
|
||||
@@ -21,6 +29,20 @@ export interface TourPlannerWaypoint {
|
||||
carryNext?: boolean;
|
||||
/** PR1-10: the place of the trip this point stands for, chosen on purpose; the point keeps its own spot. */
|
||||
linkedPlaceId?: number;
|
||||
/** CW-03: the point's saved key, the same across saves, which a city walk's visits hang on. */
|
||||
key?: string;
|
||||
/** The point's own name: a city walk's station, a place found along the route. */
|
||||
name?: string | null;
|
||||
/** CW-02: a station's stay; null not known, 0 a point passed through. */
|
||||
stayMinutes?: number | null;
|
||||
/** CW-03: how much a station matters; absent is normal. */
|
||||
importance?: StationImportance | null;
|
||||
/** CW-10: a station a proposed change must leave where it is. */
|
||||
protected?: boolean;
|
||||
/** CW-04: the station's opening hours as OpenStreetMap writes them. */
|
||||
openingHours?: string | null;
|
||||
/** PLN-03: the kind of place the point was taken in from along the tour, whose icon its row carries. */
|
||||
poiCategory?: TourPoiCategory | null;
|
||||
}
|
||||
|
||||
/** A saved tour's waypoints as the editor holds them, each under a fresh id. */
|
||||
@@ -34,6 +56,13 @@ export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () =>
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
...(point.shaping ? { shaping: true } : {}),
|
||||
...(point.linked_place_id ? { linkedPlaceId: point.linked_place_id } : {}),
|
||||
...(point.key ? { key: point.key } : {}),
|
||||
...(point.name ? { name: point.name } : {}),
|
||||
...(point.stay_minutes != null ? { stayMinutes: point.stay_minutes } : {}),
|
||||
...(point.importance ? { importance: point.importance } : {}),
|
||||
...(point.protected ? { protected: true } : {}),
|
||||
...(point.opening_hours ? { openingHours: point.opening_hours } : {}),
|
||||
...(point.poi_category ? { poiCategory: point.poi_category } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -42,7 +71,9 @@ export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () =>
|
||||
* keeps and nothing of the editor's own (ids, automatic marks, a portage
|
||||
* waiting for its next point).
|
||||
*/
|
||||
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWaypoint[] {
|
||||
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[], tourType?: PlannableTourType): TourWaypoint[] {
|
||||
// A stay, an importance and a protection belong to a city walk's stations; another type sends none.
|
||||
const walk = tourType === 'walk';
|
||||
return points.map((point, sequence) => ({
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
@@ -52,6 +83,13 @@ export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWa
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
...(point.shaping ? { shaping: true } : {}),
|
||||
...(point.linkedPlaceId ? { linked_place_id: point.linkedPlaceId } : {}),
|
||||
...(point.key ? { key: point.key } : {}),
|
||||
...(point.name ? { name: point.name } : {}),
|
||||
...(walk && point.stayMinutes != null ? { stay_minutes: point.stayMinutes } : {}),
|
||||
...(walk && point.importance ? { importance: point.importance } : {}),
|
||||
...(walk && point.protected ? { protected: true } : {}),
|
||||
...(point.openingHours ? { opening_hours: point.openingHours } : {}),
|
||||
...(point.poiCategory ? { poi_category: point.poiCategory } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -93,21 +131,82 @@ export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The line, and what was planned along it that draws no line: a portage's
|
||||
* time, a lock, a point the router set, the place a point stands for.
|
||||
* What was planned along the line that draws no line: a portage's time, a
|
||||
* lock, a point the router set, the place a point stands for. With
|
||||
* {@link tourWaypointKey} it is what a route is planned from.
|
||||
*/
|
||||
export function routeExtrasKey(points: TourPlannerWaypoint[]): string {
|
||||
return JSON.stringify(
|
||||
points.map((point) => [
|
||||
point.fixedLeg?.kind === 'portage' ? (point.fixedLeg.extra_minutes ?? null) : null,
|
||||
point.lock ?? null,
|
||||
!!point.auto,
|
||||
!!point.carryNext,
|
||||
point.linkedPlaceId ?? null,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02: everything a route is planned from: every point with all it holds but
|
||||
* a station's own fields (its name, stay, importance, protection, opening
|
||||
* hours and the kind of place it was found as) and the key a save gave it. A change to those alone leaves the route
|
||||
* as it is; any other change plans it again.
|
||||
*/
|
||||
export function routeInputsKey(points: readonly TourPlannerWaypoint[]): string {
|
||||
return JSON.stringify(
|
||||
points.map(
|
||||
({
|
||||
name: _name,
|
||||
stayMinutes: _stay,
|
||||
importance: _importance,
|
||||
protected: _protected,
|
||||
openingHours: _hours,
|
||||
poiCategory: _kind,
|
||||
key: _key,
|
||||
...routed
|
||||
}) => routed
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** CW-01 to CW-04: what a city walk's station holds besides its spot, none of which the route depends on. */
|
||||
function stationFieldsKey(points: TourPlannerWaypoint[]): string {
|
||||
return JSON.stringify(
|
||||
points.map((point) => [
|
||||
point.name ?? null,
|
||||
point.stayMinutes ?? null,
|
||||
point.importance ?? null,
|
||||
!!point.protected,
|
||||
point.openingHours ?? null,
|
||||
point.poiCategory ?? null,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** The same step of the history: the line, what was planned along it, and every station's own fields. */
|
||||
export function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
|
||||
const extras = (points: TourPlannerWaypoint[]) =>
|
||||
JSON.stringify(
|
||||
points.map((point) => [
|
||||
point.fixedLeg?.kind === 'portage' ? (point.fixedLeg.extra_minutes ?? null) : null,
|
||||
point.lock ?? null,
|
||||
!!point.auto,
|
||||
!!point.carryNext,
|
||||
point.linkedPlaceId ?? null,
|
||||
])
|
||||
);
|
||||
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b);
|
||||
return (
|
||||
tourWaypointKey(a) === tourWaypointKey(b) &&
|
||||
routeExtrasKey(a) === routeExtrasKey(b) &&
|
||||
stationFieldsKey(a) === stationFieldsKey(b)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03: the keys a save was answered with, taken over by position. The server
|
||||
* keeps the points of a save in their order and gives one sent without a key
|
||||
* a key of its own; sent back with the next save, it keeps the visits made on
|
||||
* that station. An answer of another length is left alone, and nothing new
|
||||
* answers the same array.
|
||||
*/
|
||||
export function withSavedKeys(points: TourPlannerWaypoint[], saved: readonly TourWaypoint[]): TourPlannerWaypoint[] {
|
||||
if (saved.length !== points.length) return points;
|
||||
const keyed = points.map((point, index) => {
|
||||
const key = saved[index]?.key;
|
||||
return key && key !== point.key ? { ...point, key } : point;
|
||||
});
|
||||
return keyed.some((point, index) => point !== points[index]) ? keyed : points;
|
||||
}
|
||||
|
||||
/** P2A-03: the points with a shaping point set into the leg after `afterId`; unchanged when there is no such leg. */
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { isClosedLoop } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import {
|
||||
alignFreeLegs,
|
||||
closeLoop,
|
||||
freeLegBetween,
|
||||
isClosedLoop,
|
||||
portageLegBetween,
|
||||
reverseRoute,
|
||||
setPortageExtraMinutes,
|
||||
@@ -27,18 +28,6 @@ const FERRATA = {
|
||||
] as [number, number][],
|
||||
};
|
||||
|
||||
describe('isClosedLoop', () => {
|
||||
it('reads a loop from the points: the end on the spot of the start', () => {
|
||||
expect(isClosedLoop([A, B, { ...A, id: 'a2' }])).toBe(true);
|
||||
expect(isClosedLoop([A, B, C])).toBe(false);
|
||||
});
|
||||
|
||||
it('is no loop with two points on one spot, or none', () => {
|
||||
expect(isClosedLoop([A, { ...A, id: 'a2' }])).toBe(false);
|
||||
expect(isClosedLoop([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeLoop (PR1-01)', () => {
|
||||
it('adds one end on the start, under a new id, and keeps every stop', () => {
|
||||
const closed = closeLoop([A, B, C], 'back');
|
||||
@@ -57,6 +46,23 @@ describe('closeLoop (PR1-01)', () => {
|
||||
const closed = closeLoop([{ ...A, fixedLeg: freeLegBetween(A, B) }, B], 'back');
|
||||
expect(closed[2].fixedLeg).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes none of a city walk's start station onto the way back, only its spot and the place it stands for", () => {
|
||||
const cathedral: TourPlannerWaypoint = {
|
||||
...A,
|
||||
role: 'start',
|
||||
key: 'stop-cathedral',
|
||||
name: 'Cathedral',
|
||||
stayMinutes: 30,
|
||||
importance: 'must',
|
||||
protected: true,
|
||||
openingHours: 'Mo-Su 09:00-18:00',
|
||||
linkedPlaceId: 7,
|
||||
};
|
||||
const closed = closeLoop<TourPlannerWaypoint>([cathedral, { ...B, role: 'via' }, { ...C, role: 'end' }], 'back');
|
||||
expect(closed[3]).toEqual({ id: 'back', lat: A.lat, lng: A.lng, role: 'start', linkedPlaceId: 7 });
|
||||
expect(closed[0]).toBe(cathedral);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverseRoute (PR1-02)', () => {
|
||||
@@ -79,6 +85,28 @@ describe('reverseRoute (PR1-02)', () => {
|
||||
expect(isClosedLoop(reversed)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a city walk's start station first when its loop runs the other way, and the way back no station", () => {
|
||||
const start = { ...A, key: 'stop-a', name: 'Cathedral', stayMinutes: 30, importance: 'must', protected: true };
|
||||
const loop = closeLoop<RoutePoint & Partial<TourPlannerWaypoint>>(
|
||||
[start as RoutePoint & Partial<TourPlannerWaypoint>, B, C],
|
||||
'back'
|
||||
);
|
||||
const reversed = reverseRoute(loop);
|
||||
expect(reversed.map((point) => point.id)).toEqual(['back', 'c', 'b', 'a']);
|
||||
expect(reversed[0]).toEqual({
|
||||
id: 'back',
|
||||
lat: A.lat,
|
||||
lng: A.lng,
|
||||
key: 'stop-a',
|
||||
name: 'Cathedral',
|
||||
stayMinutes: 30,
|
||||
importance: 'must',
|
||||
protected: true,
|
||||
});
|
||||
expect(reversed[3]).toEqual({ id: 'a', lat: A.lat, lng: A.lng });
|
||||
expect(reverseRoute(reversed)).toEqual(loop);
|
||||
});
|
||||
|
||||
it('twice is the tour as it was', () => {
|
||||
const route = [{ ...A, fixedLeg: freeLegBetween(A, B) }, { ...B, fixedLeg: FERRATA }, C];
|
||||
expect(reverseRoute(reverseRoute(route))).toEqual(route);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PaddleLock, TourFixedLeg } from '@trek/shared';
|
||||
import { isClosedLoop, type PaddleLock, type TourFixedLeg } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The route tools of the tour planner as plain list operations: reversing the
|
||||
@@ -22,22 +22,23 @@ export interface RoutePoint {
|
||||
carryNext?: boolean;
|
||||
}
|
||||
|
||||
/** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */
|
||||
const SAME_SPOT_DEG = 1e-7;
|
||||
|
||||
function sameSpot(a: { lat: number; lng: number }, b: { lat: number; lng: number }): boolean {
|
||||
return Math.abs(a.lat - b.lat) < SAME_SPOT_DEG && Math.abs(a.lng - b.lng) < SAME_SPOT_DEG;
|
||||
/** What makes the start a station of a city walk (CW-01 to CW-04), and its saved key (CW-03). */
|
||||
interface StationParts {
|
||||
key?: string;
|
||||
name?: string | null;
|
||||
stayMinutes?: number | null;
|
||||
importance?: string | null;
|
||||
protected?: boolean;
|
||||
openingHours?: string | null;
|
||||
poiCategory?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tour ends where it starts. Read from the points themselves, so a
|
||||
* saved loop opens as a loop again without a flag of its own; a tour of two
|
||||
* points on one spot is no loop, there is nothing to return from.
|
||||
*/
|
||||
export function isClosedLoop(points: readonly { lat: number; lng: number }[]): boolean {
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
return points.length >= 3 && !!first && !!last && sameSpot(first, last);
|
||||
/** A point split into what makes it a station and the rest of it; a part it does not have is left out. */
|
||||
function stationSplit<T extends RoutePoint>(point: T & StationParts): { station: StationParts; rest: T } {
|
||||
const { key, name, stayMinutes, importance, protected: kept, openingHours, poiCategory, ...rest } = point;
|
||||
const parts: StationParts = { key, name, stayMinutes, importance, protected: kept, openingHours, poiCategory };
|
||||
const station = Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined));
|
||||
return { station, rest: rest as T };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,11 +57,13 @@ export function withPointAppended<T extends { lat: number; lng: number; role: st
|
||||
* The tour taken back from its last point to its start: one end point on the
|
||||
* start's spot, not a second copy of the start among the stops. `newId` names
|
||||
* it; a tour that already returns, or has nothing to return from, is left as it is.
|
||||
* The way back is no station of its own, so it takes neither the start's key
|
||||
* nor its name, stay, importance, protection or opening hours.
|
||||
*/
|
||||
export function closeLoop<T extends RoutePoint>(points: readonly T[], newId: string): T[] {
|
||||
const first = points[0];
|
||||
if (!first || points.length < 2 || isClosedLoop(points)) return [...points];
|
||||
const { fixedLeg: _leg, lock: _lock, ...start } = first;
|
||||
const { fixedLeg: _leg, lock: _lock, ...start } = stationSplit(first).rest;
|
||||
return [...points, { ...start, id: newId } as T];
|
||||
}
|
||||
|
||||
@@ -68,10 +71,11 @@ export function closeLoop<T extends RoutePoint>(points: readonly T[], newId: str
|
||||
* The same tour the other way round: the last point starts it, the stops run
|
||||
* backwards, and a fixed leg (a via ferrata, a hand-drawn stretch) stays on the
|
||||
* stretch it covered, its line turned round with it. A loop stays a loop from
|
||||
* the same spot.
|
||||
* the same spot, and its start stays the station there: the way back is no
|
||||
* station, so the start's key, name and stay stay first (CW-02, CW-03).
|
||||
*/
|
||||
export function reverseRoute<T extends RoutePoint>(points: readonly T[]): T[] {
|
||||
return points.map((_, index) => {
|
||||
const reversed = points.map((_, index) => {
|
||||
const point = points[points.length - 1 - index];
|
||||
// In the new order the leg after this point is the one that led into it before.
|
||||
const before = points[points.length - 2 - index];
|
||||
@@ -80,6 +84,10 @@ export function reverseRoute<T extends RoutePoint>(points: readonly T[]): T[] {
|
||||
if (!leg || index === points.length - 1) return rest as T;
|
||||
return { ...rest, fixedLeg: { ...leg, geometry: [...leg.geometry].reverse() } } as T;
|
||||
});
|
||||
if (!isClosedLoop(points)) return reversed;
|
||||
const start = stationSplit(reversed[0]);
|
||||
const back = stationSplit(reversed[reversed.length - 1]);
|
||||
return [{ ...start.rest, ...back.station } as T, ...reversed.slice(1, -1), { ...back.rest, ...start.station } as T];
|
||||
}
|
||||
|
||||
/** A hand-drawn leg from this point straight to the next. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TOUR_MAX_SNAP_M } from '@trek/shared';
|
||||
import type { TourRouteResult } from './tourRouting';
|
||||
|
||||
/**
|
||||
@@ -17,4 +18,4 @@ export function isTourRoute(value: TourRouteResult | TourRouteUnreachable | null
|
||||
}
|
||||
|
||||
/** How far the router may move a point onto its way before the point counts as unreachable for the tour's type. */
|
||||
export const MAX_TOUR_SNAP_M = 300;
|
||||
export const MAX_TOUR_SNAP_M = TOUR_MAX_SNAP_M;
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('../../Map/valhallaRoute', () => ({
|
||||
valhallaTurn,
|
||||
}))
|
||||
|
||||
import { enrichTourElevations, parseValhallaElevation, routeBikeTour, routeWalkingTour, type TourRouteResult } from './tourRouting'
|
||||
import { enrichTourElevations, parseValhallaElevation, routeBikeTour, routeCityWalk, routeWalkingTour, type TourRouteResult } from './tourRouting'
|
||||
|
||||
/** A route as the planner reads it, for the cases that expect one. */
|
||||
const route = async (...args: Parameters<typeof routeWalkingTour>) => (await routeWalkingTour(...args)) as TourRouteResult | null
|
||||
@@ -229,4 +229,10 @@ describe('routing on land, bike profiles and unreachable points (BIK-01)', () =>
|
||||
await routeWalkingTour([{ lat: 47.39, lng: 11.01, fixedLeg: free }, { lat: 47.392, lng: 11.012 }, { lat: 47.4, lng: 11.02 }])
|
||||
).toEqual({ unreachable: 1, snappedMeters: 400 })
|
||||
})
|
||||
|
||||
it('TOUR-ROUTING-033: a city walk goes on foot with no hiking limit, no choice of a hike, and avoids ferries (CW-01)', async () => {
|
||||
valhallaRun.mockResolvedValueOnce(run([3, 4]))
|
||||
expect(await routeCityWalk(waypoints)).toMatchObject({ distanceMeters: 1500, durationSeconds: 300 })
|
||||
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, { use_ferry: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,6 +103,22 @@ export async function routeWalkingTour(
|
||||
}, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-01: a city walk, on foot between its stations. No hiking limit is sent,
|
||||
* so the router keeps to the ways it takes for walking anyway (T1), and no
|
||||
* route choice of a hike. Ferries are avoided: a walk is walked, and with the
|
||||
* router's default Venice was crossed on the vaporetto lines instead of the
|
||||
* bridges. Where no way on land exists the router still takes the ferry.
|
||||
*/
|
||||
export async function routeCityWalk(
|
||||
waypoints: LandWaypoint[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<TourRouteResult | TourRouteUnreachable | null> {
|
||||
return routeOnLand(waypoints, 'walking', CITY_WALK_COSTING, signal)
|
||||
}
|
||||
|
||||
const CITY_WALK_COSTING: Readonly<CostingOptions> = { use_ferry: 0 }
|
||||
|
||||
/** BIK-01: a bike tour, routed for its profile; a leg drawn by hand is kept as drawn. */
|
||||
export async function routeBikeTour(
|
||||
waypoints: LandWaypoint[],
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { TourCreateRequest, TourCreateResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan, paddleForget } =
|
||||
vi.hoisted(() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
paddlePlan: vi.fn(),
|
||||
paddleForget: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
routeWalkingTour,
|
||||
routeCityWalk,
|
||||
enrichTourElevations,
|
||||
createTour,
|
||||
detailTour,
|
||||
updateTour,
|
||||
paddlePlan,
|
||||
paddleForget,
|
||||
} = vi.hoisted(() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
routeCityWalk: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
paddlePlan: vi.fn(),
|
||||
paddleForget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: paddlePlan, forget: paddleForget } }));
|
||||
vi.mock('../useTourPermissions', () => ({
|
||||
@@ -95,6 +105,7 @@ describe('useTourPlanner route tools', () => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||
routeCityWalk.mockReset().mockResolvedValue(routed);
|
||||
paddlePlan.mockReset().mockImplementation(async (_trip: number, legs: LegAsked[]) => onWater(legs));
|
||||
paddleForget.mockReset();
|
||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||
@@ -526,4 +537,145 @@ describe('useTourPlanner route tools', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a city walk's keys and stops (CW-02, CW-03)", () => {
|
||||
/** The server's answer to a save: the points in their order, a key sent kept, one missing made up. */
|
||||
const answered = (body: TourCreateRequest, revision: number): TourCreateResponse => ({
|
||||
tour: { ...savedTour, tour_type: 'walk', revision },
|
||||
waypoints: body.waypoints.map((point, index) => ({ ...point, key: point.key ?? `new-${revision}-${index}` })),
|
||||
});
|
||||
/** The keys of the points a create or an update sent, the body being its last argument. */
|
||||
const keysSent = (call: unknown[] | undefined) =>
|
||||
(call?.[call.length - 1] as TourCreateRequest).waypoints.map((point) => point.key);
|
||||
|
||||
/** A walk through these points, named and routed, ready to save. */
|
||||
async function walkPlanned(points: Array<[number, number]>) {
|
||||
createTour.mockImplementation(async (_trip: number, body: TourCreateRequest) => answered(body, 1));
|
||||
updateTour.mockImplementation(async (_trip: number, _place: number, body: TourCreateRequest) =>
|
||||
answered(body, (updateTour.mock.calls.length as number) + 1)
|
||||
);
|
||||
const hook = plannerWith(points);
|
||||
act(() => hook.result.current.setTourType('walk'));
|
||||
act(() => hook.result.current.setName('Old town'));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
return hook;
|
||||
}
|
||||
|
||||
it('sends the keys a save answered with the next save, and plans no route again for them', async () => {
|
||||
const { result } = await walkPlanned([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
const routings = routeCityWalk.mock.calls.length;
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(keysSent(createTour.mock.calls[0])).toEqual([undefined, undefined, undefined]);
|
||||
expect(result.current.waypoints.map((point) => point.key)).toEqual(['new-1-0', 'new-1-1', 'new-1-2']);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(routings);
|
||||
expect(result.current.status).toBe('saved');
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
|
||||
act(() => result.current.setName('Old town at dusk'));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(keysSent(updateTour.mock.calls[0])).toEqual(['new-1-0', 'new-1-1', 'new-1-2']);
|
||||
});
|
||||
|
||||
it("closes a walk without the start's key, and the way back keeps the key it was given", async () => {
|
||||
const { result } = await walkPlanned([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
act(() => result.current.returnToStart());
|
||||
expect(result.current.waypoints.map((point) => point.key)).toEqual(['new-1-0', 'new-1-1', 'new-1-2', undefined]);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(result.current.waypoints.map((point) => point.key)).toEqual(['new-1-0', 'new-1-1', 'new-1-2', 'new-2-3']);
|
||||
|
||||
act(() => result.current.setName('Round the old town'));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(keysSent(updateTour.mock.calls[1])).toEqual(['new-1-0', 'new-1-1', 'new-1-2', 'new-2-3']);
|
||||
});
|
||||
|
||||
it("takes a stop's stay from its dialog as a step of its own and saves it, without routing the walk again", async () => {
|
||||
const { result } = await walkPlanned([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
const routings = routeCityWalk.mock.calls.length;
|
||||
const stop = must(result.current.waypoints[1]);
|
||||
act(() =>
|
||||
result.current.editWaypoints((points) =>
|
||||
points.map((point) => (point.id === stop.id ? { ...point, name: 'Market', stayMinutes: 45 } : point))
|
||||
)
|
||||
);
|
||||
expect(result.current.waypoints[1]).toMatchObject({ name: 'Market', stayMinutes: 45 });
|
||||
expect(result.current.stationStays?.stayMinutes).toBe(45);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(routings);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.canSave).toBe(true);
|
||||
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.waypoints[1]?.stayMinutes).toBeUndefined();
|
||||
act(() => result.current.redo());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(routings);
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect((createTour.mock.calls[0]?.[1] as TourCreateRequest).waypoints[1]).toMatchObject({
|
||||
name: 'Market',
|
||||
stay_minutes: 45,
|
||||
});
|
||||
});
|
||||
|
||||
it('names a point of a paddle tour without planning the water again', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
const plans = paddlePlan.mock.calls.length;
|
||||
act(() =>
|
||||
result.current.editWaypoints((points) =>
|
||||
points.map((point, index) => (index === 1 ? { ...point, name: 'Boathouse' } : point))
|
||||
)
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
expect(paddlePlan).toHaveBeenCalledTimes(plans);
|
||||
expect(result.current.waypoints[1]?.name).toBe('Boathouse');
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { hikingPreferencesOf, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { hikingPreferencesOf, isClosedLoop, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
|
||||
import { tourRepo } from '../../../repo/tourRepo'
|
||||
import { randomId } from '../../../utils/randomId'
|
||||
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||
import { closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions'
|
||||
import { closeLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions'
|
||||
import { walkTimes } from './stationModel'
|
||||
import { hasCompleteElevation, useTourRoute } from './useTourRoute'
|
||||
import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints'
|
||||
import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, routeInputsKey, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, withSavedKeys, type TourPlannerWaypoint } from './tourPlannerWaypoints'
|
||||
import { openedTourFields, tourPlannerFacts } from './tourPlannerFacts'
|
||||
import { usePlannerHistory } from './usePlannerHistory'
|
||||
import { useTourDraftStorage } from './useTourDraftStorage'
|
||||
import { useTourRevisionGuard } from './useTourRevisionGuard'
|
||||
import { useRouteShaping } from './useRouteShaping'
|
||||
import { useTourMapView } from './useTourMapView'
|
||||
import type { DraftPayload } from './tourPlannerDraft'
|
||||
import type { DraftPayload, DraftSource } from './tourPlannerDraft'
|
||||
import { paddleEstimateOf } from './paddlePlanOf'
|
||||
import { adoptChangedPoints } from './paddleRouting'
|
||||
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
|
||||
@@ -25,9 +26,10 @@ export interface DerivedTourDraft {
|
||||
description?: string | null
|
||||
website?: string | null
|
||||
difficulty: TourMaxHikingDifficulty
|
||||
points: Array<{ lat: number; lng: number }>
|
||||
/** The import the points were taken from. */
|
||||
sourcePlaceId: number
|
||||
/** The control points in order; a city walk's stop keeps the name it came with (CW-07). */
|
||||
points: Array<{ lat: number; lng: number; name?: string | null }>
|
||||
/** The import the points were taken from; absent for a link read from outside the trip (CW-07). */
|
||||
sourcePlaceId?: number
|
||||
/**
|
||||
* Plan the import over in place, keeping its id, days and notes (the server
|
||||
* keeps the recorded track to go back to), rather than as a new tour beside it.
|
||||
@@ -39,6 +41,8 @@ export interface DerivedTourDraft {
|
||||
tourType?: PlannableTourType
|
||||
paddle?: PaddleFieldValues
|
||||
bikeProfile?: BikeProfile
|
||||
/** CW-07: where the points came from, said in the planner until the draft is saved. */
|
||||
source?: DraftSource
|
||||
}
|
||||
|
||||
export type TourPlannerStatus =
|
||||
@@ -58,6 +62,13 @@ interface HistoryEntry {
|
||||
tourType: PlannableTourType
|
||||
}
|
||||
|
||||
/** CW-02: two steps plan the same route: their points alike but for a station's own fields, and the same choices. */
|
||||
function sameRoute(a: HistoryEntry, b: HistoryEntry): boolean {
|
||||
const { waypoints: pointsA, ...choicesA } = a
|
||||
const { waypoints: pointsB, ...choicesB } = b
|
||||
return routeInputsKey(pointsA) === routeInputsKey(pointsB) && JSON.stringify(choicesA) === JSON.stringify(choicesB)
|
||||
}
|
||||
|
||||
/** Where a new tour was planned from; an edit leaves it as it was saved. */
|
||||
function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } {
|
||||
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
|
||||
@@ -125,6 +136,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const [draftRestored, setDraftRestored] = useState(Boolean(restored))
|
||||
const [editingPlaceId, setEditingPlaceId] = useState<number | null>(restored?.editingPlaceId ?? null)
|
||||
const [derivedFromPlaceId, setDerivedFromPlaceId] = useState<number | null>(restored?.derivedFromPlaceId ?? null)
|
||||
const [draftSource, setDraftSource] = useState<DraftSource | null>(restored?.draftSource ?? null)
|
||||
// CW-11: the open walk was saved with its stations only; its way is planned here and kept by the next save.
|
||||
const [routePending, setRoutePending] = useState(false)
|
||||
const [openingTourId, setOpeningTourId] = useState<number | null>(null)
|
||||
const [saveOutcome, setSaveOutcome] = useState<TourListItem | null>(null)
|
||||
const [retryToken, setRetryToken] = useState(0)
|
||||
@@ -232,17 +246,22 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
// A point the router set that the paddler moves or edits is theirs from then on.
|
||||
const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, paddle)
|
||||
if (sameWaypointSnapshot(current, next)) return
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
recordStep(snapshot())
|
||||
waypointsRef.current = next
|
||||
setWaypoints(next)
|
||||
// CW-02: a station's own fields (its stay, its name) plan no route: the routing goes on, and Save stays as it was.
|
||||
if (routeInputsKey(current) === routeInputsKey(next)) {
|
||||
markEdited()
|
||||
return
|
||||
}
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
setStatus('dirty')
|
||||
setError(null)
|
||||
}, [recordStep, invalidateDetailRequest, interruptRouting, snapshot])
|
||||
}, [recordStep, invalidateDetailRequest, interruptRouting, markEdited, snapshot])
|
||||
|
||||
// Detail laid in after the step that asked for it (a portage's way on land), with no step of its own.
|
||||
const amendWaypoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||
@@ -391,8 +410,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
/** A snapshot of the history put back: the route and what it depends on. */
|
||||
const restoreStep = useCallback((entry: HistoryEntry) => {
|
||||
// CW-02: a step that changed a station's own fields only comes back without planning the route again.
|
||||
const routeKept = sameRoute(entry, snapshot())
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
if (!routeKept) interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
waypointsRef.current = entry.waypoints
|
||||
difficultyRef.current = entry.difficulty
|
||||
@@ -406,9 +427,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setTourTypeState(entry.tourType)
|
||||
setSelectedWaypointId(id => entry.waypoints.some(point => point.id === id) ? id : entry.waypoints[0]?.id ?? null)
|
||||
setHasUnsavedChanges(true)
|
||||
if (routeKept) return
|
||||
setStatus('dirty')
|
||||
setError(null)
|
||||
}, [invalidateDetailRequest, interruptRouting])
|
||||
}, [invalidateDetailRequest, interruptRouting, snapshot])
|
||||
|
||||
const { undo: undoStep, redo: redoStep } = history
|
||||
const undo = useCallback(() => {
|
||||
@@ -478,14 +500,17 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const routedLegLengths = paddleRoute && routeCurrent ? paddleRoute.legLengths : undefined
|
||||
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
|
||||
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
|
||||
// CW-02: a city walk's stays, what of them is known.
|
||||
const stays = useMemo(() => (tourType === 'walk' ? walkTimes(waypoints, null) : null), [tourType, waypoints])
|
||||
const facts = useMemo(() => tourPlannerFacts({
|
||||
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, website, plannedDurationMinutes, breakAdditionalMinutes,
|
||||
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
|
||||
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds,
|
||||
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays,
|
||||
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
|
||||
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, tourType, waterContext, website])
|
||||
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stays, tourType, waterContext, website])
|
||||
// CW-02: a city walk's times as the overview says them, from the same derivation as its planned total.
|
||||
const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes,
|
||||
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, typeFields } = facts
|
||||
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays } = facts
|
||||
|
||||
const returnToNeutral = useCallback(() => {
|
||||
if (saveInFlightRef.current) return
|
||||
@@ -521,6 +546,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setEditingPlaceId(null)
|
||||
editingPlaceIdRef.current = null
|
||||
setDerivedFromPlaceId(null)
|
||||
setDraftSource(null)
|
||||
setRoutePending(false)
|
||||
adoptRevision(null)
|
||||
setOpeningTourId(null)
|
||||
setSaveOutcome(null)
|
||||
@@ -568,9 +595,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
returnToNeutral()
|
||||
const type = draft.tourType ?? 'hike'
|
||||
const points = normalizePlannerWaypoints(
|
||||
draft.points.map(({ lat, lng }) => ({ id: randomId(), lat, lng, role: 'via' as const })),
|
||||
draft.points.map(({ lat, lng, name }) => ({
|
||||
id: randomId(), lat, lng, role: 'via' as const, ...(name ? { name } : {}),
|
||||
})),
|
||||
type === 'kayak',
|
||||
)
|
||||
setDraftSource(draft.source ?? null)
|
||||
tourTypeRef.current = type
|
||||
setTourTypeState(type)
|
||||
if (draft.paddle) loadPaddle(draft.paddle)
|
||||
@@ -586,12 +616,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setMaxHikingDifficultyState(draft.difficulty)
|
||||
difficultyRef.current = draft.difficulty
|
||||
setSelectedWaypointId(points[0].id)
|
||||
if (draft.inPlace) {
|
||||
if (draft.inPlace && draft.sourcePlaceId !== undefined) {
|
||||
setEditingPlaceId(draft.sourcePlaceId)
|
||||
editingPlaceIdRef.current = draft.sourcePlaceId
|
||||
adoptRevision(draft.revision)
|
||||
} else {
|
||||
setDerivedFromPlaceId(draft.sourcePlaceId)
|
||||
setDerivedFromPlaceId(draft.sourcePlaceId ?? null)
|
||||
}
|
||||
setStatus('dirty')
|
||||
setHasUnsavedChanges(true)
|
||||
@@ -680,7 +710,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
clearHistory()
|
||||
setStatus('dirty')
|
||||
clearRoute()
|
||||
setHasUnsavedChanges(false)
|
||||
// CW-11: a walk without its way yet is planned on opening and can be saved with it at once.
|
||||
const pending = result.tour.route_pending === true
|
||||
setRoutePending(pending)
|
||||
setHasUnsavedChanges(pending && editPermissionRef.current)
|
||||
setDraftRestored(false)
|
||||
setEditingPlaceId(result.tour.place_id)
|
||||
editingPlaceIdRef.current = result.tour.place_id
|
||||
@@ -688,6 +721,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setMapFocusKey(key => key + 1)
|
||||
setSaveOutcome(null)
|
||||
setReadOnlyGpxTour(null)
|
||||
setDraftSource(null)
|
||||
return true
|
||||
} catch {
|
||||
if (requestIsCurrent() && !saveInFlightRef.current) setError('open')
|
||||
@@ -730,12 +764,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
const draft: DraftPayload = { version: 10, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile,
|
||||
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile,
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
|
||||
}
|
||||
keepDraft(draft)
|
||||
}, [breakAdditionalMinutes,
|
||||
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
|
||||
derivedFromPlaceId, draftSource, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences,
|
||||
bikeProfile])
|
||||
|
||||
@@ -774,10 +808,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
website: parsedWebsiteOrNull(website),
|
||||
...typeFields,
|
||||
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
|
||||
waypoints: savedWaypointsOf(waypoints),
|
||||
waypoints: savedWaypointsOf(waypoints, tourType),
|
||||
duration_seconds: savedDurationSeconds,
|
||||
planned_duration_minutes: plannedDurationMinutes,
|
||||
break_additional_minutes: breakAdditionalMinutes,
|
||||
break_additional_minutes: savedBreakAdditionalMinutes,
|
||||
...derivedFromField(targetPlaceId, derivedFromPlaceId),
|
||||
...(expectedRevision !== null ? { expected_revision: expectedRevision } : {}),
|
||||
} as const
|
||||
@@ -795,14 +829,22 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
? await tourRepo.create(tripId, payload)
|
||||
: await tourRepo.update(tripId, targetPlaceId, payload)
|
||||
if (stillCurrent()) {
|
||||
// CW-03: the keys the server gave new points stay with them, so the next save keeps their visits.
|
||||
const keyed = withSavedKeys(waypointsRef.current, result.waypoints)
|
||||
if (keyed !== waypointsRef.current) {
|
||||
waypointsRef.current = keyed
|
||||
setWaypoints(keyed)
|
||||
}
|
||||
forgetDraft()
|
||||
adoptRevision(result.tour.revision)
|
||||
if (options.name) setNameState(options.name.trim())
|
||||
setHasUnsavedChanges(false)
|
||||
setRoutePending(false)
|
||||
setStatus('saved')
|
||||
clearHistory()
|
||||
setEditingPlaceId(result.tour.place_id)
|
||||
editingPlaceIdRef.current = result.tour.place_id
|
||||
setDraftSource(null)
|
||||
setSaveOutcome(result.tour)
|
||||
}
|
||||
if (mountedRef.current && String(tripIdRef.current) === submittedTripId && !deletedTourPlaceIdsRef.current.has(result.tour.place_id)) {
|
||||
@@ -822,9 +864,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
}, [adoptRevision, refuseRevision, revisionBaseRef, clearHistory, breakAdditionalMinutes, canSave,
|
||||
}, [adoptRevision, refuseRevision, revisionBaseRef, clearHistory, savedBreakAdditionalMinutes, canSave,
|
||||
derivedFromPlaceId,
|
||||
description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, forgetDraft, tripId, typeFields, waypoints, website])
|
||||
description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, forgetDraft, tourType, tripId, typeFields, waypoints, website])
|
||||
|
||||
return {
|
||||
tripId, canEdit, canAssign,
|
||||
@@ -834,7 +876,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
description, setDescription, website, setWebsite, websiteInvalid,
|
||||
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
|
||||
breakAdditionalMinutes, setBreakAdditionalMinutes, breakAdditionalInvalid,
|
||||
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
|
||||
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid, stationStays,
|
||||
waypoints, selectedWaypointId, setSelectedWaypointId,
|
||||
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, editWaypoints: commitWaypoints,
|
||||
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
|
||||
@@ -848,9 +890,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
startNewTour,
|
||||
startDerivedTour,
|
||||
derivedFromPlaceId,
|
||||
draftSource,
|
||||
newTourConfirmationOpen, cancelNewTour, forgetDeletedTour,
|
||||
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored, mapFocusKey,
|
||||
editingPlaceId, openingTourId, openTour,
|
||||
editingPlaceId, openingTourId, openTour, routePending,
|
||||
saveOutcome, hasUnsavedChanges, canSave, save, discard, returnToNeutral, revision,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { TourCreateRequest, TourCreateResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
|
||||
() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
routeCityWalk: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
|
||||
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import { useTourPlanner } from './useTourPlanner';
|
||||
|
||||
// FE-TOUR-WALK-PLANNER-001 to FE-TOUR-WALK-PLANNER-008: a city walk's stays in the planner, edited without
|
||||
// planning its route again, and saved without breaks (CW-02). FE-TOUR-WALK-PLANNER-009 and 010: a walk an
|
||||
// assistant saved with its stops only, planned when it is opened (CW-11).
|
||||
|
||||
/** 45 minutes on foot between the stops. */
|
||||
const routed = {
|
||||
coordinates: [
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
] as [number, number][],
|
||||
parts: [
|
||||
{
|
||||
coordinates: [
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
] as [number, number][],
|
||||
free: false,
|
||||
},
|
||||
],
|
||||
distanceMeters: 3200,
|
||||
durationSeconds: 2700,
|
||||
};
|
||||
const enriched = [
|
||||
[48, 11, 520],
|
||||
[48.01, 11.02, 525],
|
||||
] as [number, number, number][];
|
||||
|
||||
/** The server's answer to a save: the points in their order, each with a key. */
|
||||
const answered = (body: TourCreateRequest): TourCreateResponse => ({
|
||||
tour: {
|
||||
place_id: 42,
|
||||
name: body.name,
|
||||
tour_type: 'walk',
|
||||
distance: 3.2,
|
||||
elevation_gain: 5,
|
||||
elevation_loss: 0,
|
||||
duration: 45,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: false,
|
||||
caution: false,
|
||||
revision: 1,
|
||||
},
|
||||
waypoints: body.waypoints.map((point, index) => ({ ...point, key: point.key ?? `stop-${index}` })),
|
||||
});
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
}
|
||||
|
||||
/** The value, or a failure saying it is missing instead of a later undefined. */
|
||||
function must<T>(value: T | null | undefined): T {
|
||||
if (value === null || value === undefined) throw new Error('expected a value');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A walk through three stops, named, routed and with its heights: ready to save. */
|
||||
async function walkPlanned() {
|
||||
const hook = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
act(() => hook.result.current.startNewTour());
|
||||
for (const [lat, lng] of [
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
])
|
||||
act(() => hook.result.current.addWaypoint(lat, lng));
|
||||
act(() => hook.result.current.setTourType('walk'));
|
||||
act(() => hook.result.current.setName('Old town'));
|
||||
await settle();
|
||||
return hook;
|
||||
}
|
||||
|
||||
type Planner = ReturnType<typeof useTourPlanner>;
|
||||
|
||||
/** The stay of the stop at `index` set as the station dialog sets it. */
|
||||
function stayAt(planner: Planner, index: number, stayMinutes: number | null) {
|
||||
const id = must(planner.waypoints[index]).id;
|
||||
act(() =>
|
||||
planner.editWaypoints((points: TourPlannerWaypoint[]) =>
|
||||
points.map((point) => (point.id === id ? { ...point, stayMinutes } : point))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** The body of the save the planner sent last. */
|
||||
const lastSaved = () => createTour.mock.calls[createTour.mock.calls.length - 1]?.[1] as TourCreateRequest;
|
||||
|
||||
describe('useTourPlanner, city walks (CW-02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||
routeCityWalk.mockReset().mockResolvedValue(routed);
|
||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||
createTour.mockReset().mockImplementation(async (_trip: number, body: TourCreateRequest) => answered(body));
|
||||
detailTour.mockReset();
|
||||
updateTour.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('FE-TOUR-WALK-PLANNER-001: a stay changes the time overview at once, and the route is planned once', async () => {
|
||||
const { result } = await walkPlanned();
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.walkingDurationMinutes).toBe(45);
|
||||
expect(result.current.stationStays).toMatchObject({
|
||||
stayMinutes: 0,
|
||||
unknownStays: 3,
|
||||
stations: 3,
|
||||
totalMinutes: 45,
|
||||
});
|
||||
|
||||
stayAt(result.current, 1, 90);
|
||||
stayAt(result.current, 2, 30);
|
||||
stayAt(result.current, 0, 0);
|
||||
// Nothing waits for a route: the planner stays ready to save after each stay.
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.canSave).toBe(true);
|
||||
expect(result.current.stationStays).toMatchObject({
|
||||
walkingMinutes: 45,
|
||||
stayMinutes: 120,
|
||||
unknownStays: 0,
|
||||
totalMinutes: 165,
|
||||
partial: false,
|
||||
});
|
||||
expect(result.current.plannedTotalMinutes).toBe(165);
|
||||
|
||||
await settle();
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(1);
|
||||
expect(enrichTourElevations).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.routeCurrent).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-PLANNER-002: one undo takes a stay back, still without routing again', async () => {
|
||||
const { result } = await walkPlanned();
|
||||
stayAt(result.current, 1, 90);
|
||||
expect(result.current.canUndo).toBe(true);
|
||||
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.waypoints[1]?.stayMinutes).toBeUndefined();
|
||||
expect(result.current.stationStays?.stayMinutes).toBe(0);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.canSave).toBe(true);
|
||||
act(() => result.current.redo());
|
||||
expect(result.current.waypoints[1]?.stayMinutes).toBe(90);
|
||||
await settle();
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-PLANNER-003: saves the stays and no breaks, so a stay never counts as a break too', async () => {
|
||||
const { result } = await walkPlanned();
|
||||
// A break kept from the hike this draft was, out of range besides: it neither counts nor holds the save up.
|
||||
act(() => result.current.setBreakAdditionalMinutes(5000));
|
||||
expect(result.current.breakAdditionalInvalid).toBe(false);
|
||||
stayAt(result.current, 1, 90);
|
||||
stayAt(result.current, 2, 0);
|
||||
expect(result.current.canSave).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
const body = lastSaved();
|
||||
expect(body.break_additional_minutes).toBeNull();
|
||||
expect(body.waypoints.map((point) => point.stay_minutes)).toEqual([undefined, 90, 0]);
|
||||
expect(body.duration_seconds).toBe(2700);
|
||||
expect(result.current.status).toBe('saved');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-PLANNER-004: a hike keeps its breaks: they are saved and checked as before', async () => {
|
||||
const { result } = await walkPlanned();
|
||||
act(() => result.current.setTourType('hike'));
|
||||
await settle();
|
||||
act(() => result.current.setBreakAdditionalMinutes(5000));
|
||||
expect(result.current.breakAdditionalInvalid).toBe(true);
|
||||
expect(result.current.canSave).toBe(false);
|
||||
act(() => result.current.setBreakAdditionalMinutes(20));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(lastSaved().break_additional_minutes).toBe(20);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-PLANNER-005: moving a stop still plans the route again', async () => {
|
||||
const { result } = await walkPlanned();
|
||||
const stop = must(result.current.waypoints[1]);
|
||||
act(() => {
|
||||
void result.current.setWaypointPosition(stop.id, 48.011, 11.021);
|
||||
});
|
||||
expect(result.current.status).toBe('dirty');
|
||||
expect(result.current.canSave).toBe(false);
|
||||
await settle();
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-PLANNER-006: a stay set while the route is being planned lets that planning finish', async () => {
|
||||
let answer: (value: typeof routed) => void = () => undefined;
|
||||
routeCityWalk.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
);
|
||||
const hook = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
act(() => hook.result.current.startNewTour());
|
||||
act(() => hook.result.current.addWaypoint(48, 11));
|
||||
act(() => hook.result.current.addWaypoint(48.01, 11.02));
|
||||
act(() => hook.result.current.setTourType('walk'));
|
||||
act(() => hook.result.current.setName('Old town'));
|
||||
await settle();
|
||||
expect(hook.result.current.status).toBe('routing');
|
||||
|
||||
stayAt(hook.result.current, 1, 30);
|
||||
expect(hook.result.current.status).toBe('routing');
|
||||
await act(async () => {
|
||||
answer(routed);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
await settle();
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(1);
|
||||
expect(hook.result.current.status).toBe('ready');
|
||||
expect(hook.result.current.canSave).toBe(true);
|
||||
expect(hook.result.current.plannedTotalMinutes).toBe(75);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-PLANNER-007: a stay changed after a save makes the walk ready to save again, its route as it was', async () => {
|
||||
const { result } = await walkPlanned();
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(result.current.status).toBe('saved');
|
||||
expect(result.current.canSave).toBe(false);
|
||||
|
||||
stayAt(result.current, 2, 30);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.hasUnsavedChanges).toBe(true);
|
||||
expect(result.current.canSave).toBe(true);
|
||||
await settle();
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("FE-TOUR-WALK-PLANNER-008: a loop run the other way keeps its start's stay, counted once", async () => {
|
||||
const { result } = await walkPlanned();
|
||||
stayAt(result.current, 0, 20);
|
||||
act(() => result.current.returnToStart());
|
||||
await settle();
|
||||
expect(result.current.stationStays).toMatchObject({ stayMinutes: 20, stations: 3 });
|
||||
|
||||
act(() => result.current.reverse());
|
||||
expect(result.current.isLoop).toBe(true);
|
||||
expect(result.current.waypoints[0]?.stayMinutes).toBe(20);
|
||||
expect(result.current.waypoints[3]?.stayMinutes).toBeUndefined();
|
||||
expect(result.current.stationStays).toMatchObject({ stayMinutes: 20, stations: 3, unknownStays: 2 });
|
||||
});
|
||||
|
||||
/** A saved walk of two stops as the detail answers it, with or without its way. */
|
||||
const savedWalk = (routePending: boolean) => ({
|
||||
tour: {
|
||||
place_id: 42,
|
||||
name: 'Briefed walk',
|
||||
tour_type: 'walk' as const,
|
||||
distance: routePending ? null : 3.2,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: routePending ? null : 45,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: true,
|
||||
revision: 1,
|
||||
route_pending: routePending,
|
||||
},
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0, key: 'stop-cathedral', name: 'Cathedral' },
|
||||
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1, key: 'stop-park', name: 'Park' },
|
||||
],
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-PLANNER-009: a walk saved with its stops only is planned on opening and can be saved with its way at once', async () => {
|
||||
detailTour.mockResolvedValue(savedWalk(true));
|
||||
updateTour.mockImplementation(async (_trip: number, _place: number, body: TourCreateRequest) => answered(body));
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
await act(async () => {
|
||||
await result.current.openTour({ place_id: 42 });
|
||||
});
|
||||
expect(result.current.routePending).toBe(true);
|
||||
expect(result.current.hasUnsavedChanges).toBe(true);
|
||||
|
||||
await settle();
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.canSave).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(updateTour).toHaveBeenCalledWith(
|
||||
31,
|
||||
42,
|
||||
expect.objectContaining({ route_geometry: enriched, tour_type: 'walk', expected_revision: 1 })
|
||||
);
|
||||
expect(result.current.routePending).toBe(false);
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
expect(result.current.status).toBe('saved');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-PLANNER-010: a walk with its way opens with nothing to save', async () => {
|
||||
detailTour.mockResolvedValue(savedWalk(false));
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
await act(async () => {
|
||||
await result.current.openTour({ place_id: 42 });
|
||||
});
|
||||
await settle();
|
||||
expect(result.current.routePending).toBe(false);
|
||||
expect(result.current.hasUnsavedChanges).toBe(false);
|
||||
expect(result.current.canSave).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { TourCreateRequest, TourCreateResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
|
||||
() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
routeCityWalk: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
|
||||
|
||||
import { validDraft } from './tourPlannerDraft';
|
||||
import { useTourPlanner, type DerivedTourDraft } from './useTourPlanner';
|
||||
|
||||
// FE-TOUR-WALK-IMPORT-PLANNER-001 to FE-TOUR-WALK-IMPORT-PLANNER-004: a walk read from a Google Maps link,
|
||||
// opened as a new draft with its stop names, routed on foot by TREK and saved like any other walk (CW-07).
|
||||
|
||||
const routed = {
|
||||
coordinates: [
|
||||
[41.1425899, -8.6092477],
|
||||
[41.1382776, -8.6117899],
|
||||
] as [number, number][],
|
||||
parts: [
|
||||
{
|
||||
coordinates: [
|
||||
[41.1425899, -8.6092477],
|
||||
[41.1382776, -8.6117899],
|
||||
] as [number, number][],
|
||||
free: false,
|
||||
},
|
||||
],
|
||||
distanceMeters: 1400,
|
||||
durationSeconds: 1200,
|
||||
};
|
||||
const enriched = [
|
||||
[41.1425899, -8.6092477, 80],
|
||||
[41.1382776, -8.6117899, 10],
|
||||
] as [number, number, number][];
|
||||
|
||||
const answered = (body: TourCreateRequest): TourCreateResponse => ({
|
||||
tour: {
|
||||
place_id: 51,
|
||||
name: body.name,
|
||||
tour_type: 'walk',
|
||||
distance: 1.4,
|
||||
elevation_gain: 5,
|
||||
elevation_loss: 75,
|
||||
duration: 20,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: false,
|
||||
caution: false,
|
||||
revision: 1,
|
||||
},
|
||||
waypoints: body.waypoints.map((point, index) => ({ ...point, key: point.key ?? `stop-${index}` })),
|
||||
});
|
||||
|
||||
/** What the walk import hands the planner for a route of three stops, the middle one given as coordinates. */
|
||||
const FROM_GOOGLE: DerivedTourDraft = {
|
||||
name: 'Walk from Google Maps',
|
||||
difficulty: 2,
|
||||
points: [
|
||||
{ lat: 41.1425899, lng: -8.6092477, name: 'Igreja de Santa Clara' },
|
||||
{ lat: 41.1428531, lng: -8.6111165, name: null },
|
||||
{ lat: 41.1382776, lng: -8.6117899, name: 'Ribeira de Gaia' },
|
||||
],
|
||||
inPlace: false,
|
||||
tourType: 'walk',
|
||||
source: 'google_maps',
|
||||
};
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
}
|
||||
|
||||
const storedDraft = (tripId: number) => JSON.parse(localStorage.getItem(`tour-draft-${tripId}`) ?? 'null') as unknown;
|
||||
|
||||
describe('useTourPlanner, a walk read from Google Maps (CW-07)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||
routeCityWalk.mockReset().mockResolvedValue(routed);
|
||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||
createTour.mockReset().mockImplementation(async (_trip: number, body: TourCreateRequest) => answered(body));
|
||||
detailTour.mockReset();
|
||||
updateTour.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('FE-TOUR-WALK-IMPORT-PLANNER-001: opens as a new walk with its stop names, routed on foot by TREK', async () => {
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
let started = false;
|
||||
act(() => {
|
||||
started = result.current.startDerivedTour(FROM_GOOGLE);
|
||||
});
|
||||
expect(started).toBe(true);
|
||||
expect(result.current.mode).toEqual({ type: 'new-draft' });
|
||||
expect(result.current.tourType).toBe('walk');
|
||||
expect(result.current.name).toBe('Walk from Google Maps');
|
||||
expect(result.current.draftSource).toBe('google_maps');
|
||||
expect(result.current.derivedFromPlaceId).toBeNull();
|
||||
expect(result.current.editingPlaceId).toBeNull();
|
||||
expect(result.current.waypoints.map((point) => [point.name, point.role])).toEqual([
|
||||
['Igreja de Santa Clara', 'start'],
|
||||
[undefined, 'via'],
|
||||
['Ribeira de Gaia', 'end'],
|
||||
]);
|
||||
await settle();
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(1);
|
||||
expect(routeWalkingTour).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-IMPORT-PLANNER-002: saves as a walk with the stop names and no import it was derived from', async () => {
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
act(() => {
|
||||
result.current.startDerivedTour(FROM_GOOGLE);
|
||||
});
|
||||
await settle();
|
||||
expect(result.current.canSave).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
const body = createTour.mock.calls[0]?.[1] as TourCreateRequest;
|
||||
expect(body.tour_type).toBe('walk');
|
||||
expect(body.waypoints.map((point) => point.name ?? null)).toEqual([
|
||||
'Igreja de Santa Clara',
|
||||
null,
|
||||
'Ribeira de Gaia',
|
||||
]);
|
||||
expect(body).not.toHaveProperty('derived_from_place_id');
|
||||
// Saved, the walk is TREK's own: the notice of where it came from goes.
|
||||
expect(result.current.draftSource).toBeNull();
|
||||
expect(result.current.mode).toEqual({ type: 'edit-saved', placeId: 51 });
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-IMPORT-PLANNER-003: the source is kept with the unsaved draft across a reload, and forgotten when it is thrown away', () => {
|
||||
const first = renderHook(() => useTourPlanner({ tripId: 32 }));
|
||||
act(() => {
|
||||
first.result.current.startDerivedTour(FROM_GOOGLE);
|
||||
});
|
||||
expect(storedDraft(32)).toMatchObject({ draftSource: 'google_maps', tourType: 'walk' });
|
||||
first.unmount();
|
||||
|
||||
const reloaded = renderHook(() => useTourPlanner({ tripId: 32 }));
|
||||
expect(reloaded.result.current.draftSource).toBe('google_maps');
|
||||
expect(reloaded.result.current.waypoints[0]?.name).toBe('Igreja de Santa Clara');
|
||||
act(() => reloaded.result.current.returnToNeutral());
|
||||
expect(reloaded.result.current.draftSource).toBeNull();
|
||||
expect(storedDraft(32)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-IMPORT-PLANNER-004: a stored draft names no source, or Google Maps; any other is not taken back', () => {
|
||||
const draft = { version: 10, name: 'Walk', waypoints: [] };
|
||||
expect(validDraft(draft)).toBe(true);
|
||||
expect(validDraft({ ...draft, draftSource: null })).toBe(true);
|
||||
expect(validDraft({ ...draft, draftSource: 'google_maps' })).toBe(true);
|
||||
expect(validDraft({ ...draft, draftSource: 'komoot' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,29 @@ import type {
|
||||
PlannableTourType,
|
||||
TourMaxHikingDifficulty,
|
||||
} from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { planPaddleStep, type PaddleTourRoute } from './paddleRouting';
|
||||
import { normalizePlannerWaypoints, tourWaypointKey, type TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import {
|
||||
normalizePlannerWaypoints,
|
||||
routeInputsKey,
|
||||
tourWaypointKey,
|
||||
type TourPlannerWaypoint,
|
||||
} from './tourPlannerWaypoints';
|
||||
import { isTourRoute, type TourRouteUnreachable } from './tourRouteOutcome';
|
||||
import {
|
||||
enrichTourElevations,
|
||||
routeBikeTour,
|
||||
routeCityWalk,
|
||||
routeWalkingTour,
|
||||
type TourElevationPoint,
|
||||
type TourRoutePart,
|
||||
@@ -36,7 +51,7 @@ export interface TourRouteInputs {
|
||||
retryToken: number;
|
||||
/** The planner's error, so a point found unreachable is only named while that is the error. */
|
||||
error: string | null;
|
||||
setStatus: (status: TourPlannerStatus) => void;
|
||||
setStatus: Dispatch<SetStateAction<TourPlannerStatus>>;
|
||||
setError: (error: string | null) => void;
|
||||
onCleared: () => void;
|
||||
/** Points the router set itself (a paddle tour's), laid in without a step of the history. */
|
||||
@@ -52,6 +67,8 @@ interface PlannedRoute {
|
||||
distanceMeters: number | null;
|
||||
durationSeconds: number | null;
|
||||
forKey: string | null;
|
||||
/** The run it was planned in: its points with what was planned along them, its choices and the retry. */
|
||||
forRun: string | null;
|
||||
}
|
||||
|
||||
const NO_ROUTE: PlannedRoute = {
|
||||
@@ -62,12 +79,21 @@ const NO_ROUTE: PlannedRoute = {
|
||||
distanceMeters: null,
|
||||
durationSeconds: null,
|
||||
forKey: null,
|
||||
forRun: null,
|
||||
};
|
||||
|
||||
/** A route planned whole: a paddle route without a failed leg, a route on land with its heights. */
|
||||
function routeComplete(planned: PlannedRoute): boolean {
|
||||
return planned.paddleRoute
|
||||
? planned.paddleRoute.failures.length === 0
|
||||
: hasCompleteElevation(planned.enrichedGeometry);
|
||||
}
|
||||
|
||||
/** One planning run: what it plans from, whether it went stale, and where it writes. */
|
||||
interface RouteRun {
|
||||
inputs: Omit<TourRouteInputs, 'retryToken' | 'error' | 'onCleared'>;
|
||||
plannedKey: string;
|
||||
runKey: string;
|
||||
signal: AbortSignal;
|
||||
stale: () => boolean;
|
||||
setPlanned: (next: PlannedRoute | ((current: PlannedRoute) => PlannedRoute)) => void;
|
||||
@@ -75,7 +101,7 @@ interface RouteRun {
|
||||
}
|
||||
|
||||
// PAD-11: a paddle tour is planned on the water by the server's router; its heights are no concern.
|
||||
async function routePaddle({ inputs, plannedKey, signal, stale, setPlanned }: RouteRun) {
|
||||
async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlanned }: RouteRun) {
|
||||
const { waypoints, tripId, paddleBoat, paddleLakeStyle, setStatus, setError } = inputs;
|
||||
setStatus('routing');
|
||||
const profile = { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle } as const;
|
||||
@@ -91,6 +117,7 @@ async function routePaddle({ inputs, plannedKey, signal, stale, setPlanned }: Ro
|
||||
distanceMeters: planned.distanceMeters,
|
||||
durationSeconds: null,
|
||||
forKey: plannedKey,
|
||||
forRun: runKey,
|
||||
});
|
||||
const failed = planned.failures.length > 0;
|
||||
setStatus(failed ? 'routing-failed' : 'ready');
|
||||
@@ -98,13 +125,15 @@ async function routePaddle({ inputs, plannedKey, signal, stale, setPlanned }: Ro
|
||||
}
|
||||
|
||||
// A hike or a bike tour on land, then the heights along it.
|
||||
async function routeOnLand({ inputs, plannedKey, signal, stale, setPlanned, setUnreachable }: RouteRun) {
|
||||
async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlanned, setUnreachable }: RouteRun) {
|
||||
const { waypoints, tourType, bikeProfile, maxHikingDifficulty, hikingPreferences, setStatus, setError } = inputs;
|
||||
setStatus('routing');
|
||||
const routed =
|
||||
tourType === 'bike'
|
||||
? await routeBikeTour(waypoints, bikeProfile, signal)
|
||||
: await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
|
||||
: tourType === 'walk'
|
||||
? await routeCityWalk(waypoints, signal)
|
||||
: await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
|
||||
if (stale()) return;
|
||||
if (!isTourRoute(routed)) {
|
||||
setUnreachable(routed);
|
||||
@@ -121,6 +150,7 @@ async function routeOnLand({ inputs, plannedKey, signal, stale, setPlanned, setU
|
||||
distanceMeters: routed.distanceMeters,
|
||||
durationSeconds: routed.durationSeconds,
|
||||
forKey: plannedKey,
|
||||
forRun: runKey,
|
||||
});
|
||||
setStatus('enriching-elevation');
|
||||
|
||||
@@ -180,16 +210,29 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
// What a route was planned for besides its points: a route of another type or profile is not this one's.
|
||||
const choices = [tourType, bikeProfile, maxHikingDifficulty, hikingPreferences, paddleBoat, paddleLakeStyle];
|
||||
const plannedKey = `${waypointKey}#${JSON.stringify(choices)}`;
|
||||
// CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from,
|
||||
// so a change to them alone plans nothing again.
|
||||
const routeInputs = useMemo(() => routeInputsKey(waypoints), [waypoints]);
|
||||
const runKey = `${plannedKey}#${routeInputs}#${retryToken}`;
|
||||
// The points as they are when a run starts, their station fields included, read without making them a reason to run.
|
||||
const currentPoints = useEffectEvent(() => waypoints);
|
||||
// The route shown was planned whole for exactly this run, so a change that leaves it as it is plans nothing again.
|
||||
const routeStands = useEffectEvent(() => planned.forRun === runKey && routeComplete(planned));
|
||||
|
||||
useEffect(() => {
|
||||
if (waypoints.length < 2) {
|
||||
const points = currentPoints();
|
||||
if (points.length < 2) {
|
||||
interruptRouting();
|
||||
setStatus(waypoints.length === 0 ? 'empty' : 'dirty');
|
||||
setStatus(points.length === 0 ? 'empty' : 'dirty');
|
||||
clearRoute();
|
||||
onCleared();
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (routeStands()) {
|
||||
setStatus((current) => (current === 'dirty' ? 'ready' : current));
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = ++generationRef.current;
|
||||
const controller = new AbortController();
|
||||
@@ -199,7 +242,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
const run: RouteRun = {
|
||||
inputs: {
|
||||
tripId,
|
||||
waypoints,
|
||||
waypoints: points,
|
||||
tourType,
|
||||
maxHikingDifficulty,
|
||||
hikingPreferences,
|
||||
@@ -211,6 +254,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
patchWaypoints,
|
||||
},
|
||||
plannedKey,
|
||||
runKey,
|
||||
signal: controller.signal,
|
||||
stale,
|
||||
setPlanned,
|
||||
@@ -241,14 +285,15 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
patchWaypoints,
|
||||
plannedKey,
|
||||
retryToken,
|
||||
routeInputs,
|
||||
runKey,
|
||||
setError,
|
||||
setStatus,
|
||||
tourType,
|
||||
tripId,
|
||||
waypoints,
|
||||
]);
|
||||
|
||||
const { forKey, ...shown } = planned;
|
||||
const { forKey, forRun: _run, ...shown } = planned;
|
||||
return {
|
||||
...shown,
|
||||
/** The route shown was planned for these points and these choices. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import type { Category, Place } from '../../../types';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { useLinkedPlaceOf } from '../walk/useWalkSchedule';
|
||||
import { unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import { withPointAppended } from './tourRouteActions';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
@@ -49,7 +50,7 @@ export function useTripPlaceWaypoints(planner: TourPlannerController, tourPlaceI
|
||||
const categories = useTripStore((state) => state.categories) ?? NO_CATEGORIES;
|
||||
const { editWaypoints, setSelectedWaypointId, waypoints, route } = planner;
|
||||
|
||||
const placesById = useMemo(() => new Map(places.map((place) => [place.id, place])), [places]);
|
||||
const linkedPlaceOf = useLinkedPlaceOf();
|
||||
|
||||
const candidates = useMemo((): TripPlaceCandidate[] => {
|
||||
const line = tourLine({ route, waypoints });
|
||||
@@ -87,13 +88,6 @@ export function useTripPlaceWaypoints(planner: TourPlannerController, tourPlaceI
|
||||
[editWaypoints]
|
||||
);
|
||||
|
||||
/** The place a point stands for while the trip still has it. */
|
||||
const linkedPlaceOf = useCallback(
|
||||
(point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>) =>
|
||||
point.linkedPlaceId ? (placesById.get(point.linkedPlaceId) ?? null) : null,
|
||||
[placesById]
|
||||
);
|
||||
|
||||
return { candidates, add, unlink, linkedPlaceOf };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { TourPoi, WalkTheme } from '@trek/shared';
|
||||
import { WALK_THEMES } from '@trek/shared';
|
||||
import { Clock, ExternalLink, MoveHorizontal, Plus, Route, Star, X } from 'lucide-react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
|
||||
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
|
||||
import { PillSelect } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
|
||||
import { usePoiInsertion } from './usePoiInsertion';
|
||||
import { useSelectedPoi } from './useTourPoiSearch';
|
||||
|
||||
type Unit = Parameters<typeof formatDistance>[1];
|
||||
|
||||
const km = (metres: number) => metres / 1000;
|
||||
|
||||
/** The point a place would follow, picked from the tour's points, with why one cannot take it. */
|
||||
function AfterPicker({
|
||||
planner,
|
||||
poi,
|
||||
insertion,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
poi: TourPoi;
|
||||
insertion: ReturnType<typeof usePoiInsertion>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const names = useWaypointPlaceNames(planner.waypoints);
|
||||
const afterId = insertion.afterIdFor(poi);
|
||||
const options = planner.waypoints.map((point, index) => ({
|
||||
value: point.id,
|
||||
label: `${index + 1}. ${point.shaping ? t('tours.planner.shapingPoint') : (names[point.id] ?? `${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}`)}`,
|
||||
}));
|
||||
const block = afterId ? insertion.blockOf(afterId) : 'none';
|
||||
const locked = !planner.canEdit || planner.isSaving;
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-2 border-t border-edge-faint pt-3" data-testid="tour-poi-insert">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex-none text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('tours.poi.addAfter')}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<PillSelect
|
||||
quiet
|
||||
label={t('tours.poi.addAfter')}
|
||||
value={afterId ?? ''}
|
||||
options={options}
|
||||
onChange={(value) => insertion.chooseAfter(poi, value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{block && (
|
||||
<p className="m-0 text-warning" style={fs(11, 'body')}>
|
||||
{t(`tours.poi.blocked.${block}`)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!block || locked}
|
||||
onClick={() => insertion.insert(poi)}
|
||||
data-testid="tour-poi-add"
|
||||
title={t('tours.poi.notAPlace')}
|
||||
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('tours.poi.add')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the source says about the place: its address, its opening hours as written, and its website. */
|
||||
function PoiFacts({ poi }: { poi: TourPoi }) {
|
||||
const { t } = useTranslation();
|
||||
if (!poi.address && !poi.opening_hours && !poi.website) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
{poi.address && (
|
||||
<p className="m-0 break-words text-content-secondary" style={fs(11.5, 'body')}>
|
||||
{poi.address}
|
||||
</p>
|
||||
)}
|
||||
{poi.opening_hours && (
|
||||
<p className="m-0 flex items-start gap-1.5 text-content-secondary" style={fs(11.5, 'body')}>
|
||||
<Clock size={12} strokeWidth={2.2} className="mt-0.5 flex-none text-content-faint" />
|
||||
<span className="min-w-0 break-words" title={t('tours.poi.hoursHint')}>
|
||||
{poi.opening_hours}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{poi.website && (
|
||||
<a
|
||||
href={poi.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 self-start text-content-secondary underline-offset-2 hover:underline"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<ExternalLink size={12} strokeWidth={2.2} />
|
||||
{t('tours.poi.website')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PLN-02/PLN-03: a place found along the tour, picked on the map or in the
|
||||
* list, in the look of the other cards over the planner map: what it is, how
|
||||
* far from the route and how far along it, why a city walk's theme suggested
|
||||
* it, where the data comes from, and the way to take it in as a stop.
|
||||
*/
|
||||
function TourPoiCard({
|
||||
poi,
|
||||
theme,
|
||||
planner,
|
||||
insertion,
|
||||
unit,
|
||||
onClose,
|
||||
width,
|
||||
}: {
|
||||
poi: TourPoi;
|
||||
theme: WalkTheme | null;
|
||||
planner: TourPlannerController;
|
||||
insertion: ReturnType<typeof usePoiInsertion>;
|
||||
unit: Unit;
|
||||
onClose: () => void;
|
||||
width?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const look = TOUR_POI_LOOKS[poi.category];
|
||||
const kind = t(look.labelKey);
|
||||
const suggested = theme && (WALK_THEMES[theme] as readonly string[]).includes(poi.category) ? theme : null;
|
||||
return (
|
||||
<article
|
||||
className={`max-h-[70vh] overflow-y-auto overscroll-contain rounded-2xl p-3 text-content ${width ? '' : 'w-[min(20rem,calc(100vw_-_2rem))]'}`}
|
||||
style={{ ...MAP_CARD_GLASS, width }}
|
||||
data-testid="tour-poi-card-map"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* The category's own map colour, as its pin on the map has it: a light wash, the icon in full. */}
|
||||
<span
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full"
|
||||
style={{ background: `color-mix(in srgb, ${look.color} 16%, transparent)`, color: look.color }}
|
||||
>
|
||||
<look.Icon size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={poi.name || kind}>
|
||||
{poi.name || kind}
|
||||
</h3>
|
||||
{(poi.name || poi.highlight) && (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{poi.name && <p className="min-w-0 truncate text-caption text-content-muted">{kind}</p>}
|
||||
{/* One of the best known places along the route, as the star in the list marks it. */}
|
||||
{poi.highlight && (
|
||||
<span className="flex-none" title={t('tours.poi.highlightHint')} data-testid="tour-poi-highlight-badge">
|
||||
<SoftPill
|
||||
tone="warning"
|
||||
icon={<Star size={10} strokeWidth={2.2} fill="currentColor" aria-hidden="true" />}
|
||||
>
|
||||
{t('tours.poi.highlight')}
|
||||
</SoftPill>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="flex-none rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className="text-content-faint" />}>
|
||||
{t('tours.poi.fromRoute', { distance: formatDistance(km(poi.distance_m), unit) })}
|
||||
</WhiteBadge>
|
||||
<WhiteBadge icon={<Route size={10} strokeWidth={2.2} className="text-content-faint" />}>
|
||||
{t('tours.poi.along', { distance: formatDistance(km(poi.along_m), unit) })}
|
||||
</WhiteBadge>
|
||||
</div>
|
||||
<PoiFacts poi={poi} />
|
||||
<AfterPicker planner={planner} poi={poi} insertion={insertion} />
|
||||
<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>}
|
||||
<span>{t(poi.source === 'trek-places' ? 'tours.poi.source.trek' : 'tours.poi.source.osm')}</span>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen place's card over the planner map, where a via ferrata's or a
|
||||
* paddle object's card sits and closing as those do: on a press outside it
|
||||
* and on Escape, the press handed on so the map leaves its click alone. The
|
||||
* choice lives in the places store, so a pin on the map and a row in the list
|
||||
* open the same card.
|
||||
*/
|
||||
export default function TourPoiOverlay({
|
||||
planner,
|
||||
onOutsidePress,
|
||||
insetEnd,
|
||||
bottom,
|
||||
width,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
onOutsidePress?: () => void;
|
||||
insetEnd: number;
|
||||
bottom: number;
|
||||
width?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const poi = useSelectedPoi();
|
||||
const theme = useTourPoiStore((state) => state.theme);
|
||||
const select = useTourPoiStore((state) => state.select);
|
||||
const insertion = usePoiInsertion(planner);
|
||||
const close = useCallback(() => select(null), [select]);
|
||||
useMapCardDismiss(ref, !!poi, close, onOutsidePress);
|
||||
|
||||
if (!poi) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
|
||||
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
|
||||
data-testid="tour-poi-overlay"
|
||||
>
|
||||
<TourPoiCard
|
||||
poi={poi}
|
||||
theme={theme}
|
||||
planner={planner}
|
||||
insertion={insertion}
|
||||
unit={unit}
|
||||
onClose={close}
|
||||
width={width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { TourPoi, TourPoiCategory } from '@trek/shared';
|
||||
import type { TourPoi, WalkTheme } from '@trek/shared';
|
||||
import { TOUR_POI_CORRIDORS_M } from '@trek/shared';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
Coffee,
|
||||
Landmark,
|
||||
Loader2,
|
||||
MoveHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Route,
|
||||
Search,
|
||||
Shuffle,
|
||||
Star,
|
||||
Trees,
|
||||
WifiOff,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -18,11 +22,12 @@ import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import { PopoverItem } from '../../Packing/PackingPopover';
|
||||
import { WhiteBadge } from '../../Planner/planParts';
|
||||
import { PillSelect, Segmented } from '../../shared/dialogParts';
|
||||
import { PopoverListPanel, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { usePopoverList } from '../../shared/usePopoverList';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import { usePoiInsertion, type InsertionOutcome } from './usePoiInsertion';
|
||||
@@ -33,28 +38,127 @@ type Unit = Parameters<typeof formatDistance>[1];
|
||||
const km = (metres: number) => metres / 1000;
|
||||
|
||||
/** A category to search for, as a chip: its icon in its map colour, ticked or not. */
|
||||
function CategoryChip({ category, on, onToggle }: { category: TourPoiCategory; on: boolean; onToggle: () => void }) {
|
||||
/**
|
||||
* What is searched for, in one line: the chosen categories' icons and names,
|
||||
* with every category the tour type offers in a list that stays open while
|
||||
* they are ticked. A theme of a city walk sets them all at once above it.
|
||||
*/
|
||||
function CategoryPicker({ search }: { search: TourPoiSearch }) {
|
||||
const { t } = useTranslation();
|
||||
const look = TOUR_POI_LOOKS[category];
|
||||
const list = usePopoverList(0, { estimatedHeight: 320 });
|
||||
const { open, toggle, triggerRef, listId } = list;
|
||||
const chosen = search.offered.filter((category) => search.categories.includes(category));
|
||||
const names = chosen.map((category) => t(TOUR_POI_LOOKS[category].labelKey));
|
||||
const summary =
|
||||
names.length === 0
|
||||
? t('tours.poi.categoriesNone')
|
||||
: names.length <= 2
|
||||
? names.join(', ')
|
||||
: t('tours.poi.categoriesMore', { first: names.slice(0, 2).join(', '), more: names.length - 2 });
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-pressed={on}
|
||||
data-testid={`tour-poi-category-${category}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-inset ring-edge-faint' : 'bg-surface-tertiary text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<look.Icon size={12} strokeWidth={2.2} style={{ color: on ? look.color : undefined }} />
|
||||
{t(look.labelKey)}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
data-testid="tour-poi-categories"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-[10px] border border-edge bg-surface-input px-2.5 py-1.5 text-start hover:border-content-faint"
|
||||
>
|
||||
<span className="sr-only">{t('tours.poi.categories')}: </span>
|
||||
<span className="flex flex-none" aria-hidden="true">
|
||||
{chosen.slice(0, 3).map((category, index) => {
|
||||
const look = TOUR_POI_LOOKS[category];
|
||||
return (
|
||||
<span
|
||||
key={category}
|
||||
className={`grid h-5 w-5 place-items-center rounded-full bg-surface-card ring-2 ring-[color:var(--bg-card)] ${index > 0 ? '-ms-1' : ''}`}
|
||||
style={{ color: look.color }}
|
||||
>
|
||||
<look.Icon size={11} strokeWidth={2.2} />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12, 'body')}>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
strokeWidth={2.2}
|
||||
aria-hidden="true"
|
||||
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<PopoverListPanel list={list} label={t('tours.poi.categories')} className="min-w-[240px]">
|
||||
{search.offered.map((category) => {
|
||||
const look = TOUR_POI_LOOKS[category];
|
||||
return (
|
||||
<PopoverItem
|
||||
key={category}
|
||||
icon={<look.Icon size={13} strokeWidth={2.2} style={{ color: look.color }} />}
|
||||
label={t(look.labelKey)}
|
||||
active={search.categories.includes(category)}
|
||||
onClick={() => search.toggleCategory(category)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the search answered beyond its places: out of date, offline, failed, partly searched, cut, or empty. */
|
||||
const THEME_ICONS: Record<WalkTheme, LucideIcon> = { culture: Landmark, parks: Trees, food: Coffee, mixed: Shuffle };
|
||||
|
||||
/**
|
||||
* CW-05: a city walk's four themes as a grid of two by two, each naming
|
||||
* itself in full; the one the categories below match is on.
|
||||
*/
|
||||
function ThemeGrid({
|
||||
themes,
|
||||
active,
|
||||
onChoose,
|
||||
}: {
|
||||
themes: readonly WalkTheme[];
|
||||
active: WalkTheme | null;
|
||||
onChoose: (theme: WalkTheme) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5" role="group" aria-label={t('tours.walk.themes')}>
|
||||
{themes.map((theme) => {
|
||||
const Icon = THEME_ICONS[theme];
|
||||
const on = theme === active;
|
||||
return (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
onClick={() => onChoose(theme)}
|
||||
aria-pressed={on}
|
||||
title={t(`tours.walk.themeHint.${theme}`)}
|
||||
data-testid={`tour-poi-theme-${theme}`}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-2 text-start font-medium leading-tight transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-inset ring-edge-faint' : 'bg-surface-tertiary text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<Icon size={13} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||
<span className="min-w-0">{t(`tours.walk.theme.${theme}`)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the search answered beyond its places: offline, failed, kinds that
|
||||
* 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.
|
||||
*/
|
||||
function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit }) {
|
||||
const { t } = useTranslation();
|
||||
const { status, answer, outdated } = search;
|
||||
const { status, answer } = search;
|
||||
if (status === 'offline')
|
||||
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.poi.offline')}</TourNotice>;
|
||||
if (status === 'error')
|
||||
@@ -64,21 +168,21 @@ function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit })
|
||||
</TourNotice>
|
||||
);
|
||||
if (!answer) return null;
|
||||
// Out of date is no fault, the route was just changed (often by adding a place from here): said plainly.
|
||||
const unavailable = answer.unavailable.map((category) => t(TOUR_POI_LOOKS[category].labelKey)).join(', ');
|
||||
const notes = [
|
||||
unavailable && t('tours.poi.unavailable', { kinds: unavailable }),
|
||||
answer.partial && t('tours.poi.partial'),
|
||||
answer.capped && t('tours.poi.capped'),
|
||||
answer.truncated && t('tours.poi.truncated'),
|
||||
].filter((note): note is string => !!note);
|
||||
return (
|
||||
<>
|
||||
{outdated && <TourNotice icon={<RefreshCw size={13} strokeWidth={2} />}>{t('tours.poi.outdated')}</TourNotice>}
|
||||
{notes.map((note) => (
|
||||
<TourNotice key={note} tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{note}
|
||||
</TourNotice>
|
||||
))}
|
||||
{answer.pois.length === 0 && (
|
||||
{answer.pois.length === 0 && answer.unavailable.length === 0 && (
|
||||
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
|
||||
{t('tours.poi.empty', { distance: formatDistance(km(search.corridorM), unit) })}
|
||||
</p>
|
||||
@@ -87,76 +191,12 @@ function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit })
|
||||
);
|
||||
}
|
||||
|
||||
/** The point a place would follow, picked from the tour's points, with why one cannot take it. */
|
||||
function AfterPicker({
|
||||
planner,
|
||||
poi,
|
||||
insertion,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
poi: TourPoi;
|
||||
insertion: ReturnType<typeof usePoiInsertion>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const names = useWaypointPlaceNames(planner.waypoints);
|
||||
const afterId = insertion.afterIdFor(poi);
|
||||
const options = planner.waypoints.map((point, index) => ({
|
||||
value: point.id,
|
||||
label: `${index + 1}. ${point.shaping ? t('tours.planner.shapingPoint') : (names[point.id] ?? `${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}`)}`,
|
||||
}));
|
||||
const block = afterId ? insertion.blockOf(afterId) : 'none';
|
||||
const locked = !planner.canEdit || planner.isSaving;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-[10px] bg-surface-secondary p-2.5" data-testid="tour-poi-insert">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('tours.poi.addAfter')}
|
||||
</span>
|
||||
<PillSelect
|
||||
quiet
|
||||
label={t('tours.poi.addAfter')}
|
||||
value={afterId ?? ''}
|
||||
options={options}
|
||||
onChange={(value) => insertion.chooseAfter(poi, value)}
|
||||
/>
|
||||
</div>
|
||||
{block && (
|
||||
<p className="m-0 text-warning" style={fs(11, 'body')}>
|
||||
{t(`tours.poi.blocked.${block}`)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!block || locked}
|
||||
onClick={() => insertion.insert(poi)}
|
||||
data-testid="tour-poi-add"
|
||||
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('tours.poi.add')}
|
||||
</button>
|
||||
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.poi.notAPlace')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One place found: its kind, name and where it lies; opened, what the source says and the way to take it in. */
|
||||
function PoiRow({
|
||||
poi,
|
||||
selected,
|
||||
planner,
|
||||
insertion,
|
||||
unit,
|
||||
}: {
|
||||
poi: TourPoi;
|
||||
selected: boolean;
|
||||
planner: TourPlannerController;
|
||||
insertion: ReturnType<typeof usePoiInsertion>;
|
||||
unit: Unit;
|
||||
}) {
|
||||
/**
|
||||
* One place found, in one line: its kind's icon in its map colour, its name,
|
||||
* and how far it lies from the route. A click opens its card over the map,
|
||||
* where the rest is said and the place is taken in as a stop.
|
||||
*/
|
||||
function PoiRow({ poi, selected, unit }: { poi: TourPoi; selected: boolean; unit: Unit }) {
|
||||
const { t } = useTranslation();
|
||||
const select = useTourPoiStore((state) => state.select);
|
||||
const look = TOUR_POI_LOOKS[poi.category];
|
||||
@@ -164,76 +204,45 @@ function PoiRow({
|
||||
useEffect(() => {
|
||||
if (selected) rowRef.current?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [selected]);
|
||||
const distance = formatDistance(km(poi.distance_m), unit);
|
||||
return (
|
||||
<li
|
||||
ref={rowRef}
|
||||
className={`rounded-[10px] ${selected ? 'bg-surface-selected' : ''}`}
|
||||
data-testid={`tour-poi-${poi.id}`}
|
||||
>
|
||||
<li ref={rowRef} data-testid={`tour-poi-${poi.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(selected ? null : poi.id)}
|
||||
aria-expanded={selected}
|
||||
className="flex w-full min-w-0 items-start gap-2.5 rounded-[10px] px-2 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||
onClick={() => select(poi.id)}
|
||||
aria-pressed={selected}
|
||||
className={`flex w-full min-w-0 items-center gap-2 rounded-[8px] px-1.5 py-1 text-start transition-colors hover:bg-surface-hover ${selected ? 'bg-surface-selected' : ''}`}
|
||||
>
|
||||
{/* The category's own map colour, as its pin on the map has it: a light wash, the icon in full. */}
|
||||
<span
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full"
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full"
|
||||
style={{ background: `color-mix(in srgb, ${look.color} 16%, transparent)`, color: look.color }}
|
||||
>
|
||||
<look.Icon size={14} strokeWidth={2.2} />
|
||||
<look.Icon size={12} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="min-w-0 truncate font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{poi.name || t(look.labelKey)}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className="text-content-faint" />}>
|
||||
{t('tours.poi.fromRoute', { distance: formatDistance(km(poi.distance_m), unit) })}
|
||||
</WhiteBadge>
|
||||
<WhiteBadge icon={<Route size={10} strokeWidth={2.2} className="text-content-faint" />}>
|
||||
{t('tours.poi.along', { distance: formatDistance(km(poi.along_m), unit) })}
|
||||
</WhiteBadge>
|
||||
{/* One of the best known places along the route: a star before its name. */}
|
||||
{poi.highlight && (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t('tours.poi.highlight')}
|
||||
title={t('tours.poi.highlightHint')}
|
||||
className="flex-none text-warning"
|
||||
data-testid="tour-poi-highlight"
|
||||
>
|
||||
<Star size={12} strokeWidth={2.2} fill="currentColor" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12, 'body')}>
|
||||
{poi.name || t(look.labelKey)}
|
||||
</span>
|
||||
<span
|
||||
className="flex-none tabular-nums text-content-faint"
|
||||
style={fs(11, 'body')}
|
||||
title={t('tours.poi.fromRoute', { distance })}
|
||||
>
|
||||
{distance}
|
||||
</span>
|
||||
</button>
|
||||
{selected && (
|
||||
<div className="flex flex-col gap-2 px-2 pb-2.5">
|
||||
<p className="m-0 flex flex-wrap gap-x-3 text-content-faint" style={fs(10.5, 'body')}>
|
||||
<span>{t(look.labelKey)}</span>
|
||||
<span>{t(poi.source === 'trek-places' ? 'tours.poi.source.trek' : 'tours.poi.source.osm')}</span>
|
||||
</p>
|
||||
{poi.address && (
|
||||
<p className="m-0 text-content-secondary" style={fs(11.5, 'body')}>
|
||||
{poi.address}
|
||||
</p>
|
||||
)}
|
||||
{poi.opening_hours && (
|
||||
<p className="m-0 flex items-start gap-1.5 text-content-secondary" style={fs(11.5, 'body')}>
|
||||
<Clock size={12} strokeWidth={2.2} className="mt-0.5 flex-none text-content-faint" />
|
||||
<span>
|
||||
{poi.opening_hours}
|
||||
<span className="block text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.poi.hoursHint')}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{poi.website && (
|
||||
<a
|
||||
href={poi.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 self-start text-content-secondary underline-offset-2 hover:underline"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<ExternalLink size={12} strokeWidth={2.2} />
|
||||
{t('tours.poi.website')}
|
||||
</a>
|
||||
)}
|
||||
<AfterPicker planner={planner} poi={poi} insertion={insertion} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -253,24 +262,30 @@ function OutcomeNotice({ outcome, onDismiss, unit }: { outcome: InsertionOutcome
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex flex-col gap-1.5">
|
||||
<span>{t(`tours.poi.outcome.${outcome.kind}`, { name: outcome.name })}</span>
|
||||
{outcome.kind === 'measured' && (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<WhiteBadge>{signed(formatDistance(km(Math.abs(outcome.extraM)), unit), outcome.extraM < 0)}</WhiteBadge>
|
||||
{outcome.extraMinutes != null && (
|
||||
<span className="flex items-start gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<span>{t(`tours.poi.outcome.${outcome.kind}`, { name: outcome.name })}</span>
|
||||
{outcome.kind === 'measured' && (
|
||||
<>
|
||||
<WhiteBadge>
|
||||
{signed(formatPlannedTourDuration(Math.abs(outcome.extraMinutes)), outcome.extraMinutes < 0)}
|
||||
{signed(formatDistance(km(Math.abs(outcome.extraM)), unit), outcome.extraM < 0)}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex flex-wrap items-center gap-2 text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.poi.undoHint')}
|
||||
<button type="button" onClick={onDismiss} className="font-semibold underline underline-offset-2">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
{outcome.extraMinutes != null && (
|
||||
<WhiteBadge>
|
||||
{signed(formatPlannedTourDuration(Math.abs(outcome.extraMinutes)), outcome.extraMinutes < 0)}
|
||||
</WhiteBadge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('common.close')}
|
||||
className="flex-none rounded p-0.5 text-content-faint hover:text-content"
|
||||
>
|
||||
<X size={12} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</TourNotice>
|
||||
</div>
|
||||
@@ -304,19 +319,12 @@ export function TourPoiSearchCard({ planner }: { planner: TourPlannerController
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3" data-testid="tour-poi-card">
|
||||
<p className="m-0 text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.poi.intro')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t('tours.poi.categories')}>
|
||||
{search.offered.map((category) => (
|
||||
<CategoryChip
|
||||
key={category}
|
||||
category={category}
|
||||
on={search.categories.includes(category)}
|
||||
onToggle={() => search.toggleCategory(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{search.themes.length > 0 && (
|
||||
<div data-testid="tour-poi-themes">
|
||||
<ThemeGrid themes={search.themes} active={search.theme} onChoose={search.chooseTheme} />
|
||||
</div>
|
||||
)}
|
||||
<CategoryPicker search={search} />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-content-muted" style={fs(11, 'body')}>
|
||||
{t('tours.poi.within')}
|
||||
@@ -336,8 +344,10 @@ export function TourPoiSearchCard({ planner }: { planner: TourPlannerController
|
||||
type="button"
|
||||
onClick={() => void search.search()}
|
||||
disabled={!search.canSearch}
|
||||
title={search.outdated ? t('tours.poi.outdated') : undefined}
|
||||
data-testid="tour-poi-search"
|
||||
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-[10px] bg-surface-tertiary font-semibold text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
|
||||
data-outdated={search.outdated || undefined}
|
||||
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-[10px] font-semibold transition-colors disabled:cursor-default disabled:opacity-50 ${search.outdated ? 'bg-accent text-accent-text hover:opacity-90' : 'bg-surface-tertiary text-content hover:bg-surface-hover'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{search.status === 'loading' ? (
|
||||
@@ -354,16 +364,12 @@ export function TourPoiSearchCard({ planner }: { planner: TourPlannerController
|
||||
{insertion.outcome && <OutcomeNotice outcome={insertion.outcome} onDismiss={insertion.dismiss} unit={unit} />}
|
||||
<SearchNotices search={search} unit={unit} />
|
||||
{pois.length > 0 && (
|
||||
<ul className={`m-0 flex list-none flex-col gap-0.5 p-0 ${search.status === 'loading' ? 'opacity-60' : ''}`}>
|
||||
<ul
|
||||
className={`m-0 flex max-h-[320px] list-none flex-col gap-px overflow-y-auto overscroll-contain p-0 ${search.status === 'loading' ? 'opacity-60' : ''}`}
|
||||
data-testid="tour-poi-list"
|
||||
>
|
||||
{pois.map((poi) => (
|
||||
<PoiRow
|
||||
key={poi.id}
|
||||
poi={poi}
|
||||
selected={selected?.id === poi.id}
|
||||
planner={planner}
|
||||
insertion={insertion}
|
||||
unit={unit}
|
||||
/>
|
||||
<PoiRow key={poi.id} poi={poi} selected={selected?.id === poi.id} unit={unit} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { nearnessToLine } from '@trek/shared';
|
||||
import { isClosedLoop, withPointAppended, type RoutePoint } from '../planner/tourRouteActions';
|
||||
import { isClosedLoop, nearnessToLine } from '@trek/shared';
|
||||
import { withPointAppended, type RoutePoint } from '../planner/tourRouteActions';
|
||||
|
||||
type Point = RoutePoint & { role: string; shaping?: boolean };
|
||||
|
||||
|
||||
@@ -2,16 +2,19 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourPoi, TourPoiSearchResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourPoiSearchRepo } from '../../../repo/tourPoiSearchRepo';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import TourPoiOverlay from './TourPoiCard';
|
||||
import { TourPoiSearchCard } from './TourPoiSearchCard';
|
||||
import { usePoiInsertion, type PoiInsertionPlanner } from './usePoiInsertion';
|
||||
import { useTourPoiMarkers } from './useTourPoiMarkers';
|
||||
import { useTourPoiSearch } from './useTourPoiSearch';
|
||||
|
||||
// FE-TOUR-POI-001 to FE-TOUR-POI-008
|
||||
// FE-TOUR-POI-001 to FE-TOUR-POI-018
|
||||
|
||||
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
|
||||
|
||||
@@ -39,7 +42,13 @@ const SPRING: TourPoi = {
|
||||
distance_m: 80,
|
||||
along_m: 1650,
|
||||
};
|
||||
const ANSWER: TourPoiSearchResponse = { pois: [SPRING], truncated: false, partial: false, capped: false };
|
||||
const ANSWER: TourPoiSearchResponse = {
|
||||
pois: [SPRING],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
capped: false,
|
||||
unavailable: [],
|
||||
};
|
||||
|
||||
let search: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
@@ -186,21 +195,246 @@ describe('usePoiInsertion (PLN-03)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The list in the side panel and the chosen place's card over the map, as the planner shows them. */
|
||||
function renderAlong(planner: TourPlannerController) {
|
||||
return render(
|
||||
<>
|
||||
<TourPoiSearchCard planner={planner} />
|
||||
<TourPoiOverlay planner={planner} insetEnd={0} bottom={0} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('TourPoiSearchCard', () => {
|
||||
it('FE-TOUR-POI-008: lists what was found with where it lies; opening a place offers to add it after a point', async () => {
|
||||
it('FE-TOUR-POI-008: lists what was found in one line each; a place opens its card over the map, which adds it after a point', async () => {
|
||||
const planner = {
|
||||
...searchPlanner(),
|
||||
...insertionPlanner(),
|
||||
} as unknown as TourPlannerController;
|
||||
render(<TourPoiSearchCard planner={planner} />);
|
||||
renderAlong(planner);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
const row = await screen.findByTestId('tour-poi-node:1');
|
||||
expect(row).toHaveTextContent('Spring');
|
||||
expect(row).toHaveTextContent('80 m from the route');
|
||||
expect(row).toHaveTextContent('80 m');
|
||||
expect(within(row).queryByTestId('tour-poi-add')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: /Spring/ }));
|
||||
expect(within(row).getByText('Mo-Su 08:00-18:00')).toBeInTheDocument();
|
||||
expect(within(row).getByText('As the source has them; check before you go.')).toBeInTheDocument();
|
||||
fireEvent.click(within(row).getByTestId('tour-poi-add'));
|
||||
const card = screen.getByTestId('tour-poi-overlay');
|
||||
expect(within(row).getByRole('button', { name: /Spring/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(card).toHaveTextContent('80 m from the route');
|
||||
expect(within(card).getByText('Mo-Su 08:00-18:00')).toBeInTheDocument();
|
||||
expect(within(card).getByText('Mo-Su 08:00-18:00')).toHaveAttribute(
|
||||
'title',
|
||||
'As the source has them; check before you go.'
|
||||
);
|
||||
fireEvent.click(within(card).getByTestId('tour-poi-add'));
|
||||
await waitFor(() => expect(planner.editWaypoints).toHaveBeenCalledTimes(1));
|
||||
// The card closes once the place is in. What it did is said in the list once the route has it (FE-TOUR-POI-006).
|
||||
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-016: the card over the map closes on its button and on Escape, and a pin on the map opens it too', async () => {
|
||||
const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
|
||||
renderAlong(planner);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
await screen.findByTestId('tour-poi-node:1');
|
||||
|
||||
const markers = renderHook(() => useTourPoiMarkers(), { wrapper: TranslationProvider });
|
||||
// The one place found, picked as a click on its pin does.
|
||||
for (const poi of markers.result.current.pois) act(() => markers.result.current.onPoiClick(poi));
|
||||
expect(screen.getByTestId('tour-poi-overlay')).toHaveTextContent('Spring');
|
||||
fireEvent.click(within(screen.getByTestId('tour-poi-overlay')).getByRole('button', { name: 'Close' }));
|
||||
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
|
||||
expect(screen.getByTestId('tour-poi-overlay')).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByTestId('tour-poi-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-009: names the kinds that could not be searched, and does not claim nothing was found', async () => {
|
||||
search.mockResolvedValue({ ...ANSWER, pois: [], unavailable: ['viewpoint', 'shelter'] });
|
||||
render(<TourPoiSearchCard planner={searchPlanner()} />);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
expect(
|
||||
await screen.findByText('Could not be searched right now: Viewpoints, Huts and shelters. Try again in a moment.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Nothing found/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-010: an unnamed place is named by its kind in the list, on the map and once taken in', async () => {
|
||||
const tap: TourPoi = { ...SPRING, id: 'node:2', name: '' };
|
||||
search.mockResolvedValue({ ...ANSWER, pois: [tap] });
|
||||
const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
|
||||
render(<TourPoiSearchCard planner={planner} />);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
const row = await screen.findByTestId('tour-poi-node:2');
|
||||
expect(within(row).getByRole('button', { name: /Drinking water/ })).toBeInTheDocument();
|
||||
|
||||
const markers = renderHook(() => useTourPoiMarkers(), { wrapper: TranslationProvider });
|
||||
expect(markers.result.current.pois.map((poi) => poi.name)).toEqual(['Drinking water']);
|
||||
|
||||
let taking = insertionPlanner();
|
||||
const { result, rerender } = renderHook(() => usePoiInsertion(taking), { wrapper: TranslationProvider });
|
||||
act(() => {
|
||||
result.current.insert(tap);
|
||||
});
|
||||
const added = vi.mocked(taking.setSelectedWaypointId).mock.calls[0]?.[0] as string;
|
||||
const withPoint = [PA, PB, { id: added, lat: tap.lat, lng: tap.lng, role: 'via' as const }, PC];
|
||||
taking = insertionPlanner({ waypoints: withPoint, routeCurrent: false, status: 'routing' });
|
||||
rerender();
|
||||
expect(result.current.outcome).toEqual({ kind: 'measuring', name: 'Drinking water' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlights along a tour', () => {
|
||||
const SQUARE: TourPoi = {
|
||||
...SPRING,
|
||||
id: 'way:1',
|
||||
name: 'Piazza San Marco',
|
||||
category: 'sights',
|
||||
kind: 'place=square',
|
||||
opening_hours: null,
|
||||
website: null,
|
||||
highlight: true,
|
||||
};
|
||||
|
||||
it('FE-TOUR-POI-017: a highlight carries a star before its name in the list, a place that is none carries none', async () => {
|
||||
search.mockResolvedValue({ ...ANSWER, pois: [SQUARE, SPRING] });
|
||||
render(<TourPoiSearchCard planner={searchPlanner()} />);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
const square = await screen.findByTestId('tour-poi-way:1');
|
||||
const star = within(square).getByTestId('tour-poi-highlight');
|
||||
expect(star).toHaveAttribute('aria-label', 'Highlight');
|
||||
expect(star).toHaveAttribute('title', 'One of the best-known places here');
|
||||
expect(within(square).getByRole('button', { name: /Highlight.*Piazza San Marco/ })).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('tour-poi-node:1')).queryByTestId('tour-poi-highlight')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-018: the card over the map says Highlight beside the kind, and only for a highlight', async () => {
|
||||
search.mockResolvedValue({ ...ANSWER, pois: [SQUARE, SPRING] });
|
||||
const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
|
||||
renderAlong(planner);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
fireEvent.click(within(await screen.findByTestId('tour-poi-way:1')).getByRole('button', { name: /Piazza/ }));
|
||||
const badge = within(screen.getByTestId('tour-poi-overlay')).getByTestId('tour-poi-highlight-badge');
|
||||
expect(badge).toHaveTextContent('Highlight');
|
||||
expect(badge).toHaveAttribute('title', 'One of the best-known places here');
|
||||
expect(screen.getByTestId('tour-poi-overlay')).toHaveTextContent('Sights');
|
||||
|
||||
fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
|
||||
expect(screen.queryByTestId('tour-poi-highlight-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a city walk along its places (CW-05)', () => {
|
||||
const CAFE: TourPoi = {
|
||||
...SPRING,
|
||||
id: 'node:3',
|
||||
name: 'Cafe Luitpold',
|
||||
category: 'cafe',
|
||||
kind: 'amenity=cafe',
|
||||
opening_hours: null,
|
||||
};
|
||||
const walkPlanner = () =>
|
||||
({ ...searchPlanner({ tourType: 'walk' }), ...insertionPlanner() }) as unknown as TourPlannerController;
|
||||
const themeButton = (theme: string) => screen.getByTestId(`tour-poi-theme-${theme}`);
|
||||
|
||||
it('FE-TOUR-POI-011: a walk offers four themes; one sets its categories at once, a change of them leaves it', async () => {
|
||||
const planner = walkPlanner();
|
||||
render(<TourPoiSearchCard planner={planner} />);
|
||||
const themes = screen.getByTestId('tour-poi-themes');
|
||||
expect(
|
||||
within(themes)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.textContent)
|
||||
).toEqual(['Culture and history', 'Parks and views', 'Food and local life', 'A bit of everything']);
|
||||
// The walk starts with its first three categories, which no theme is.
|
||||
for (const theme of ['culture', 'parks', 'food', 'mixed'])
|
||||
expect(themeButton(theme)).toHaveAttribute('aria-pressed', 'false');
|
||||
// What a theme looks for is said on the button, not in a line of its own.
|
||||
expect(themeButton('food')).toHaveAttribute('title', 'Coffee shops, places to eat and markets along the route.');
|
||||
|
||||
fireEvent.click(themeButton('food'));
|
||||
expect(themeButton('food')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByTestId('tour-poi-categories')).toHaveTextContent('Coffee shops, Places to eat +1');
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
await waitFor(() =>
|
||||
expect(search).toHaveBeenCalledWith(
|
||||
4,
|
||||
expect.objectContaining({ tour_type: 'walk', categories: ['cafe', 'restaurant', 'market'] }),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('tour-poi-categories'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Markets' }));
|
||||
expect(themeButton('food')).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-012: the category field names two categories, counts the rest, and says when none is chosen', () => {
|
||||
render(<TourPoiSearchCard planner={walkPlanner()} />);
|
||||
const field = screen.getByTestId('tour-poi-categories');
|
||||
expect(field).toHaveTextContent('Sights, Museums and galleries +1');
|
||||
expect(field).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(field);
|
||||
expect(field).toHaveAttribute('aria-expanded', 'true');
|
||||
const list = screen.getByRole('group', { name: 'What to look for' });
|
||||
fireEvent.click(within(list).getByRole('button', { name: 'Parks and gardens' }));
|
||||
expect(field).toHaveTextContent('Sights, Museums and galleries');
|
||||
expect(field).not.toHaveTextContent('+1');
|
||||
fireEvent.click(within(list).getByRole('button', { name: 'Sights' }));
|
||||
fireEvent.click(within(list).getByRole('button', { name: 'Museums and galleries' }));
|
||||
expect(field).toHaveTextContent('No category chosen');
|
||||
expect(screen.getByTestId('tour-poi-search')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-013: a place found for a theme says which theme suggested it', async () => {
|
||||
search.mockResolvedValue({ ...ANSWER, pois: [CAFE, SPRING] });
|
||||
renderAlong(walkPlanner());
|
||||
fireEvent.click(themeButton('food'));
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
const cafe = await screen.findByTestId('tour-poi-node:3');
|
||||
fireEvent.click(within(cafe).getByRole('button', { name: /Cafe Luitpold/ }));
|
||||
expect(
|
||||
within(screen.getByTestId('tour-poi-overlay')).getByText('Suggested for Food and local life')
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
|
||||
expect(screen.queryByText('Suggested for Food and local life')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-014: a hike has no themes', () => {
|
||||
render(<TourPoiSearchCard planner={searchPlanner()} />);
|
||||
expect(screen.queryByTestId('tour-poi-themes')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-poi-categories')).toHaveTextContent('Viewpoints, Huts and shelters +1');
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-015: a place taken in keeps its name and opening hours, and a place without them adds none', () => {
|
||||
const planner = insertionPlanner();
|
||||
const { result } = renderHook(() => usePoiInsertion(planner), { wrapper: TranslationProvider });
|
||||
act(() => {
|
||||
result.current.insert(SPRING);
|
||||
});
|
||||
act(() => {
|
||||
result.current.insert({ ...CAFE, id: 'node:4', name: '', along_m: 400 });
|
||||
});
|
||||
const added = vi
|
||||
.mocked(planner.editWaypoints)
|
||||
.mock.calls.map(
|
||||
([update]) =>
|
||||
(update as (points: TourPlannerWaypoint[]) => TourPlannerWaypoint[])(POINTS).find(
|
||||
(point) => !POINTS.includes(point)
|
||||
) as TourPlannerWaypoint
|
||||
);
|
||||
expect(added[0]).toMatchObject({
|
||||
name: 'Spring',
|
||||
openingHours: 'Mo-Su 08:00-18:00',
|
||||
poiCategory: 'drinking_water',
|
||||
});
|
||||
// PLN-03: the kind the place was found as goes with it, so its row carries the kind's icon.
|
||||
expect(added[1]).toMatchObject({ poiCategory: 'cafe' });
|
||||
expect(added[1]).not.toHaveProperty('name');
|
||||
expect(added[1]).not.toHaveProperty('openingHours');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { TourPoi } from '@trek/shared';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useTourPoiStore, type InsertedTourPoi } from '../../../store/tourPoiStore';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController, TourPlannerStatus } from '../planner/useTourPlanner';
|
||||
import { defaultAfterId, insertionBlock, withPointAfter } from './poiInsertion';
|
||||
@@ -14,15 +16,6 @@ interface Measures {
|
||||
minutes: number | null;
|
||||
}
|
||||
|
||||
/** A place taken in: which point it became, and the route's measures just before. */
|
||||
interface Inserted {
|
||||
pointId: string;
|
||||
name: string;
|
||||
before: Measures | null;
|
||||
/** How many points the tour had once the place was in; another edit after it ends the comparison. */
|
||||
pointCount: number;
|
||||
}
|
||||
|
||||
export type InsertionOutcome =
|
||||
| { kind: 'measuring'; name: string }
|
||||
| { kind: 'measured'; name: string; extraM: number; extraMinutes: number | null }
|
||||
@@ -57,9 +50,12 @@ function measuresOf(planner: PoiInsertionPlanner): Measures | null {
|
||||
* hand or a fixed line is never cut: the point after which it would go says why.
|
||||
*/
|
||||
export function usePoiInsertion(planner: PoiInsertionPlanner) {
|
||||
const { t } = useTranslation();
|
||||
const select = useTourPoiStore((state) => state.select);
|
||||
const [afterChoice, setAfterChoice] = useState<{ poiId: string; afterId: string } | null>(null);
|
||||
const [inserted, setInserted] = useState<Inserted | null>(null);
|
||||
// Kept in the store: the place is taken in from its card over the map, and the list says what it did.
|
||||
const inserted = useTourPoiStore((state) => state.inserted);
|
||||
const setInserted = useTourPoiStore((state) => state.setInserted);
|
||||
|
||||
const afterIdFor = useCallback(
|
||||
(poi: TourPoi) =>
|
||||
@@ -74,20 +70,32 @@ export function usePoiInsertion(planner: PoiInsertionPlanner) {
|
||||
(poi: TourPoi) => {
|
||||
const afterId = afterIdFor(poi);
|
||||
if (!afterId || !planner.canEdit || planner.isSaving) return false;
|
||||
const point: TourPlannerWaypoint = { id: randomId(), lat: poi.lat, lng: poi.lng, role: 'via' };
|
||||
// The place keeps its name, and on a city walk its opening hours to check the arrival against (CW-04).
|
||||
const point: TourPlannerWaypoint = {
|
||||
id: randomId(),
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
role: 'via',
|
||||
...(poi.name ? { name: poi.name } : {}),
|
||||
...(poi.opening_hours ? { openingHours: poi.opening_hours } : {}),
|
||||
// PLN-03: its row carries the kind's icon, as the map drew the place.
|
||||
poiCategory: poi.category,
|
||||
};
|
||||
const next = withPointAfter(planner.waypoints, afterId, point);
|
||||
if (!next) return false;
|
||||
const before = measuresOf(planner);
|
||||
planner.editWaypoints((current) => withPointAfter(current, afterId, point) ?? current);
|
||||
planner.setSelectedWaypointId(point.id);
|
||||
setInserted({ pointId: point.id, name: poi.name, before, pointCount: next.length });
|
||||
// An unnamed place (a tap, a bench) is told by its kind, as the list names it.
|
||||
const name = poi.name || t(TOUR_POI_LOOKS[poi.category].labelKey);
|
||||
setInserted({ pointId: point.id, name, before, pointCount: next.length });
|
||||
select(null);
|
||||
return true;
|
||||
},
|
||||
[afterIdFor, planner, select]
|
||||
[afterIdFor, planner, select, setInserted, t]
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => setInserted(null), []);
|
||||
const dismiss = useCallback(() => setInserted(null), [setInserted]);
|
||||
|
||||
return {
|
||||
afterIdFor,
|
||||
@@ -104,7 +112,7 @@ export function usePoiInsertion(planner: PoiInsertionPlanner) {
|
||||
* now: nothing once the point is gone again (undone or removed) or the tour
|
||||
* was edited further, so the figure never describes another route.
|
||||
*/
|
||||
function outcomeOf(inserted: Inserted | null, planner: PoiInsertionPlanner): InsertionOutcome | null {
|
||||
function outcomeOf(inserted: InsertedTourPoi | null, planner: PoiInsertionPlanner): InsertionOutcome | null {
|
||||
if (!inserted) return null;
|
||||
const stillThere = planner.waypoints.some((point) => point.id === inserted.pointId);
|
||||
if (!stillThere || planner.waypoints.length !== inserted.pointCount) return null;
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import type { Poi } from '../../Map/poiCategories';
|
||||
import { TOUR_POI_LOOKS, type Poi } from '../../Map/poiCategories';
|
||||
|
||||
/**
|
||||
* PLN-02: the places found along the tour as the planner map draws POIs, each
|
||||
* in its category's look, and a click on one that picks it in the card.
|
||||
* in its category's look, and a click on one that picks it in the card. An
|
||||
* unnamed place (a tap, a bench) is named by its kind, as the list names it.
|
||||
*/
|
||||
export function useTourPoiMarkers(): { pois: Poi[]; onPoiClick: (poi: Poi) => void } {
|
||||
const { t } = useTranslation();
|
||||
const found = useTourPoiStore((state) => state.pois);
|
||||
const select = useTourPoiStore((state) => state.select);
|
||||
const pois = useMemo(
|
||||
() =>
|
||||
found.map((poi): Poi => ({
|
||||
osm_id: poi.id,
|
||||
name: poi.name,
|
||||
name: poi.name || t(TOUR_POI_LOOKS[poi.category].labelKey),
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
category: poi.category,
|
||||
@@ -25,7 +28,7 @@ export function useTourPoiMarkers(): { pois: Poi[]; onPoiClick: (poi: Poi) => vo
|
||||
cuisine: null,
|
||||
source: poi.source,
|
||||
})),
|
||||
[found]
|
||||
[found, t]
|
||||
);
|
||||
return { pois, onPoiClick: (poi: Poi) => select(poi.osm_id) };
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ import {
|
||||
TOUR_POI_CATEGORIES_BY_TYPE,
|
||||
TOUR_POI_MAX_CATEGORIES,
|
||||
TOUR_POI_MAX_LINE_POINTS,
|
||||
WALK_THEME_KEYS,
|
||||
WALK_THEMES,
|
||||
type PlannableTourType,
|
||||
type TourPoi,
|
||||
type TourPoiCategory,
|
||||
type TourPoiSearchResponse,
|
||||
type WalkTheme,
|
||||
} from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -29,6 +32,10 @@ function thinned(line: Array<[number, number]>, most: number): Array<[number, nu
|
||||
);
|
||||
}
|
||||
|
||||
function sameSet(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((item) => b.includes(item));
|
||||
}
|
||||
|
||||
/** What a search was asked with, so a later change of the route or the choices shows it is out of date. */
|
||||
function keyOf(route: Array<[number, number]> | null, type: string, categories: readonly string[], corridor: number) {
|
||||
const ends = route && route.length > 1 ? [route[0], route[route.length - 1], route.length] : [];
|
||||
@@ -64,6 +71,13 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
|
||||
const currentKey = keyOf(route, type, categories, corridorM);
|
||||
const canSearch = !!route && route.length > 1 && categories.length > 0 && loadingKey === null;
|
||||
|
||||
// CW-05: a city walk's theme is a set of its categories; the one the categories match exactly is the theme on.
|
||||
const theme = type === 'walk' ? (WALK_THEME_KEYS.find((key) => sameSet(WALK_THEMES[key], categories)) ?? null) : null;
|
||||
const chooseTheme = useCallback(
|
||||
(key: WalkTheme) => setChosen((current) => ({ ...current, walk: [...WALK_THEMES[key]] })),
|
||||
[]
|
||||
);
|
||||
|
||||
const toggleCategory = useCallback(
|
||||
(category: TourPoiCategory) =>
|
||||
setChosen((current) => {
|
||||
@@ -98,7 +112,7 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
|
||||
controller.signal
|
||||
);
|
||||
setAnswer({ key, status: 'ready', data });
|
||||
publish(data.pois);
|
||||
publish(data.pois, theme);
|
||||
} catch (err: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
setAnswer({ key, status: err instanceof OnlineOnlyError ? 'offline' : 'error' });
|
||||
@@ -109,7 +123,7 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
|
||||
setLoadingKey(null);
|
||||
}
|
||||
}
|
||||
}, [categories, clear, corridorM, currentKey, locale, planner.tripId, publish, route, type]);
|
||||
}, [categories, clear, corridorM, currentKey, locale, planner.tripId, publish, route, theme, type]);
|
||||
|
||||
// Leaving the planner, or a tour closed, takes the places off the map.
|
||||
useEffect(
|
||||
@@ -125,6 +139,10 @@ export function useTourPoiSearch(planner: TourPoiPlanner) {
|
||||
offered: TOUR_POI_CATEGORIES_BY_TYPE[type],
|
||||
categories,
|
||||
toggleCategory,
|
||||
/** CW-05: a city walk's themes; none for any other type. */
|
||||
themes: type === 'walk' ? WALK_THEME_KEYS : [],
|
||||
theme,
|
||||
chooseTheme,
|
||||
corridorM,
|
||||
setCorridorM,
|
||||
canSearch,
|
||||
|
||||
@@ -30,5 +30,6 @@ describe('movingTimeLabel', () => {
|
||||
expect(movingTimeLabel({ tour_type: 'kayak' }, 'walking', t)).toBe('tours.paddle.timeEstimatedShort');
|
||||
expect(movingTimeLabel({ tour_type: 'hike' }, 'walking', t)).toBe('walking');
|
||||
expect(movingTimeLabel(undefined, 'walking', t)).toBe('walking');
|
||||
expect(movingTimeLabel({ tour_type: 'walk' }, 'walking', t)).toBe('tours.walk.timeEstimatedShort');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,8 @@ export function withoutHeights<
|
||||
|
||||
/**
|
||||
* What the time on the move is called: walking for a hike (in the wording the
|
||||
* place asks for), riding for a bike tour, paddling for a paddle tour.
|
||||
* place asks for) and for a city walk (CW-02), riding for a bike tour,
|
||||
* paddling for a paddle tour.
|
||||
*/
|
||||
export function movingTimeLabel(
|
||||
tour: { tour_type?: string } | undefined,
|
||||
@@ -54,5 +55,6 @@ export function movingTimeLabel(
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (tour?.tour_type === 'kayak') return t('tours.paddle.timeEstimatedShort');
|
||||
if (tour?.tour_type === 'walk') return t('tours.walk.timeEstimatedShort');
|
||||
return tour?.tour_type === 'bike' ? t('tours.bike.timeEstimatedShort') : hikeLabel;
|
||||
}
|
||||
|
||||
@@ -301,7 +301,9 @@ type FieldColumns = keyof typeof FIELD_COLUMNS
|
||||
|
||||
/**
|
||||
* Labelled values in the booking-card look: a fixed number of columns where the
|
||||
* caller wants one row per group, otherwise as many as fit.
|
||||
* caller wants one row per group, otherwise as many as fit. A value too long for
|
||||
* its column, such as a city walk's "at least" total on a phone, wraps rather
|
||||
* than being cut, and the boxes of a row keep one height.
|
||||
*/
|
||||
export function TourFactFields({ fields, columns }: { fields: readonly TourMetricField[]; columns?: FieldColumns }) {
|
||||
return (
|
||||
@@ -309,7 +311,7 @@ export function TourFactFields({ fields, columns }: { fields: readonly TourMetri
|
||||
className={`m-0 grid gap-2 ${columns ? FIELD_COLUMNS[columns] : 'grid-cols-[repeat(auto-fit,minmax(104px,1fr))]'}`}
|
||||
>
|
||||
{fields.map((field) => (
|
||||
<div key={field.label} className="min-w-0">
|
||||
<div key={field.label} className="flex min-w-0 flex-col">
|
||||
<dt className="mb-[3px] flex min-w-0 items-center gap-0.5">
|
||||
<Eyebrow className="relative min-w-0 truncate">
|
||||
<FitLabel full={field.label} short={field.shortLabel} />
|
||||
@@ -321,7 +323,7 @@ export function TourFactFields({ fields, columns }: { fields: readonly TourMetri
|
||||
)}
|
||||
</dt>
|
||||
<dd
|
||||
className={`${BOX} m-0 truncate px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`}
|
||||
className={`${BOX} m-0 flex flex-1 flex-col justify-center break-words px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{field.value}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
|
||||
import { filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
|
||||
|
||||
describe('tourPresentation', () => {
|
||||
it('formats planned Tour totals using the shared compact duration formatter', () => {
|
||||
@@ -10,14 +10,38 @@ describe('tourPresentation', () => {
|
||||
|
||||
it('calculates planned total from walking and breaks unless a manual override exists', () => {
|
||||
expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: null, break_additional_minutes: 25 })).toEqual({
|
||||
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 105, manuallyOverridden: false,
|
||||
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 105, manuallyOverridden: false, walk: null, atLeast: false,
|
||||
})
|
||||
expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: 130, break_additional_minutes: 25 })).toEqual({
|
||||
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 130, manuallyOverridden: true,
|
||||
walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 130, manuallyOverridden: true, walk: null, atLeast: false,
|
||||
})
|
||||
expect(tourPlannedTimes({ duration: null, planned_duration_minutes: null, break_additional_minutes: 25 }).plannedTotalMinutes).toBeNull()
|
||||
})
|
||||
|
||||
it('CW-02: adds a city walk its stays and no breaks, and says the total is at least that while a stay is not entered', () => {
|
||||
const walk = { tour_type: 'walk' as const, duration: 45, planned_duration_minutes: null, break_additional_minutes: 15 }
|
||||
expect(tourPlannedTimes({ ...walk, station_count: 3, station_stay_minutes: 120, station_stays_unknown: 0 })).toEqual({
|
||||
walkingMinutes: 45, breakMinutes: null, plannedTotalMinutes: 165, manuallyOverridden: false, atLeast: false,
|
||||
walk: { walkingMinutes: 45, stayMinutes: 120, unknownStays: 0, stations: 3, totalMinutes: 165, partial: false },
|
||||
})
|
||||
const partial = tourPlannedTimes({ ...walk, station_count: 3, station_stay_minutes: 90, station_stays_unknown: 1 })
|
||||
expect(partial).toMatchObject({ plannedTotalMinutes: 135, atLeast: true })
|
||||
// A total entered by hand is that total, whatever the stays say.
|
||||
expect(tourPlannedTimes({ ...walk, planned_duration_minutes: 200, station_count: 3, station_stay_minutes: 90, station_stays_unknown: 1 }))
|
||||
.toMatchObject({ plannedTotalMinutes: 200, manuallyOverridden: true, atLeast: false })
|
||||
// A list cached before the stays were listed: the walking time alone, and still no breaks.
|
||||
expect(tourPlannedTimes(walk)).toMatchObject({ breakMinutes: null, plannedTotalMinutes: 45, walk: null, atLeast: false })
|
||||
expect(tourPlannedTimes({ ...walk, station_stay_minutes: 30 }).walk).toMatchObject({ stations: 0, unknownStays: 0, totalMinutes: 75 })
|
||||
})
|
||||
|
||||
it('CW-02: says a planned total as it is, at least that much, or as a badge with ≥', () => {
|
||||
const t = (key: string, params?: Record<string, unknown>) => `${key}:${String(params?.time)}`
|
||||
expect(plannedTotalText({ plannedTotalMinutes: null, atLeast: false }, t)).toBeNull()
|
||||
expect(plannedTotalText({ plannedTotalMinutes: 165, atLeast: false }, t)).toBe('2 h 45 min')
|
||||
expect(plannedTotalText({ plannedTotalMinutes: 165, atLeast: true }, t)).toBe('tours.walk.totalAtLeast:2 h 45 min')
|
||||
expect(plannedTotalText({ plannedTotalMinutes: 165, atLeast: true }, t, true)).toBe('≥ 2 h 45 min')
|
||||
})
|
||||
|
||||
it('uses the source abstraction for TREK, GPX import, and wanderer badges', () => {
|
||||
const t = vi.fn((key: string) => key)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TourListItem, TourOrigin } from '@trek/shared'
|
||||
import { tourWebsiteSchema } from '@trek/shared'
|
||||
import { formatDurationShort } from '@trek/shared/roadtrip'
|
||||
import { walkTimesOf, type WalkTimes } from './planner/stationModel'
|
||||
|
||||
export type TourSource = TourOrigin
|
||||
|
||||
@@ -21,20 +22,58 @@ export interface TourPlannedTimes {
|
||||
breakMinutes: number | null
|
||||
plannedTotalMinutes: number | null
|
||||
manuallyOverridden: boolean
|
||||
/** CW-02: a city walk's walking time and stays as the list knows them; null for any other type, or a list without them. */
|
||||
walk: WalkTimes | null
|
||||
/** The planned total leaves out a stay nobody entered yet: it is at least that much. */
|
||||
atLeast: boolean
|
||||
}
|
||||
|
||||
export function tourPlannedTimes(tour: Pick<TourListItem, 'duration' | 'planned_duration_minutes' | 'break_additional_minutes'>): TourPlannedTimes {
|
||||
type PlannedTimesSource = Pick<TourListItem, 'duration' | 'planned_duration_minutes' | 'break_additional_minutes'>
|
||||
& Partial<Pick<TourListItem, 'tour_type' | 'station_count' | 'station_stay_minutes' | 'station_stays_unknown'>>
|
||||
|
||||
/** CW-02: a city walk's times from what the list says of its stations; null where it says nothing of them. */
|
||||
function walkTimesOfTour(tour: PlannedTimesSource): WalkTimes | null {
|
||||
if (tour.tour_type !== 'walk' || tour.station_stay_minutes == null) return null
|
||||
return walkTimesOf({
|
||||
walkingMinutes: tour.duration,
|
||||
stayMinutes: tour.station_stay_minutes,
|
||||
unknownStays: tour.station_stays_unknown ?? 0,
|
||||
stations: tour.station_count ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The times a tour is planned with. A city walk takes no breaks: its stops are
|
||||
* its stays, so a break kept from another type never counts on top (CW-02), and
|
||||
* its total is the walking time and the stays entered.
|
||||
*/
|
||||
export function tourPlannedTimes(tour: PlannedTimesSource): TourPlannedTimes {
|
||||
const manualOverride = tour.planned_duration_minutes ?? null
|
||||
const breaks = tour.break_additional_minutes ?? 0
|
||||
const plannedTotal = manualOverride ?? (tour.duration == null ? null : tour.duration + breaks)
|
||||
const walk = walkTimesOfTour(tour)
|
||||
const breaks = tour.tour_type === 'walk' ? null : (tour.break_additional_minutes ?? null)
|
||||
const automatic = walk ? walk.totalMinutes : (tour.duration == null ? null : tour.duration + (breaks ?? 0))
|
||||
return {
|
||||
walkingMinutes: tour.duration,
|
||||
breakMinutes: tour.break_additional_minutes ?? null,
|
||||
plannedTotalMinutes: plannedTotal,
|
||||
breakMinutes: breaks,
|
||||
plannedTotalMinutes: manualOverride ?? automatic,
|
||||
manuallyOverridden: manualOverride !== null,
|
||||
walk,
|
||||
atLeast: manualOverride === null && !!walk?.partial,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The planned total as said, or null without one: "at least" where a city
|
||||
* walk's stay is not entered yet (CW-02). A badge says it as "≥", the way a
|
||||
* sum short of the whole reads in the tour summary.
|
||||
*/
|
||||
export function plannedTotalText(times: Pick<TourPlannedTimes, 'plannedTotalMinutes' | 'atLeast'>, t: Translate, badge = false): string | null {
|
||||
if (times.plannedTotalMinutes == null) return null
|
||||
const time = formatPlannedTourDuration(times.plannedTotalMinutes)
|
||||
if (!times.atLeast) return time
|
||||
return badge ? `≥ ${time}` : t('tours.walk.totalAtLeast', { time })
|
||||
}
|
||||
|
||||
const TOUR_WEBSITE_PROVIDERS = [
|
||||
{ provider: 'Komoot', domains: ['komoot.com'] },
|
||||
{ provider: 'AllTrails', domains: ['alltrails.com'] },
|
||||
|
||||
@@ -90,7 +90,23 @@ describe('tourScopeSummary (STA-01)', () => {
|
||||
expect(hike.routedMinutes).toEqual({ value: 300, known: 2, of: 2 });
|
||||
expect(hike.importedMinutes).toEqual({ value: 150, known: 1, of: 1 });
|
||||
// Planned: each tour's own time plus its breaks (HIKE_B's 30 min).
|
||||
expect(hike.plannedMinutes).toEqual({ value: 480, known: 3, of: 3 });
|
||||
expect(hike.plannedMinutes).toEqual({ value: 480, known: 3, of: 3, atLeast: false });
|
||||
});
|
||||
|
||||
it('CW-02: plans a city walk with its stays and no breaks, at least that much while a stay is not entered', () => {
|
||||
const walk = tour({
|
||||
place_id: 6,
|
||||
tour_type: 'walk',
|
||||
duration: 45,
|
||||
break_additional_minutes: 15,
|
||||
station_count: 3,
|
||||
station_stay_minutes: 120,
|
||||
station_stays_unknown: 0,
|
||||
});
|
||||
const [entered] = tourScopeSummary([walk]).types;
|
||||
expect(entered.plannedMinutes).toEqual({ value: 165, known: 1, of: 1, atLeast: false });
|
||||
const [open] = tourScopeSummary([walk, { ...walk, place_id: 7, station_stays_unknown: 1 }]).types;
|
||||
expect(open.plannedMinutes).toEqual({ value: 330, known: 2, of: 2, atLeast: true });
|
||||
});
|
||||
|
||||
it('sums no heights for paddling, not even those a recorded paddle track brought', () => {
|
||||
@@ -128,6 +144,22 @@ describe('TourScopeSummary', () => {
|
||||
expect(screen.getByTestId('tour-scope-kayak-distance')).toHaveTextContent('12 km');
|
||||
});
|
||||
|
||||
it('CW-02: marks a planned time whose city walk has a stay not entered as at least, and says so', () => {
|
||||
const walk = tour({
|
||||
place_id: 6,
|
||||
tour_type: 'walk',
|
||||
duration: 45,
|
||||
station_count: 3,
|
||||
station_stay_minutes: 90,
|
||||
station_stays_unknown: 1,
|
||||
});
|
||||
render(<TourScopeSummary tours={[walk]} />);
|
||||
fireEvent.click(screen.getByTestId('tour-scope-toggle'));
|
||||
const planned = screen.getByTestId('tour-scope-walk-planned');
|
||||
expect(planned).toHaveTextContent('≥ 2 h 15 min');
|
||||
expect(planned).toHaveAccessibleName('Planned with stays: at least 2 h 15 min');
|
||||
});
|
||||
|
||||
it('starts folded to its counts, and remembers an opening in this browser', () => {
|
||||
const { unmount } = render(<TourScopeSummary tours={[HIKE_A]} />);
|
||||
expect(screen.queryByTestId('tour-scope-hike')).toBeNull();
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ScopeSum {
|
||||
value: number | null;
|
||||
known: number;
|
||||
of: number;
|
||||
/** CW-02: a value counted is itself only what is known of it (a city walk's stay not entered yet): the sum is at least this. */
|
||||
atLeast?: boolean;
|
||||
}
|
||||
|
||||
/** One tour type's share of the trip: how many tours, how many on a day, and their measures. */
|
||||
@@ -64,7 +66,16 @@ function scopeOfType(type: TourTypeKey, tours: readonly TourListItem[], onDay: O
|
||||
descentM: heights ? sumOf(withHeights.map((tour) => tour?.elevation_loss)) : NO_SUM,
|
||||
routedMinutes: sumOf(planned.map((tour) => tour.duration)),
|
||||
importedMinutes: sumOf(imported.map((tour) => tour.duration)),
|
||||
plannedMinutes: sumOf(tours.map((tour) => tourPlannedTimes(tour).plannedTotalMinutes)),
|
||||
plannedMinutes: plannedSum(tours),
|
||||
};
|
||||
}
|
||||
|
||||
/** The planned totals summed; at least the sum where a city walk's total leaves out a stay nobody entered (CW-02). */
|
||||
function plannedSum(tours: readonly TourListItem[]): ScopeSum {
|
||||
const times = tours.map((tour) => tourPlannedTimes(tour));
|
||||
return {
|
||||
...sumOf(times.map((time) => time.plannedTotalMinutes)),
|
||||
atLeast: times.some((time) => time.atLeast),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// CW-04: a station's arrival, end of stay and opening hours as the planner and
|
||||
// the walk's detail both show them.
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { StationScheduleLine, StationScheduleRow } from './StationSchedule';
|
||||
import type { StationSchedule, WalkSchedule } from './walkSchedule';
|
||||
|
||||
const at = (overrides: Partial<StationSchedule>): StationSchedule => ({
|
||||
arrival: 1040,
|
||||
atLeast: false,
|
||||
leave: 1100,
|
||||
fit: { kind: 'unknown' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const line = (schedule: StationSchedule | undefined) =>
|
||||
render(<StationScheduleLine schedule={schedule} name="Lenbachhaus" />);
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
|
||||
});
|
||||
|
||||
describe('StationScheduleLine', () => {
|
||||
it('names station, arrival, end of stay and window for the card case: 17:20 to 18:20 against a closing at 18:00', () => {
|
||||
line(at({ fit: { kind: 'closes', source: 'osm', window: [600, 1080] } }));
|
||||
expect(screen.getByText('Arrival about 17:20, stay until 18:20')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
|
||||
'Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)'
|
||||
);
|
||||
expect(screen.getByTestId('walk-hours-source')).toHaveTextContent('OpenStreetMap');
|
||||
expect(screen.getByText('Found with the stop on OpenStreetMap; it may be out of date.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows the 12 hour format, a window to midnight included', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
|
||||
const { unmount } = line(at({ leave: 1130, fit: { kind: 'closes', source: 'place', window: [600, 1080] } }));
|
||||
expect(screen.getByText('Arrival about 5:20 PM, stay until 6:50 PM')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
|
||||
'Lenbachhaus closes at 6:00 PM, during the stay from 5:20 PM to 6:50 PM (open 10:00 AM to 6:00 PM)'
|
||||
);
|
||||
expect(screen.getByTestId('walk-hours-source')).toHaveTextContent('Trip place');
|
||||
// In 12 hours a window to midnight ends at 12:00 AM, never at 24:00.
|
||||
unmount();
|
||||
line(at({ fit: { kind: 'open', source: 'place', window: [600, 1440] } }));
|
||||
expect(screen.getByText(/Open 10:00 AM to 12:00 AM/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the window an open station is checked against; one to midnight reads 24:00', () => {
|
||||
line(at({ fit: { kind: 'open', source: 'place', window: [600, 1440] } }));
|
||||
expect(screen.getByText(/Open 10:00 to 24:00/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a station closed at its arrival with the windows of the day, with and without a stay', () => {
|
||||
const fit = {
|
||||
kind: 'closed',
|
||||
source: 'osm',
|
||||
windows: [
|
||||
[600, 720],
|
||||
[840, 1080],
|
||||
],
|
||||
} as const;
|
||||
const { unmount } = line(at({ arrival: 780, leave: 810, fit }));
|
||||
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
|
||||
'Lenbachhaus is closed at 13:00, the stay would last until 13:30; open 10:00 to 12:00, 14:00 to 18:00'
|
||||
);
|
||||
unmount();
|
||||
line(at({ arrival: 780, leave: null, fit }));
|
||||
expect(screen.getByText('Arrival about 13:00')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent(
|
||||
'Lenbachhaus is closed at 13:00; open 10:00 to 12:00, 14:00 to 18:00'
|
||||
);
|
||||
});
|
||||
|
||||
it('names a station closed all day, and a point only passed by its arrival alone', () => {
|
||||
line(at({ leave: 1040, fit: { kind: 'closedAllDay', source: 'osm' } }));
|
||||
expect(screen.getByText('Arrival about 17:20')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('walk-station-conflict')).toHaveTextContent('Lenbachhaus is closed all day on this date');
|
||||
});
|
||||
|
||||
it('for an arrival at the earliest, says the hours are not checked yet', () => {
|
||||
line(at({ atLeast: true, fit: { kind: 'uncertain', source: 'osm' } }));
|
||||
expect(screen.getByText('Arrival 17:20 at the earliest, stay until 18:20 or later')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Opening hours not checked: a stay before this stop is not entered yet\./)
|
||||
).toBeInTheDocument();
|
||||
const { container } = line(at({ atLeast: true, leave: null }));
|
||||
expect(container).toHaveTextContent('Arrival 17:20 at the earliest');
|
||||
});
|
||||
|
||||
it('gives hours it cannot read back as written, and says nothing for hours not known', () => {
|
||||
const { unmount } = line(at({ fit: { kind: 'unread', text: 'PH off' } }));
|
||||
expect(screen.getByText('Opening hours not read here: PH off')).toBeInTheDocument();
|
||||
unmount();
|
||||
line(at({ fit: { kind: 'unknown' } }));
|
||||
expect(screen.getByTestId('walk-station-hours')).toHaveTextContent('Arrival about 17:20, stay until 18:20');
|
||||
expect(screen.queryByText(/Open/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('walk-hours-source')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says an arrival after midnight is on the next day and is not checked', () => {
|
||||
line(at({ arrival: 1470, leave: 1500, fit: { kind: 'nextDay' } }));
|
||||
expect(
|
||||
screen.getByText('Arrival about 00:30 the next day; opening hours are not checked across midnight.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows nothing without a schedule', () => {
|
||||
const { container } = line(undefined);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StationScheduleRow', () => {
|
||||
const schedule: WalkSchedule = {
|
||||
state: 'ready',
|
||||
anyHours: true,
|
||||
byStation: new Map([['a', at({ fit: { kind: 'closedAllDay', source: 'osm' } })]]),
|
||||
};
|
||||
|
||||
it("names the station by its own name, else the place's, else its number", () => {
|
||||
const { rerender } = render(
|
||||
<StationScheduleRow schedule={schedule} point={{ id: 'a', name: 'Museum' }} placeName="Square" number={2} />
|
||||
);
|
||||
expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Museum is closed all day');
|
||||
rerender(<StationScheduleRow schedule={schedule} point={{ id: 'a' }} placeName="Square" number={2} />);
|
||||
expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Square is closed all day');
|
||||
rerender(<StationScheduleRow schedule={schedule} point={{ id: 'a' }} placeName={undefined} number={2} />);
|
||||
expect(screen.getByTestId('walk-station-schedule')).toHaveTextContent('Stop 2 is closed all day');
|
||||
});
|
||||
|
||||
it('shows nothing for a point the schedule has no station for', () => {
|
||||
render(<StationScheduleRow schedule={schedule} point={{ id: 'b' }} placeName={undefined} number={3} />);
|
||||
expect(screen.queryByTestId('walk-station-schedule')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { clockOf, type HoursSource, type HoursWindow, type StationFit } from '@trek/shared';
|
||||
import { AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatClockTime } from '../../../utils/formatters';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { StationSchedule, WalkSchedule } from './walkSchedule';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/** Where a station's hours come from, named on the pill, with how far to trust them behind it. */
|
||||
const SOURCES: Record<HoursSource, { labelKey: string; hintKey: string }> = {
|
||||
osm: { labelKey: 'tours.walk.hours.sourceOsm', hintKey: 'tours.walk.hours.sourceOsmHint' },
|
||||
place: { labelKey: 'tours.walk.hours.sourcePlace', hintKey: 'tours.walk.hours.sourcePlaceHint' },
|
||||
};
|
||||
|
||||
interface ClockFormat {
|
||||
/** A time of the day in the user's 12 or 24 hours. */
|
||||
at: (minutes: number) => string;
|
||||
/** The end of a window: one running to midnight reads 24:00 in 24 hours. */
|
||||
end: (minutes: number) => string;
|
||||
}
|
||||
|
||||
function useClock(): ClockFormat {
|
||||
const is12h = useSettingsStore((state) => state.settings.time_format) === '12h';
|
||||
const at = (minutes: number) => formatClockTime(clockOf(minutes), is12h);
|
||||
return { at, end: (minutes) => (minutes >= 24 * 60 && !is12h ? '24:00' : at(minutes)) };
|
||||
}
|
||||
|
||||
function windowsText(windows: readonly HoursWindow[], clock: ClockFormat, t: Translate): string {
|
||||
return windows
|
||||
.map(([from, to]) => t('tours.walk.hours.window', { from: clock.at(from), to: clock.end(to) }))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** The arrival, and the end of the stay where it lasts; the next day said as such. */
|
||||
function arrivalText(schedule: StationSchedule, clock: ClockFormat, t: Translate): string {
|
||||
const arrival = clock.at(schedule.arrival);
|
||||
if (schedule.fit.kind === 'nextDay') return t('tours.walk.arrivalNextDay', { time: arrival });
|
||||
const leave = schedule.leave ?? schedule.arrival;
|
||||
if (leave > schedule.arrival) {
|
||||
return t(schedule.atLeast ? 'tours.walk.arrivalStayAtLeast' : 'tours.walk.arrivalStay', {
|
||||
arrival,
|
||||
leave: clock.at(leave),
|
||||
});
|
||||
}
|
||||
return t(schedule.atLeast ? 'tours.walk.arrivalAtLeast' : 'tours.walk.arrival', { time: arrival });
|
||||
}
|
||||
|
||||
type Conflict = Extract<StationFit, { kind: 'closes' | 'closed' | 'closedAllDay' }>;
|
||||
|
||||
/** A conflict in one sentence: the station, the arrival, the end of the stay and the window it was checked against. */
|
||||
function conflictText(
|
||||
fit: Conflict,
|
||||
schedule: StationSchedule,
|
||||
name: string,
|
||||
clock: ClockFormat,
|
||||
t: Translate
|
||||
): string {
|
||||
const arrival = clock.at(schedule.arrival);
|
||||
const leave = schedule.leave ?? schedule.arrival;
|
||||
if (fit.kind === 'closedAllDay') return t('tours.walk.hours.closedAllDay', { name });
|
||||
if (fit.kind === 'closes') {
|
||||
return t('tours.walk.hours.closes', {
|
||||
name,
|
||||
closes: clock.end(fit.window[1]),
|
||||
arrival,
|
||||
leave: clock.at(leave),
|
||||
window: windowsText([fit.window], clock, t),
|
||||
});
|
||||
}
|
||||
const open = windowsText(fit.windows, clock, t);
|
||||
return leave > schedule.arrival
|
||||
? t('tours.walk.hours.closed', { name, arrival, leave: clock.at(leave), window: open })
|
||||
: t('tours.walk.hours.closedNoStay', { name, arrival, window: open });
|
||||
}
|
||||
|
||||
function SourcePill({ source }: { source: HoursSource }) {
|
||||
const { t } = useTranslation();
|
||||
const { labelKey, hintKey } = SOURCES[source];
|
||||
return (
|
||||
<Tooltip label={t(hintKey)}>
|
||||
<span className="inline-flex" data-testid="walk-hours-source">
|
||||
<SoftPill>{t(labelKey)}</SoftPill>
|
||||
<span className="sr-only">{t(hintKey)}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the hours say: a conflict in the warning tone, otherwise a quiet line; nothing where none are known. */
|
||||
function FitLine({ schedule, name, clock }: { schedule: StationSchedule; name: string; clock: ClockFormat }) {
|
||||
const { t } = useTranslation();
|
||||
const { fit } = schedule;
|
||||
if (fit.kind === 'unknown' || fit.kind === 'nextDay') return null;
|
||||
if (fit.kind === 'unread') {
|
||||
return (
|
||||
<span className="break-words text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.walk.hours.unread', { text: fit.text })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (fit.kind === 'open' || fit.kind === 'uncertain') {
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-1 text-content-faint" style={fs(10.5, 'body')}>
|
||||
{fit.kind === 'open'
|
||||
? t('tours.walk.hours.open', { window: windowsText([fit.window], clock, t) })
|
||||
: t('tours.walk.hours.uncertain')}
|
||||
<SourcePill source={fit.source} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inline-flex flex-wrap items-start gap-1 text-warning"
|
||||
style={fs(11, 'body')}
|
||||
data-testid="walk-station-conflict"
|
||||
>
|
||||
<AlertTriangle size={11} strokeWidth={2.2} className="mt-0.5 flex-none" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 break-words">{conflictText(fit, schedule, name, clock, t)}</span>
|
||||
<SourcePill source={fit.source} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-04: a station on the day looked at: its expected arrival, the end of its
|
||||
* stay, and what its opening hours say, with where they come from. The same
|
||||
* lines in the planner and in the walk's detail; nothing is changed by them.
|
||||
*/
|
||||
export function StationScheduleLine({ schedule, name }: { schedule: StationSchedule | undefined; name: string }) {
|
||||
const { t } = useTranslation();
|
||||
const clock = useClock();
|
||||
if (!schedule) return null;
|
||||
return (
|
||||
<span className="flex flex-col gap-0.5" data-testid="walk-station-hours">
|
||||
<span className="inline-flex items-start gap-1 text-content-muted" style={fs(11, 'body')}>
|
||||
<Clock size={11} strokeWidth={2.2} className="mt-0.5 flex-none" aria-hidden="true" />
|
||||
<span className="min-w-0 break-words">{arrivalText(schedule, clock, t)}</span>
|
||||
</span>
|
||||
<FitLine schedule={schedule} name={name} clock={clock} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The station's lines under its row in the planner, beside its select button
|
||||
* and aligned with its name; nothing for a point that is no station.
|
||||
*/
|
||||
export function StationScheduleRow({
|
||||
schedule,
|
||||
point,
|
||||
placeName,
|
||||
number,
|
||||
}: {
|
||||
schedule: WalkSchedule;
|
||||
point: Pick<TourPlannerWaypoint, 'id' | 'name'>;
|
||||
/** The name the row shows for a point without its own: the trip place it stands for, else the place found there. */
|
||||
placeName: string | undefined;
|
||||
/** The stop's number, for a station without any name. */
|
||||
number: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const at = schedule.byStation.get(point.id);
|
||||
if (!at) return null;
|
||||
return (
|
||||
<div className="pb-1 pe-1.5 ps-[66px]" data-testid="walk-station-schedule">
|
||||
<StationScheduleLine
|
||||
schedule={at}
|
||||
name={point.name ?? placeName ?? t('tours.walk.stationNumbered', { number })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// CW-02, CW-04: the day a city walk's times are for, picked where there is a
|
||||
// choice, what is missing for them and where the hours come from.
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildDay } from '../../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../../tests/helpers/store';
|
||||
import type { WalkDayChoice, WalkDayOption } from './useWalkDay';
|
||||
import { WalkDayLine } from './WalkDayLine';
|
||||
import type { WalkSchedule, WalkScheduleState } from './walkSchedule';
|
||||
|
||||
const monday: WalkDayOption = {
|
||||
day: buildDay({ id: 1, date: '2026-10-19', title: 'Old town' }),
|
||||
index: 0,
|
||||
start: null,
|
||||
};
|
||||
const tuesday: WalkDayOption = { day: buildDay({ id: 2, date: '2026-10-20' }), index: 1, start: '09:30' };
|
||||
|
||||
const schedule = (state: WalkScheduleState, anyHours = false): WalkSchedule => ({
|
||||
state,
|
||||
anyHours,
|
||||
byStation: new Map(),
|
||||
});
|
||||
|
||||
const choice = (overrides: Partial<WalkDayChoice> = {}): WalkDayChoice => ({
|
||||
options: [monday, tuesday],
|
||||
day: tuesday,
|
||||
fixed: false,
|
||||
choose: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const NO_DAY = 'Put the walk on a day with a start time to see arrivals and opening hours.';
|
||||
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
describe('WalkDayLine', () => {
|
||||
it("lets another of the walk's days be picked from a menu, the one shown marked", () => {
|
||||
function Picker() {
|
||||
const [day, setDay] = useState<WalkDayOption>(tuesday);
|
||||
const pick = (id: number) => setDay(id === 1 ? monday : tuesday);
|
||||
return <WalkDayLine choice={choice({ day, choose: pick })} schedule={schedule('ready')} stationCount={3} />;
|
||||
}
|
||||
render(<Picker />);
|
||||
expect(screen.getByText('Times for')).toBeInTheDocument();
|
||||
const picker = screen.getByTestId('walk-day-picker');
|
||||
// The pill names the date the hours are for, as the menu does.
|
||||
expect(picker).toHaveTextContent(/^Day 2, Tue, Oct 20(, 2026)?, start 09:30$/);
|
||||
expect(picker).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(picker);
|
||||
expect(picker).toHaveAttribute('aria-expanded', 'true');
|
||||
const items = [...screen.getByRole('menu').querySelectorAll('button')];
|
||||
expect(items.map((item) => item.textContent)).toEqual([
|
||||
expect.stringMatching(/^Day 1, .*: Old town, no start time$/),
|
||||
expect.stringMatching(/^Day 2, .*, start 09:30$/),
|
||||
]);
|
||||
expect(items.map((item) => !!item.querySelector('svg'))).toEqual([false, true]);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Day 1, / }));
|
||||
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent(
|
||||
/^Day 1, Mon, Oct 19(, 2026)?: Old town, no start time$/
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the menu again from its pill', () => {
|
||||
render(<WalkDayLine choice={choice()} schedule={schedule('ready')} stationCount={3} />);
|
||||
const picker = screen.getByTestId('walk-day-picker');
|
||||
fireEvent.click(picker);
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument();
|
||||
fireEvent.click(picker);
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the day alone where it is fixed or the only one', () => {
|
||||
const { rerender } = render(
|
||||
<WalkDayLine choice={choice({ fixed: true })} schedule={schedule('ready')} stationCount={3} />
|
||||
);
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 2, Tue, Oct 20(, 2026)?, start 09:30$/);
|
||||
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<WalkDayLine
|
||||
choice={choice({ options: [monday], day: monday })}
|
||||
schedule={schedule('noStart')}
|
||||
stationCount={3}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, .+: Old town, no start time$/);
|
||||
expect(screen.getByText('Give the walk a time on this day to see arrivals and opening hours.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says what is missing: a day once there are stops, a date', () => {
|
||||
const none = choice({ options: [], day: null });
|
||||
const { rerender, container } = render(<WalkDayLine choice={none} schedule={schedule('noDay')} stationCount={2} />);
|
||||
expect(screen.getByText(NO_DAY)).toBeInTheDocument();
|
||||
rerender(<WalkDayLine choice={none} schedule={schedule('noDay')} stationCount={1} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
// Opened from a day the walk has left: that day is not replaced, and nothing is asked.
|
||||
rerender(<WalkDayLine choice={{ ...none, fixed: true }} schedule={schedule('noDay')} stationCount={3} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
rerender(<WalkDayLine choice={choice()} schedule={schedule('noDate')} stationCount={3} />);
|
||||
expect(screen.getByText('This day has no date, so arrivals and opening hours are left out.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names where the hours come from once there are any to go by, and nothing for another tour', () => {
|
||||
const { rerender, container } = render(
|
||||
<WalkDayLine choice={choice()} schedule={schedule('ready', true)} stationCount={3} />
|
||||
);
|
||||
expect(screen.getByText(/Opening hours from OpenStreetMap or the trip's places/)).toBeInTheDocument();
|
||||
rerender(<WalkDayLine choice={choice()} schedule={schedule('noRoute', true)} stationCount={3} />);
|
||||
expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument();
|
||||
rerender(<WalkDayLine enabled={false} choice={choice()} schedule={schedule('ready', true)} stationCount={3} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Check, ChevronDown, Clock } from 'lucide-react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatClockTime } from '../../../utils/formatters';
|
||||
import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { dayMenuLabel } from '../tourDays';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import type { WalkDayChoice, WalkDayOption } from './useWalkDay';
|
||||
import type { WalkSchedule, WalkScheduleState } from './walkSchedule';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
const PILL =
|
||||
'inline-flex min-w-0 items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-start font-medium text-content-secondary shadow-sm';
|
||||
|
||||
/** "Day 2, Tue, Jul 8, start 09:00", or that it has no start time; `day` names the day with its date. */
|
||||
function optionLabel(option: WalkDayOption, day: string, t: Translate, is12h: boolean): string {
|
||||
return option.start
|
||||
? t('tours.walk.day.start', { day, time: formatClockTime(option.start, is12h) })
|
||||
: t('tours.walk.day.noStart', { day });
|
||||
}
|
||||
|
||||
/** What keeps the arrivals from being worked out, said once; a route still being planned says so elsewhere. */
|
||||
function missingNote(state: WalkScheduleState, choice: WalkDayChoice, stationCount: number, t: Translate) {
|
||||
if (state === 'noDay') return !choice.fixed && stationCount >= 2 ? t('tours.walk.hours.noDay') : null;
|
||||
if (state === 'noDate') return t('tours.walk.hours.noDate');
|
||||
if (state === 'noStart') return t('tours.walk.hours.noStart');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The day as a pill: a menu of the walk's days where there is a choice, its name alone where there is none.
|
||||
* It names the day with its date as the menu does, so the opening hours below read for a day that is in view.
|
||||
*/
|
||||
function DayPill({ choice, day }: { choice: WalkDayChoice; day: WalkDayOption }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const is12h = useSettingsStore((state) => state.settings.time_format) === '12h';
|
||||
const menu = useContextMenu();
|
||||
const label = optionLabel(day, dayMenuLabel(day.day, day.index, t, locale), t, is12h);
|
||||
if (choice.fixed || choice.options.length < 2) {
|
||||
return (
|
||||
<span data-testid="walk-day" className={`${PILL} break-words`} style={fs(11, 'body')}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const items = choice.options.map((option) => ({
|
||||
label: optionLabel(option, dayMenuLabel(option.day, option.index, t, locale), t, is12h),
|
||||
icon: option === day ? Check : undefined,
|
||||
onClick: () => choice.choose(option.day.id),
|
||||
}));
|
||||
const toggle = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (menu.menu) {
|
||||
event.stopPropagation();
|
||||
menu.close();
|
||||
} else menu.open(event, items, true);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="walk-day-picker"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!menu.menu}
|
||||
onClick={toggle}
|
||||
className={`${PILL} transition-colors hover:text-content`}
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
<span className="min-w-0 break-words">{label}</span>
|
||||
<ChevronDown size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||
</button>
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02, CW-04: the day a city walk's arrivals and opening hours are worked
|
||||
* out for, above its stations, in the planner and in the walk's detail alike;
|
||||
* what is missing for them; and, once there are hours to go by, where they
|
||||
* come from and that they are a hint to check.
|
||||
*/
|
||||
export function WalkDayLine({
|
||||
enabled = true,
|
||||
choice,
|
||||
schedule,
|
||||
stationCount,
|
||||
className = '',
|
||||
}: {
|
||||
/** Only a city walk has the line. */
|
||||
enabled?: boolean;
|
||||
choice: WalkDayChoice;
|
||||
schedule: WalkSchedule;
|
||||
stationCount: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!enabled) return null;
|
||||
const note = missingNote(schedule.state, choice, stationCount, t);
|
||||
if (!choice.day && !note) return null;
|
||||
return (
|
||||
<div data-testid="walk-day-line" className={`flex flex-col gap-1.5 ${className}`}>
|
||||
{choice.day && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.walk.day.label')}
|
||||
</span>
|
||||
<DayPill choice={choice} day={choice.day} />
|
||||
</div>
|
||||
)}
|
||||
{note && (
|
||||
<p className="m-0 text-content-faint" style={fs(11, 'body')}>
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
{schedule.state === 'ready' && schedule.anyHours && (
|
||||
<TourNotice icon={<Clock size={13} strokeWidth={2} />}>{t('tours.walk.hours.source')}</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { WalkImportPreviewResponse } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import type { WalkImport } from './useWalkImport';
|
||||
import { WalkImportDialog, WalkImportDraftNotice } from './WalkImportDialog';
|
||||
|
||||
// FE-WALK-IMPORT-DIALOG-001 to FE-WALK-IMPORT-DIALOG-006
|
||||
|
||||
const ROUTE = 'https://www.google.com/maps/dir/Cathedral/Market/Park/data=!3e2';
|
||||
const PREVIEW: WalkImportPreviewResponse = {
|
||||
stations: [
|
||||
{ name: 'Cathedral', lat: 48.1, lng: 11.5 },
|
||||
{ name: 'Market', lat: null, lng: null },
|
||||
{ name: null, lat: 48.12, lng: 11.52 },
|
||||
],
|
||||
resolved_url: ROUTE,
|
||||
};
|
||||
|
||||
function walkImport(over: Partial<WalkImport> = {}): WalkImport {
|
||||
return {
|
||||
open: true,
|
||||
show: vi.fn(),
|
||||
close: vi.fn(),
|
||||
url: '',
|
||||
setUrl: vi.fn(),
|
||||
phase: 'link',
|
||||
preview: null,
|
||||
failure: null,
|
||||
unplaced: 0,
|
||||
canRead: false,
|
||||
canLoad: false,
|
||||
read: vi.fn().mockResolvedValue(undefined),
|
||||
back: vi.fn(),
|
||||
load: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const atPreview = (over: Partial<WalkImport> = {}) =>
|
||||
walkImport({ phase: 'preview', preview: PREVIEW, unplaced: 1, canLoad: true, url: ROUTE, ...over });
|
||||
|
||||
describe('WalkImportDialog (CW-07)', () => {
|
||||
it('FE-WALK-IMPORT-DIALOG-001: asks for the link, reads it on the button and on Enter, and says why one was refused', () => {
|
||||
const closed = walkImport({ open: false });
|
||||
const { rerender } = render(<WalkImportDialog walkImport={closed} hasUnsavedChanges={false} />);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
|
||||
const w = walkImport({ url: 'https://maps.app.goo.gl/abc', canRead: true });
|
||||
rerender(<WalkImportDialog walkImport={w} hasUnsavedChanges={false} />);
|
||||
expect(screen.getByText('Walk from a Google Maps link')).toBeInTheDocument();
|
||||
const input = screen.getByLabelText('Link to the walking route');
|
||||
fireEvent.change(input, { target: { value: 'https://maps.app.goo.gl/xyz' } });
|
||||
expect(w.setUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/xyz');
|
||||
fireEvent.click(screen.getByTestId('walk-import-read'));
|
||||
expect(w.read).toHaveBeenCalledTimes(1);
|
||||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||||
expect(w.read).toHaveBeenCalledTimes(2);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(w.close).toHaveBeenCalledTimes(1);
|
||||
|
||||
const refused = walkImport({ failure: 'That route is not planned on foot.' });
|
||||
rerender(<WalkImportDialog walkImport={refused} hasUnsavedChanges={false} />);
|
||||
expect(screen.getByText('That route is not planned on foot.')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('walk-import-read')).toBeDisabled();
|
||||
fireEvent.submit(screen.getByLabelText('Link to the walking route').closest('form') as HTMLFormElement);
|
||||
expect(refused.read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-DIALOG-002: waits while the link is read', () => {
|
||||
render(<WalkImportDialog walkImport={walkImport({ phase: 'reading', url: ROUTE })} hasUnsavedChanges={false} />);
|
||||
expect(screen.getByLabelText('Link to the walking route')).toBeDisabled();
|
||||
expect(screen.getByTestId('walk-import-read')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-DIALOG-003: shows the stops in their order, the one without a position marked', () => {
|
||||
render(<WalkImportDialog walkImport={atPreview()} hasUnsavedChanges={false} />);
|
||||
const rows = screen.getAllByTestId('walk-import-station');
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
'1Cathedral48.10000, 11.50000',
|
||||
'2MarketNo position in the link',
|
||||
'3Stop 348.12000, 11.52000',
|
||||
]);
|
||||
expect(rows.map((row) => row.getAttribute('data-unresolved'))).toEqual([null, 'true', null]);
|
||||
expect(screen.getByText('1 stop has no position in the link and is left out.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('TREK plans the way between the stops again; the line drawn in Google Maps is not taken over.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('A walk needs at least two stops with a position.')).toBeNull();
|
||||
expect(screen.queryByText('Loading the walk replaces the unsaved changes in the planner.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-DIALOG-004: names Google Maps as the source and opens the route read there in a new tab', () => {
|
||||
render(<WalkImportDialog walkImport={atPreview()} hasUnsavedChanges={false} />);
|
||||
const preview = screen.getByTestId('walk-import-preview');
|
||||
expect(within(preview).getByText('From Google Maps')).toBeInTheDocument();
|
||||
const source = screen.getByTestId('walk-import-source');
|
||||
expect(source).toHaveAttribute('href', ROUTE);
|
||||
expect(source).toHaveAttribute('target', '_blank');
|
||||
expect(source).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(source).toHaveTextContent('Open in Google Maps');
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-DIALOG-005: loads or goes back, warns before replacing unsaved work, and loads nothing with too few positions', () => {
|
||||
const w = atPreview();
|
||||
const { rerender } = render(<WalkImportDialog walkImport={w} hasUnsavedChanges />);
|
||||
expect(screen.getByText('Loading the walk replaces the unsaved changes in the planner.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('walk-import-load'));
|
||||
expect(w.load).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByText('Use another link'));
|
||||
expect(w.back).toHaveBeenCalledTimes(1);
|
||||
// Enter does not read again while the stops are shown.
|
||||
fireEvent.submit(screen.getByTestId('walk-import-preview').closest('form') as HTMLFormElement);
|
||||
expect(w.read).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<WalkImportDialog walkImport={atPreview({ canLoad: false, unplaced: 2 })} hasUnsavedChanges={false} />);
|
||||
expect(screen.getByTestId('walk-import-load')).toBeDisabled();
|
||||
expect(screen.getByText('A walk needs at least two stops with a position.')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 stops have no position in the link and are left out.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WalkImportDraftNotice (CW-07)', () => {
|
||||
it('FE-WALK-IMPORT-DIALOG-006: says where an unsaved walk came from, and nothing for any other draft', () => {
|
||||
const { rerender } = render(
|
||||
<WalkImportDraftNotice planner={{ draftSource: 'google_maps', mode: { type: 'new-draft' } }} />
|
||||
);
|
||||
expect(
|
||||
screen.getByText('From a Google Maps walking route. TREK planned the way between the stops itself.')
|
||||
).toBeInTheDocument();
|
||||
rerender(
|
||||
<WalkImportDraftNotice planner={{ draftSource: 'google_maps', mode: { type: 'edit-saved', placeId: 3 } }} />
|
||||
);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
rerender(<WalkImportDraftNotice planner={{ draftSource: null, mode: { type: 'new-draft' } }} />);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { WalkImportPreviewResponse, WalkImportStation } from '@trek/shared';
|
||||
import { AlertTriangle, ExternalLink, Footprints, Link2, Loader2, MapPinOff, Route } from 'lucide-react';
|
||||
import { useId, type FormEvent } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { EditorField, INPUT } from '../../shared/dialogParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../../shared/DialogShell';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import type { WalkImport } from './useWalkImport';
|
||||
|
||||
/** One stop of the link, in its place in the order; a stop without a position says so instead of a position. */
|
||||
function WalkImportStationRow({ station, number }: { station: WalkImportStation; number: number }) {
|
||||
const { t } = useTranslation();
|
||||
const position =
|
||||
station.lat !== null && station.lng !== null ? `${station.lat.toFixed(5)}, ${station.lng.toFixed(5)}` : null;
|
||||
const placed = position !== null;
|
||||
return (
|
||||
<li
|
||||
data-testid="walk-import-station"
|
||||
data-unresolved={placed ? undefined : 'true'}
|
||||
className={`flex items-start gap-2.5 rounded-[10px] px-2.5 py-2 ${placed ? 'bg-surface-card' : 'border border-dashed border-edge'}`}
|
||||
>
|
||||
<span
|
||||
className="mt-px grid h-5 w-5 flex-none place-items-center rounded-full bg-surface-secondary font-geist font-semibold text-content-muted"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
className={`break-words font-medium leading-snug ${placed ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{station.name ?? t('tours.walk.stationNumbered', { number })}
|
||||
</span>
|
||||
{placed ? (
|
||||
<span className="font-geist text-content-faint" style={fs(11)}>
|
||||
{position}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-warning" style={fs(11, 'body')}>
|
||||
<MapPinOff size={11} strokeWidth={2.2} className="flex-none" />
|
||||
{t('tours.walkImport.noPosition')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The step that asks for the link. */
|
||||
function WalkImportLinkStep({ walkImport }: { walkImport: WalkImport }) {
|
||||
const { t } = useTranslation();
|
||||
const inputId = useId();
|
||||
return (
|
||||
<EditorField
|
||||
label={t('tours.walkImport.urlLabel')}
|
||||
htmlFor={inputId}
|
||||
hint={t('tours.walkImport.urlHint')}
|
||||
error={walkImport.failure}
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={INPUT}
|
||||
value={walkImport.url}
|
||||
placeholder="https://maps.app.goo.gl/…"
|
||||
disabled={walkImport.phase === 'reading'}
|
||||
onChange={(event) => walkImport.setUrl(event.target.value)}
|
||||
data-testid="walk-import-url"
|
||||
/>
|
||||
</EditorField>
|
||||
);
|
||||
}
|
||||
|
||||
/** The step that shows what the link holds, before anything is loaded. */
|
||||
function WalkImportPreviewStep({
|
||||
preview,
|
||||
unplaced,
|
||||
canLoad,
|
||||
hasUnsavedChanges,
|
||||
}: {
|
||||
preview: WalkImportPreviewResponse;
|
||||
unplaced: number;
|
||||
canLoad: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-4" data-testid="walk-import-preview">
|
||||
<p className="m-0 flex flex-wrap items-center gap-x-2 gap-y-1 text-content-muted" style={fs(12, 'body')}>
|
||||
<Footprints size={13} strokeWidth={2} className="flex-none" />
|
||||
<span>{t('tours.walkImport.source')}</span>
|
||||
<a
|
||||
href={preview.resolved_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-accent hover:underline"
|
||||
data-testid="walk-import-source"
|
||||
>
|
||||
{t('tours.walkImport.openSource')}
|
||||
<ExternalLink size={12} strokeWidth={2} className="flex-none" />
|
||||
</a>
|
||||
</p>
|
||||
<TourNotice icon={<Route size={13} strokeWidth={2} />}>{t('tours.walkImport.recalculated')}</TourNotice>
|
||||
<DialogSection label={t('tours.walkImport.stops')}>
|
||||
<ol className="m-0 flex list-none flex-col gap-1 p-0">
|
||||
{preview.stations.map((station, index) => (
|
||||
<WalkImportStationRow key={index} station={station} number={index + 1} />
|
||||
))}
|
||||
</ol>
|
||||
</DialogSection>
|
||||
{unplaced > 0 && (
|
||||
<TourNotice tone="warning" icon={<MapPinOff size={13} strokeWidth={2} />}>
|
||||
{t('tours.walkImport.unresolved', { count: unplaced })}
|
||||
</TourNotice>
|
||||
)}
|
||||
{!canLoad && <TourNotice tone="warning">{t('tours.walkImport.tooFewPositions')}</TourNotice>}
|
||||
{hasUnsavedChanges && (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{t('tours.walkImport.replacesDraft')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The buttons of each step: read the link, or go back and load the walk. */
|
||||
function WalkImportFooter({ walkImport }: { walkImport: WalkImport }) {
|
||||
const { t } = useTranslation();
|
||||
const reading = walkImport.phase === 'reading';
|
||||
return (
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={walkImport.close}>{t('common.cancel')}</DialogButton>
|
||||
{walkImport.preview ? (
|
||||
<>
|
||||
<DialogButton onClick={walkImport.back}>{t('tours.walkImport.back')}</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
icon={<Footprints size={14} strokeWidth={2} />}
|
||||
disabled={!walkImport.canLoad}
|
||||
onClick={walkImport.load}
|
||||
data-testid="walk-import-load"
|
||||
>
|
||||
{t('tours.walkImport.load')}
|
||||
</DialogButton>
|
||||
</>
|
||||
) : (
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
icon={
|
||||
reading ? (
|
||||
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
|
||||
) : (
|
||||
<Link2 size={14} strokeWidth={2} />
|
||||
)
|
||||
}
|
||||
disabled={!walkImport.canRead}
|
||||
onClick={() => void walkImport.read()}
|
||||
data-testid="walk-import-read"
|
||||
>
|
||||
{t('tours.walkImport.read')}
|
||||
</DialogButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-07: a city walk from a Google Maps walking route, opened from "Add tour".
|
||||
* First the link, then its stops in their order with the ones the link gives
|
||||
* no position marked, where the route came from, and that TREK plans the way
|
||||
* between them itself. Only the load button hands the walk to the planner.
|
||||
*/
|
||||
export function WalkImportDialog({
|
||||
walkImport,
|
||||
hasUnsavedChanges,
|
||||
}: {
|
||||
walkImport: WalkImport;
|
||||
hasUnsavedChanges: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
if (!walkImport.open) return null;
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!walkImport.preview && walkImport.canRead) void walkImport.read();
|
||||
};
|
||||
const head = (
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Footprints size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={titleId}
|
||||
onClose={walkImport.close}
|
||||
title={t('tours.walkImport.title')}
|
||||
sub={t('tours.walkImport.intro')}
|
||||
subWraps
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={walkImport.close}
|
||||
labelledBy={titleId}
|
||||
width="narrow"
|
||||
align="top"
|
||||
onSubmit={submit}
|
||||
header={head}
|
||||
footer={<WalkImportFooter walkImport={walkImport} />}
|
||||
>
|
||||
{walkImport.preview ? (
|
||||
<WalkImportPreviewStep
|
||||
preview={walkImport.preview}
|
||||
unplaced={walkImport.unplaced}
|
||||
canLoad={walkImport.canLoad}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
/>
|
||||
) : (
|
||||
<WalkImportLinkStep walkImport={walkImport} />
|
||||
)}
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** CW-07: said in the planner while a walk read from Google Maps is not saved yet. */
|
||||
export function WalkImportDraftNotice({ planner }: { planner: Pick<TourPlannerController, 'draftSource' | 'mode'> }) {
|
||||
const { t } = useTranslation();
|
||||
if (planner.draftSource !== 'google_maps' || planner.mode.type !== 'new-draft') return null;
|
||||
return (
|
||||
<TourNotice role="status" icon={<Footprints size={13} strokeWidth={2} />}>
|
||||
{t('tours.walkImport.draftNotice')}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { WalkRoutePendingHint, WalkRoutePendingNotice } from './WalkRoutePending';
|
||||
|
||||
// FE-WALK-ROUTE-PENDING-001 to FE-WALK-ROUTE-PENDING-002: a walk an assistant saved with its stops only (CW-11).
|
||||
|
||||
describe('WalkRoutePending (CW-11)', () => {
|
||||
it('FE-WALK-ROUTE-PENDING-001: the planner says the way was just planned until the walk is saved with it', () => {
|
||||
const { rerender } = render(
|
||||
<WalkRoutePendingNotice planner={{ routePending: true, hasUnsavedChanges: true, status: 'ready' }} />
|
||||
);
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Saved with its stops only. The way between them was planned just now; save the walk to keep it.'
|
||||
);
|
||||
// Saved, or open for someone who may not save it: nothing to say.
|
||||
rerender(<WalkRoutePendingNotice planner={{ routePending: true, hasUnsavedChanges: false, status: 'ready' }} />);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
rerender(<WalkRoutePendingNotice planner={{ routePending: false, hasUnsavedChanges: true, status: 'ready' }} />);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
// Still being planned, or planning it failed: nothing was planned yet.
|
||||
rerender(<WalkRoutePendingNotice planner={{ routePending: true, hasUnsavedChanges: true, status: 'routing' }} />);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
rerender(
|
||||
<WalkRoutePendingNotice planner={{ routePending: true, hasUnsavedChanges: true, status: 'routing-failed' }} />
|
||||
);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-WALK-ROUTE-PENDING-002: the detail says where the way gets planned', () => {
|
||||
render(<WalkRoutePendingHint />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Saved with its stops only. The way between them is planned when the walk is opened in the planner and saved.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Route } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { TourNotice } from '../tourParts';
|
||||
|
||||
/**
|
||||
* CW-11: said in the planner once a walk an assistant saved with its stops
|
||||
* only has its way planned here and not saved yet; not while the way is still
|
||||
* being planned, nor when planning it failed.
|
||||
*/
|
||||
export function WalkRoutePendingNotice({
|
||||
planner,
|
||||
}: {
|
||||
planner: Pick<TourPlannerController, 'routePending' | 'hasUnsavedChanges' | 'status'>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!planner.routePending || !planner.hasUnsavedChanges || planner.status !== 'ready') return null;
|
||||
return (
|
||||
<TourNotice role="status" icon={<Route size={13} strokeWidth={2} />}>
|
||||
{t('tours.walk.routePendingPlanner')}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/** CW-11: the detail of a walk saved with its stops only, whose way is still to be planned. */
|
||||
export function WalkRoutePendingHint() {
|
||||
const { t } = useTranslation();
|
||||
return <TourNotice icon={<Route size={13} strokeWidth={2} />}>{t('tours.walk.routePending')}</TourNotice>;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// CW-02, CW-03, CW-04: a city walk's stops in its detail, checked off for the
|
||||
// day it was opened from, their arrivals and opening hours on the day looked
|
||||
// at, and its progress as a pill of the desktop day row.
|
||||
import type { TourDetailResponse, TourListItem, TourWaypoint } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { tourRepo } from '../../../repo/tourRepo';
|
||||
import { tourVisitRepo } from '../../../repo/tourVisitRepo';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTourVisitStore } from '../../../store/tourVisitStore';
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import type { Assignment, Day } from '../../../types';
|
||||
import { ToastContainer } from '../../shared/Toast';
|
||||
import { WalkProgressPill, WalkStationsCard } from './WalkStations';
|
||||
|
||||
const walk: TourListItem = {
|
||||
place_id: 42,
|
||||
name: 'Old town',
|
||||
tour_type: 'walk',
|
||||
distance: 0.3,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: 20,
|
||||
break_additional_minutes: null,
|
||||
planned_duration_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: true,
|
||||
caution: false,
|
||||
station_count: 3,
|
||||
};
|
||||
|
||||
const waypoints: TourWaypoint[] = [
|
||||
{
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
role: 'start',
|
||||
sequence: 0,
|
||||
key: 'stop-01',
|
||||
name: 'Museum',
|
||||
stay_minutes: 30,
|
||||
opening_hours: 'Mo-Fr 09:00-09:20',
|
||||
},
|
||||
{ lat: 48.0005, lng: 11, role: 'via', sequence: 1, key: 'shape-01', shaping: true },
|
||||
{ lat: 48.001, lng: 11, role: 'via', sequence: 2, key: 'stop-02', opening_hours: 'sunrise-sunset' },
|
||||
{ lat: 48.002, lng: 11, role: 'end', sequence: 3, name: 'Cafe', stay_minutes: 15, opening_hours: 'Mo 10:00-18:00' },
|
||||
];
|
||||
|
||||
const ROUTE: Array<[number, number]> = [
|
||||
[48, 11],
|
||||
[48.001, 11],
|
||||
[48.002, 11],
|
||||
];
|
||||
|
||||
// 2026-10-19 is a Monday, 2026-10-20 a Tuesday.
|
||||
const MONDAY = buildDay({ id: 7, trip_id: 4, date: '2026-10-19' });
|
||||
const TUESDAY = buildDay({ id: 8, trip_id: 4, date: '2026-10-20' });
|
||||
const ON_DAY = { id: 9, day_id: 7 };
|
||||
|
||||
/** The walk's entry on a day: its start that day, else the place's own time. */
|
||||
function entry(dayId: number, time: string | null, placeTime: string | null = null): Assignment {
|
||||
return buildAssignment({
|
||||
id: dayId + 2,
|
||||
day_id: dayId,
|
||||
place_id: 42,
|
||||
assignment_time: time,
|
||||
place: buildPlace({ id: 42, place_time: placeTime }),
|
||||
});
|
||||
}
|
||||
|
||||
/** The walk on Monday from 09:00, opened from that day. */
|
||||
function onMonday(time: string | null = '09:00', day: Day = MONDAY) {
|
||||
return { assignment: ON_DAY, days: [day], assignments: { [String(day.id)]: [entry(day.id, time)] }, route: ROUTE };
|
||||
}
|
||||
|
||||
function detail(points: TourWaypoint[] = waypoints): Promise<TourDetailResponse> {
|
||||
return Promise.resolve({ tour: walk, waypoints: points });
|
||||
}
|
||||
|
||||
function card(props: Partial<Parameters<typeof WalkStationsCard>[0]> = {}) {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<WalkStationsCard tour={walk} tripId={4} assignment={ON_DAY} canMark {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const UNREAD = 'The stops of this walk could not be loaded. Open it again while online to see them and check them off.';
|
||||
|
||||
const rowOf = async (name: string) =>
|
||||
(await screen.findAllByTestId('walk-station')).find((row) => row.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
useTourVisitStore.setState({ tripId: '4', byAssignment: {} });
|
||||
vi.spyOn(tourRepo, 'detail').mockImplementation(() => detail());
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('WalkStationsCard', () => {
|
||||
it('shows nothing for a tour that is no city walk', () => {
|
||||
render(<WalkStationsCard tour={{ ...walk, tour_type: 'hike' }} tripId={4} assignment={ON_DAY} canMark />);
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('walk-stations')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says it is loading until the stops are read', () => {
|
||||
vi.mocked(tourRepo.detail).mockReturnValue(new Promise(() => undefined));
|
||||
card();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists the stops in their order without the shaping points, an unnamed one by its number', async () => {
|
||||
card();
|
||||
const rows = await screen.findAllByTestId('walk-station');
|
||||
expect(rows.map((row) => row.querySelector('.font-semibold')?.textContent)).toEqual(['Museum', 'Stop 2', 'Cafe']);
|
||||
expect(tourRepo.detail).toHaveBeenCalledWith(4, 42, expect.any(AbortSignal));
|
||||
});
|
||||
|
||||
it('opened without a day, offers nothing to check off and says where to', async () => {
|
||||
card({ assignment: null });
|
||||
await screen.findAllByTestId('walk-station');
|
||||
expect(
|
||||
screen.getByText('Open the walk from a day of the plan to check its stops off for that day.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks a stop off for its day and skips another, through the store', async () => {
|
||||
const update = vi
|
||||
.spyOn(tourVisitRepo, 'update')
|
||||
.mockResolvedValueOnce({ visits: [{ assignment_id: 9, station_key: 'stop-01', status: 'visited' }] })
|
||||
.mockResolvedValueOnce({
|
||||
visits: [
|
||||
{ assignment_id: 9, station_key: 'stop-01', status: 'visited' },
|
||||
{ assignment_id: 9, station_key: 'stop-02', status: 'skipped' },
|
||||
],
|
||||
});
|
||||
card();
|
||||
|
||||
fireEvent.click(within(await rowOf('Museum')).getByText('Mark visited'));
|
||||
expect(update).toHaveBeenLastCalledWith(4, 9, 'stop-01', { status: 'visited' });
|
||||
expect(await within(await rowOf('Museum')).findByText('Visited on this day')).toBeInTheDocument();
|
||||
fireEvent.click(within(await rowOf('Stop 2')).getByText('Skip'));
|
||||
expect(update).toHaveBeenLastCalledWith(4, 9, 'stop-02', { status: 'skipped' });
|
||||
expect(await within(await rowOf('Stop 2')).findByText('Skipped on this day')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a stop visited on that day says so and can be taken back', async () => {
|
||||
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' } } });
|
||||
const update = vi.spyOn(tourVisitRepo, 'update').mockResolvedValue({ visits: [] });
|
||||
card();
|
||||
|
||||
const museum = await rowOf('Museum');
|
||||
expect(within(museum).getByText('Visited on this day')).toBeInTheDocument();
|
||||
fireEvent.click(within(museum).getByText('Take back'));
|
||||
|
||||
expect(update).toHaveBeenCalledWith(4, 9, 'stop-01', { status: 'planned' });
|
||||
});
|
||||
|
||||
it('a stop without a key has nothing to check off, and without the right to plan the day no stop has', async () => {
|
||||
const { unmount } = card();
|
||||
expect(within(await rowOf('Cafe')).queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(within(await rowOf('Museum')).getByText('Mark visited')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
card({ canMark: false });
|
||||
await screen.findAllByTestId('walk-station');
|
||||
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Skip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('on a day put on the plan offline, says the stops wait for the sync and offers no buttons', async () => {
|
||||
card({ assignment: { id: -1700000000000, day_id: 7 } });
|
||||
await screen.findAllByTestId('walk-station');
|
||||
expect(
|
||||
screen.getByText('This walk was put on the day while offline. Its stops can be checked off once it has synced.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Open the walk from a day of the plan to check its stops off for that day.')).toBeNull();
|
||||
});
|
||||
|
||||
it('a mark the server refuses is taken back and said in a toast', async () => {
|
||||
vi.spyOn(tourVisitRepo, 'update').mockRejectedValue(new Error('No permission'));
|
||||
card();
|
||||
|
||||
fireEvent.click(within(await rowOf('Museum')).getByText('Mark visited'));
|
||||
|
||||
expect(
|
||||
await screen.findByText('The stop could not be marked. Open the walk again and try once more.')
|
||||
).toBeInTheDocument();
|
||||
expect(within(await rowOf('Museum')).getByText('Mark visited')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('heads the stops with how many were visited on that day', async () => {
|
||||
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'skipped' } } });
|
||||
card();
|
||||
expect(await screen.findByText('1 of 3 visited')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('on the day it was opened from, shows each arrival, the end of each stay and what the opening hours say', async () => {
|
||||
card(onMonday());
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, .+, start 09:00$/);
|
||||
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument();
|
||||
const museum = await rowOf('Museum');
|
||||
expect(within(museum).getByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument();
|
||||
expect(within(museum).getByTestId('walk-station-conflict')).toHaveTextContent(
|
||||
'Museum closes at 09:20, during the stay from 09:00 to 09:30 (open 09:00 to 09:20)'
|
||||
);
|
||||
expect(within(museum).getByTestId('walk-hours-source')).toHaveTextContent('OpenStreetMap');
|
||||
const unnamed = await rowOf('Stop 2');
|
||||
expect(within(unnamed).getByText('Arrival about 09:40')).toBeInTheDocument();
|
||||
expect(within(unnamed).getByText('Opening hours not read here: sunrise-sunset')).toBeInTheDocument();
|
||||
// The stay at stop 2 is not known, so the cafe's arrival is a lower bound and its hours are not judged.
|
||||
const cafe = await rowOf('Cafe');
|
||||
expect(within(cafe).getByText('Arrival 09:50 at the earliest, stay until 10:05 or later')).toBeInTheDocument();
|
||||
expect(
|
||||
within(cafe).getByText('Opening hours not checked: a stay before this stop is not entered yet.')
|
||||
).toBeInTheDocument();
|
||||
expect(within(cafe).queryByTestId('walk-station-conflict')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Opening hours from OpenStreetMap or the trip's places/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names a stop by the trip place it stands for and checks the place's own hours: 17:20 to 18:20 against 18:00", async () => {
|
||||
const week = JSON.stringify(
|
||||
Array.from({ length: 7 }, (_, day) =>
|
||||
day < 5 ? { closed: false, open: '10:00', close: '18:00' } : { closed: true }
|
||||
)
|
||||
);
|
||||
seedStore(useTripStore, { places: [buildPlace({ id: 77, name: 'Lenbachhaus', opening_hours: week })] });
|
||||
vi.mocked(tourRepo.detail).mockImplementation(() =>
|
||||
detail([
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0, key: 'stop-01', linked_place_id: 77, stay_minutes: 60 },
|
||||
{ lat: 48.002, lng: 11, role: 'end', sequence: 1, key: 'stop-02', name: 'Cafe', stay_minutes: 0 },
|
||||
])
|
||||
);
|
||||
card(onMonday('17:20'));
|
||||
|
||||
const museum = await rowOf('Lenbachhaus');
|
||||
expect(within(museum).getByText('Arrival about 17:20, stay until 18:20')).toBeInTheDocument();
|
||||
expect(within(museum).getByTestId('walk-station-conflict')).toHaveTextContent(
|
||||
'Lenbachhaus closes at 18:00, during the stay from 17:20 to 18:20 (open 10:00 to 18:00)'
|
||||
);
|
||||
expect(within(museum).getByTestId('walk-hours-source')).toHaveTextContent('Trip place');
|
||||
expect(within(museum).getByText('Entered for this place of the trip.')).toBeInTheDocument();
|
||||
// Only passed, a stop without hours stays unknown: no window and nothing said to be open.
|
||||
const cafe = await rowOf('Cafe');
|
||||
expect(within(cafe).getByText('Arrival about 18:40')).toBeInTheDocument();
|
||||
expect(within(cafe).queryByText(/Open/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the times in the 12 hour format where the user chose it', async () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
|
||||
card(onMonday());
|
||||
expect(within(await rowOf('Museum')).getByText('Arrival about 9:00 AM, stay until 9:30 AM')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, .+, start 9:00 AM$/);
|
||||
});
|
||||
|
||||
it('on a day without a start, asks for one and leaves arrivals and hours unsaid', async () => {
|
||||
card(onMonday(null));
|
||||
expect(
|
||||
await screen.findByText('Give the walk a time on this day to see arrivals and opening hours.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent(/^Day 1, .+, no start time$/);
|
||||
await screen.findAllByTestId('walk-station');
|
||||
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('on a day without a date, says so and shows no arrival', async () => {
|
||||
card(onMonday('09:00', buildDay({ id: 7, trip_id: 4, date: null })));
|
||||
expect(
|
||||
await screen.findByText('This day has no date, so arrivals and opening hours are left out.')
|
||||
).toBeInTheDocument();
|
||||
await screen.findAllByTestId('walk-station');
|
||||
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('without a route, shows no arrival', async () => {
|
||||
card({ ...onMonday(), route: null });
|
||||
await screen.findAllByTestId('walk-station');
|
||||
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names no source of hours when no stop has any', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation(() =>
|
||||
detail(waypoints.map(({ opening_hours: _hours, ...point }) => point))
|
||||
);
|
||||
card(onMonday());
|
||||
expect(within(await rowOf('Museum')).getByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Opening hours from OpenStreetMap/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('walk-hours-source')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opened from no day of the walk, takes the first with a start time, lets another be picked, and checks nothing off', async () => {
|
||||
card({
|
||||
assignment: null,
|
||||
days: [MONDAY, TUESDAY],
|
||||
assignments: { '7': [entry(7, null)], '8': [entry(8, null, '10:00')] },
|
||||
route: ROUTE,
|
||||
});
|
||||
const picker = screen.getByTestId('walk-day-picker');
|
||||
expect(picker).toHaveTextContent(/^Day 2, .+, start 10:00$/);
|
||||
// Tuesday from 10:00: the museum closed at 09:20 already.
|
||||
expect(within(await rowOf('Museum')).getByTestId('walk-station-conflict')).toHaveTextContent(
|
||||
'Museum is closed at 10:00, the stay would last until 10:30; open 09:00 to 09:20'
|
||||
);
|
||||
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Open the walk from a day of the plan to check its stops off for that day.')
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(picker);
|
||||
const monday = screen.getByRole('button', { name: /^Day 1, .*no start time$/ });
|
||||
fireEvent.click(monday);
|
||||
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent(/^Day 1, .+, no start time$/);
|
||||
expect(screen.getByText('Give the walk a time on this day to see arrivals and opening hours.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opened from no day while the walk is on none, asks to put it on one', async () => {
|
||||
card({ assignment: null });
|
||||
expect(
|
||||
await screen.findByText('Put the walk on a day with a start time to see arrivals and opening hours.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads the stops again once the walk was saved, keeping the ones shown until then', async () => {
|
||||
const view = render(<WalkStationsCard tour={{ ...walk, revision: 1 }} tripId={4} assignment={ON_DAY} canMark />);
|
||||
await screen.findAllByTestId('walk-station');
|
||||
vi.mocked(tourRepo.detail).mockImplementation(() => detail(waypoints.slice(0, 2)));
|
||||
view.rerender(<WalkStationsCard tour={{ ...walk, revision: 2 }} tripId={4} assignment={ON_DAY} canMark />);
|
||||
expect(screen.getAllByTestId('walk-station')).toHaveLength(3);
|
||||
await vi.waitFor(() => expect(screen.getAllByTestId('walk-station')).toHaveLength(1));
|
||||
expect(tourRepo.detail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('a walk whose stops cannot be read lists none, counts none and says so', async () => {
|
||||
vi.mocked(tourRepo.detail).mockRejectedValue(new Error('Tour not available offline'));
|
||||
card();
|
||||
expect(await screen.findByText(UNREAD)).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
|
||||
expect(screen.queryByText(/visited/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offline, the route's two ends read in place of the stops are no stops: no count, and it says so", async () => {
|
||||
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'visited' } } });
|
||||
const ends: TourWaypoint[] = [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.002, lng: 11, role: 'end', sequence: 1 },
|
||||
];
|
||||
vi.mocked(tourRepo.detail).mockImplementation(() => detail(ends));
|
||||
card(onMonday());
|
||||
|
||||
expect(await screen.findByText(UNREAD)).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
|
||||
expect(screen.queryByText(/visited/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Arrival/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a walk saved offline lists its stops before the sync gave them keys', async () => {
|
||||
const unsaved = waypoints.map(({ key: _key, ...point }) => point);
|
||||
vi.mocked(tourRepo.detail).mockImplementation(() => detail(unsaved));
|
||||
card({ tour: { ...walk, place_id: -1700000000001 }, assignment: { id: -1700000000000, day_id: 7 } });
|
||||
|
||||
expect(await screen.findAllByTestId('walk-station')).toHaveLength(3);
|
||||
expect(screen.getByText('0 of 3 visited')).toBeInTheDocument();
|
||||
expect(screen.queryByText(UNREAD)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('a walk without stops lists none and misses nothing', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation(() => detail([]));
|
||||
card({ tour: { ...walk, station_count: 0 } });
|
||||
await vi.waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
|
||||
|
||||
expect(screen.queryAllByTestId('walk-station')).toEqual([]);
|
||||
expect(screen.queryByText(UNREAD)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/visited/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WalkProgressPill', () => {
|
||||
it('says nothing for a hike, a walk without stops, or a day without marks', () => {
|
||||
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' } } });
|
||||
render(
|
||||
<>
|
||||
<WalkProgressPill tour={{ ...walk, tour_type: 'hike' }} assignmentId={9} />
|
||||
<WalkProgressPill tour={{ ...walk, station_count: 0 }} assignmentId={9} />
|
||||
<WalkProgressPill tour={walk} assignmentId={10} />
|
||||
<WalkProgressPill tour={walk} assignmentId={undefined} />
|
||||
</>
|
||||
);
|
||||
expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the visited stops of all of them, named in full', () => {
|
||||
useTourVisitStore.setState({
|
||||
byAssignment: { 9: { a1b2c3: 'visited', d4e5f6: 'visited', g7h8i9: 'skipped' }, 10: { a1b2c3: 'skipped' } },
|
||||
});
|
||||
render(
|
||||
<>
|
||||
<WalkProgressPill tour={{ ...walk, station_count: 5 }} assignmentId={9} />
|
||||
<WalkProgressPill tour={{ ...walk, station_count: 5 }} assignmentId={10} />
|
||||
</>
|
||||
);
|
||||
const [day9, day10] = screen.getAllByTestId('plan-walk-progress');
|
||||
expect(day9).toHaveTextContent('2/5');
|
||||
expect(day9).toHaveAccessibleName('2 of 5 visited');
|
||||
expect(day10).toHaveTextContent('0/5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { TourListItem, TourVisitStatus } from '@trek/shared';
|
||||
import { Check, CheckCheck, Redo2, SkipForward, Undo2 } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useTourVisitStore } from '../../../store/tourVisitStore';
|
||||
import type { Assignment, AssignmentsMap, Day } from '../../../types';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { DialogSection, fs } from '../../shared/DialogShell';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { stationMarkNote, stationRingClass } from '../planner/stationMarks';
|
||||
import { StationPills, StationShield } from '../planner/stationParts';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { TourDayRowPill } from '../TourDayRowFacts';
|
||||
import { CardAction } from '../tourParts';
|
||||
import { StationScheduleLine } from './StationSchedule';
|
||||
import { useWalkDay } from './useWalkDay';
|
||||
import { useLinkedPlaceOf, useWalkSchedule } from './useWalkSchedule';
|
||||
import { useWalkStations } from './useWalkStations';
|
||||
import { WalkDayLine } from './WalkDayLine';
|
||||
import { useDayVisits, useWalkProgress } from './walkProgress';
|
||||
import type { StationSchedule } from './walkSchedule';
|
||||
|
||||
const NO_DAYS: Day[] = [];
|
||||
const NO_ASSIGNMENTS: AssignmentsMap = {};
|
||||
|
||||
/**
|
||||
* CW-03: a walk's progress on one of its days, as a pill of the day row:
|
||||
* how many of its stations were visited. Nothing is shown before any is.
|
||||
*/
|
||||
export function WalkProgressPill({ tour, assignmentId }: { tour: TourListItem; assignmentId: number | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const progress = useWalkProgress(tour, assignmentId);
|
||||
if (!progress) return null;
|
||||
return (
|
||||
<TourDayRowPill
|
||||
icon={CheckCheck}
|
||||
value={`${progress.visited}/${progress.total}`}
|
||||
label={t('tours.walk.progress', progress)}
|
||||
testId="plan-walk-progress"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the stations can be checked off from here: not without a day or the
|
||||
* right to plan it, and not yet on a day put on the plan offline, which the
|
||||
* server does not know until it synced.
|
||||
*/
|
||||
function markState(canMark: boolean, assignmentId: number | null): 'none' | 'ready' | 'notSynced' {
|
||||
if (!canMark || assignmentId === null) return 'none';
|
||||
return assignmentId < 0 ? 'notSynced' : 'ready';
|
||||
}
|
||||
|
||||
function StatusActions({
|
||||
status,
|
||||
onMark,
|
||||
}: {
|
||||
status: TourVisitStatus | undefined;
|
||||
onMark: (status: TourVisitStatus | 'planned') => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (status)
|
||||
return (
|
||||
<CardAction icon={<Undo2 size={12} strokeWidth={2} />} onClick={() => onMark('planned')}>
|
||||
{t('tours.walk.visit.undo')}
|
||||
</CardAction>
|
||||
);
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<CardAction icon={<Check size={12} strokeWidth={2.2} />} onClick={() => onMark('visited')}>
|
||||
{t('tours.walk.visit.visited')}
|
||||
</CardAction>
|
||||
<CardAction icon={<SkipForward size={12} strokeWidth={2} />} onClick={() => onMark('skipped')}>
|
||||
{t('tours.walk.visit.skip')}
|
||||
</CardAction>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One station of the list: its number or mark, its name (its own, else the
|
||||
* trip place it stands for), stay, the day's arrival and opening hours, and
|
||||
* what can be marked.
|
||||
*/
|
||||
function StationRow({
|
||||
station,
|
||||
index,
|
||||
status,
|
||||
placeName,
|
||||
schedule,
|
||||
onMark,
|
||||
}: {
|
||||
station: TourPlannerWaypoint;
|
||||
index: number;
|
||||
status: TourVisitStatus | undefined;
|
||||
placeName: string | undefined;
|
||||
schedule: StationSchedule | undefined;
|
||||
onMark: ((status: TourVisitStatus | 'planned') => void) | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const name = station.name ?? placeName ?? t('tours.walk.stationNumbered', { number: index + 1 });
|
||||
return (
|
||||
<li
|
||||
data-testid="walk-station"
|
||||
className={`flex items-start gap-2.5 rounded-[10px] px-1.5 py-1.5 ${status ? 'opacity-70' : ''}`}
|
||||
>
|
||||
{/* How much the station matters rings its number; a protection is a shield at its corner (CW-03, CW-10). */}
|
||||
<span
|
||||
className={`relative grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-tertiary font-geist tabular-nums text-content-secondary ${stationRingClass(station.importance)}`}
|
||||
style={fs(11)}
|
||||
title={stationMarkNote(station, t) ?? undefined}
|
||||
>
|
||||
{status === 'visited' ? (
|
||||
<Check size={12} strokeWidth={2.4} />
|
||||
) : status === 'skipped' ? (
|
||||
<Redo2 size={12} strokeWidth={2} />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
{station.protected && <StationShield />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span
|
||||
className={`truncate font-semibold text-content ${status === 'skipped' ? 'line-through' : ''}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<StationPills point={station} />
|
||||
<StationScheduleLine schedule={schedule} name={name} />
|
||||
{status && (
|
||||
<span className="text-content-faint" style={fs(11, 'body')}>
|
||||
{t(status === 'visited' ? 'tours.walk.visit.isVisited' : 'tours.walk.visit.isSkipped')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{onMark && <StatusActions status={status} onMark={onMark} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The lines above the stations: loading, stations that could not be read, where to check off. */
|
||||
function WalkStationsNotes({
|
||||
loading,
|
||||
unread,
|
||||
any,
|
||||
fromDay,
|
||||
notSynced,
|
||||
}: {
|
||||
loading: boolean;
|
||||
unread: boolean;
|
||||
any: boolean;
|
||||
fromDay: boolean;
|
||||
notSynced: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const quiet = (text: string) => (
|
||||
<span className="text-content-faint" style={fs(11, 'body')}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
if (loading) return quiet(t('common.loading'));
|
||||
return (
|
||||
<>
|
||||
{unread && quiet(t('tours.walk.stationsUnread'))}
|
||||
{any && fromDay && quiet(t('tours.walk.visit.fromDay'))}
|
||||
{any && notSynced && quiet(t('tours.walk.visit.notSynced'))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03, CW-04: a city walk's stations in its detail, in order, each with its
|
||||
* stay, importance and protection, and its arrival and opening hours on the
|
||||
* day looked at. Opened from one of the walk's days, that day holds, and each
|
||||
* station can be marked visited or skipped for it and taken back; opened
|
||||
* otherwise, a day of the walk can be picked for the times and nothing is
|
||||
* checked off. Neither changes a route, place or order.
|
||||
*/
|
||||
export function WalkStationsCard({
|
||||
tour,
|
||||
tripId,
|
||||
assignment,
|
||||
canMark,
|
||||
days = NO_DAYS,
|
||||
assignments = NO_ASSIGNMENTS,
|
||||
route = null,
|
||||
}: {
|
||||
tour: TourListItem;
|
||||
tripId: number | string;
|
||||
/** The walk's entry on the day the dialog was opened from; without one there is nothing to check off. */
|
||||
assignment: Pick<Assignment, 'id' | 'day_id'> | null;
|
||||
canMark: boolean;
|
||||
days?: readonly Day[];
|
||||
assignments?: AssignmentsMap;
|
||||
/** The walk's line, along which its walking time is shared out. */
|
||||
route?: ReadonlyArray<readonly [number, number]> | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isWalk = tour.tour_type === 'walk';
|
||||
const stations = useWalkStations(tripId, tour.place_id, isWalk, tour.revision);
|
||||
const assignmentId = assignment?.id ?? null;
|
||||
const visits = useDayVisits(assignmentId);
|
||||
const mark = useTourVisitStore((state) => state.mark);
|
||||
const placeOf = useLinkedPlaceOf();
|
||||
const choice = useWalkDay(isWalk ? tour.place_id : null, days, assignments, assignment?.day_id ?? null);
|
||||
const walkingMinutes = tour.duration ?? null;
|
||||
const schedule = useWalkSchedule({ stations, route, walkingMinutes, day: choice.day, placeOf });
|
||||
if (!isWalk) return null;
|
||||
const onMark = (key: string, status: TourVisitStatus | 'planned') => {
|
||||
if (assignmentId === null) return;
|
||||
mark(tripId, assignmentId, key, status).catch(() => toast.error(t('tours.walk.visit.failed')));
|
||||
};
|
||||
const listed = stations ?? [];
|
||||
const visited = listed.filter((station) => station.key && visits?.[station.key] === 'visited').length;
|
||||
const marking = markState(canMark, assignmentId);
|
||||
return (
|
||||
<DialogSection
|
||||
className="flex-none"
|
||||
label={t('tours.walk.stations')}
|
||||
action={
|
||||
// No count where the stations could not be read: it would contradict the day row's.
|
||||
assignmentId !== null && listed.length > 0 ? (
|
||||
<SoftPill>{t('tours.walk.progress', { visited, total: listed.length })}</SoftPill>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div data-testid="walk-stations" className="flex flex-col gap-2">
|
||||
<WalkDayLine choice={choice} schedule={schedule} stationCount={listed.length} />
|
||||
<WalkStationsNotes
|
||||
loading={stations === null}
|
||||
unread={stations?.length === 0 && tour.station_count !== 0}
|
||||
any={listed.length > 0}
|
||||
fromDay={assignmentId === null}
|
||||
notSynced={marking === 'notSynced'}
|
||||
/>
|
||||
<ol className="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
{listed.map((station, index) => (
|
||||
<StationRow
|
||||
key={station.id}
|
||||
station={station}
|
||||
index={index}
|
||||
status={station.key ? visits?.[station.key] : undefined}
|
||||
placeName={placeOf(station)?.name}
|
||||
schedule={schedule.byStation.get(station.id)}
|
||||
onMark={marking === 'ready' && station.key ? (next) => onMark(station.key ?? '', next) : null}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</DialogSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AlertTriangle, Footprints } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatClockTime } from '../../../utils/formatters';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import type { WalkLate, WalkTransitContext } from './walkTransit';
|
||||
|
||||
function useIs12h(): boolean {
|
||||
return useSettingsStore((state) => state.settings.time_format) === '12h';
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-08: what the search between two city walks goes by, above it: when the
|
||||
* first walk ends, at the earliest where a time of it is not known, that it
|
||||
* ends after midnight, or that the end is not known, and then a time is to be
|
||||
* chosen; and when the next one starts.
|
||||
*/
|
||||
export function WalkTransitNote({ walk }: { walk: WalkTransitContext | null }) {
|
||||
const { t } = useTranslation();
|
||||
const is12h = useIs12h();
|
||||
if (!walk) return null;
|
||||
const params = { walk: walk.fromWalk, time: formatClockTime(walk.ends, is12h) };
|
||||
let end = t('tours.walk.transit.ends', params);
|
||||
if (walk.endsNextDay) end = t('tours.walk.transit.endsNextDay', params);
|
||||
else if (!walk.ends) end = t('tours.walk.transit.endUnknown', params);
|
||||
else if (walk.endsAtLeast) end = t('tours.walk.transit.endsAtLeast', params);
|
||||
return (
|
||||
<TourNotice role="status" icon={<Footprints size={13} strokeWidth={2} />}>
|
||||
<div data-testid="transit-walk-end">{end}</div>
|
||||
{walk.nextStarts && (
|
||||
<div data-testid="transit-walk-next">
|
||||
{t('tours.walk.transit.nextStarts', { walk: walk.toWalk, time: formatClockTime(walk.nextStarts, is12h) })}
|
||||
</div>
|
||||
)}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-08: a connection, or a journey saved before, that arrives after the next
|
||||
* walk starts. Said on one line that wraps; nothing is moved or replaced.
|
||||
*/
|
||||
export function WalkLateLine({ late }: { late?: WalkLate | null }) {
|
||||
const { t } = useTranslation();
|
||||
const is12h = useIs12h();
|
||||
if (!late) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="transit-walk-late"
|
||||
className="mt-1.5 flex items-start gap-1 text-warning"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<AlertTriangle size={12} strokeWidth={2.2} aria-hidden="true" className="mt-[2px] flex-none" />
|
||||
<span className="min-w-0 break-words">
|
||||
{t('tours.walk.transit.late', { walk: late.walk, time: formatClockTime(late.time, is12h) })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// CW-03: the visits of a trip's city walks, read when the trip opens and when
|
||||
// the connection changes, and kept current by its socket through the store.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { TourVisit } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { addListener, removeListener } from '../../../api/websocket';
|
||||
import { tourVisitRepo } from '../../../repo/tourVisitRepo';
|
||||
import { useTourVisitStore } from '../../../store/tourVisitStore';
|
||||
import { setForcedOffline } from '../../../sync/networkMode';
|
||||
import { useTourVisitsSync } from './useTourVisitsSync';
|
||||
|
||||
const visit = (assignment_id: number, station_key: string): TourVisit => ({
|
||||
assignment_id,
|
||||
station_key,
|
||||
status: 'visited',
|
||||
});
|
||||
|
||||
function later<T>() {
|
||||
let settle: (value: T) => void = () => undefined;
|
||||
let fail: (reason: unknown) => void = () => undefined;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
settle = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
return { promise, settle, fail };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useTourVisitStore.setState({ tripId: null, byAssignment: {} });
|
||||
vi.mocked(addListener).mockClear();
|
||||
vi.mocked(removeListener).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
act(() => setForcedOffline(false));
|
||||
});
|
||||
|
||||
describe('useTourVisitsSync', () => {
|
||||
it('reads nothing and listens to nothing while off, without a trip, or for a trip id that is no number', () => {
|
||||
const list = vi.spyOn(tourVisitRepo, 'list');
|
||||
|
||||
renderHook(() => useTourVisitsSync(4, false));
|
||||
renderHook(() => useTourVisitsSync(undefined, true));
|
||||
renderHook(() => useTourVisitsSync('abc', true));
|
||||
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
expect(addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the trip's visits and listens to its socket, once", async () => {
|
||||
const list = vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [visit(9, 'stop-01')] });
|
||||
|
||||
renderHook(() => useTourVisitsSync(4, true));
|
||||
|
||||
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-01': 'visited' } }));
|
||||
expect(list).toHaveBeenCalledExactlyOnceWith(4);
|
||||
expect(addListener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reads them again when the connection goes or comes back, without listening twice', async () => {
|
||||
const list = vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [] });
|
||||
renderHook(() => useTourVisitsSync(4, true));
|
||||
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => setForcedOffline(true));
|
||||
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2));
|
||||
act(() => setForcedOffline(false));
|
||||
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(3));
|
||||
|
||||
expect(addListener).toHaveBeenCalledOnce();
|
||||
expect(removeListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a read that fails forgets the visits, but not once the hook is gone', async () => {
|
||||
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-01': 'visited' } } });
|
||||
const answer = later<{ visits: TourVisit[] }>();
|
||||
vi.spyOn(tourVisitRepo, 'list')
|
||||
.mockRejectedValueOnce(new Error('No permission'))
|
||||
.mockReturnValueOnce(answer.promise);
|
||||
|
||||
const first = renderHook(() => useTourVisitsSync(4, true));
|
||||
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({}));
|
||||
first.unmount();
|
||||
|
||||
useTourVisitStore.setState({ tripId: '4', byAssignment: { 9: { 'stop-01': 'visited' } } });
|
||||
const second = renderHook(() => useTourVisitsSync(4, true));
|
||||
second.unmount();
|
||||
answer.fail(new Error('Gone'));
|
||||
await expect(answer.promise).rejects.toThrow('Gone');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(useTourVisitStore.getState().byAssignment).toEqual({ 9: { 'stop-01': 'visited' } });
|
||||
});
|
||||
|
||||
it('stops listening when it goes, and listens to the next trip when the trip changes', () => {
|
||||
vi.spyOn(tourVisitRepo, 'list').mockResolvedValue({ visits: [] });
|
||||
const { rerender, unmount } = renderHook(({ trip }) => useTourVisitsSync(trip, true), {
|
||||
initialProps: { trip: 4 },
|
||||
});
|
||||
const [[firstListener]] = vi.mocked(addListener).mock.calls;
|
||||
|
||||
rerender({ trip: 5 });
|
||||
expect(removeListener).toHaveBeenCalledWith(firstListener);
|
||||
expect(addListener).toHaveBeenCalledTimes(2);
|
||||
const secondListener = vi.mocked(addListener).mock.calls[1][0];
|
||||
|
||||
unmount();
|
||||
expect(removeListener).toHaveBeenLastCalledWith(secondListener);
|
||||
});
|
||||
|
||||
it('a read for the trip left before answers too late to show', async () => {
|
||||
const left = later<{ visits: TourVisit[] }>();
|
||||
vi.spyOn(tourVisitRepo, 'list')
|
||||
.mockReturnValueOnce(left.promise)
|
||||
.mockResolvedValueOnce({ visits: [visit(30, 'stop-02')] });
|
||||
const { rerender } = renderHook(({ trip }) => useTourVisitsSync(trip, true), { initialProps: { trip: 4 } });
|
||||
|
||||
rerender({ trip: 5 });
|
||||
await vi.waitFor(() => expect(useTourVisitStore.getState().byAssignment).toEqual({ 30: { 'stop-02': 'visited' } }));
|
||||
left.settle({ visits: [visit(9, 'stop-01')] });
|
||||
await left.promise;
|
||||
|
||||
expect(useTourVisitStore.getState()).toMatchObject({ tripId: '5', byAssignment: { 30: { 'stop-02': 'visited' } } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { useTourVisitStore, watchTourVisits } from '../../../store/tourVisitStore';
|
||||
|
||||
/**
|
||||
* CW-03: the visits of a trip's city walks, read when the trip opens and again
|
||||
* when the connection comes and goes, and kept current by its socket, so a
|
||||
* station another traveller checks off shows on every day plan at once. Once a
|
||||
* reconnect has sent the waiting marks, the sync reads them once more
|
||||
* (reloadTourVisits). Offline they read from this device, with the marks made
|
||||
* here still waiting to sync.
|
||||
*/
|
||||
export function useTourVisitsSync(tripId: number | string | undefined, enabled: boolean) {
|
||||
const { offline } = useNetworkMode();
|
||||
const id = enabled && tripId !== undefined && !Number.isNaN(Number(tripId)) ? tripId : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (id === null) return;
|
||||
let current = true;
|
||||
useTourVisitStore
|
||||
.getState()
|
||||
.load(id)
|
||||
.catch(() => {
|
||||
// Refused, or no copy on this device: the day plan shows no progress rather than another trip's.
|
||||
if (current) useTourVisitStore.getState().reset(id);
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [id, offline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id === null) return;
|
||||
return watchTourVisits(id);
|
||||
}, [id]);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// CW-02, CW-04: the day a city walk's times are shown for. Opened from one of
|
||||
// its days that day holds; otherwise the first with a start time, or the one
|
||||
// picked, and another walk starts over.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../../tests/helpers/factories';
|
||||
import { act, renderHook } from '../../../../tests/helpers/render';
|
||||
import type { AssignmentsMap, Day } from '../../../types';
|
||||
import { useWalkDay } from './useWalkDay';
|
||||
|
||||
const one = buildDay({ id: 1, date: '2026-10-19' });
|
||||
const two = buildDay({ id: 2, date: '2026-10-20' });
|
||||
const three = buildDay({ id: 3, date: '2026-10-21' });
|
||||
const days: Day[] = [one, two, three];
|
||||
|
||||
const entry = (dayId: number, placeId: number, time: string | null, placeTime: string | null = null) =>
|
||||
buildAssignment({
|
||||
day_id: dayId,
|
||||
place_id: placeId,
|
||||
assignment_time: time,
|
||||
place: buildPlace({ id: placeId, place_time: placeTime }),
|
||||
});
|
||||
|
||||
// Walk 42 is on day 1 without a time, on day 2 at the place's own 10:00 (overridden to 9:30 PM there)
|
||||
// and on day 3 at its place time; another place is on day 1 too.
|
||||
const assignments: AssignmentsMap = {
|
||||
'1': [entry(1, 7, '08:00'), entry(1, 42, null)],
|
||||
'2': [entry(2, 42, '9:30 PM', '10:00')],
|
||||
'3': [entry(3, 42, null, '11:15')],
|
||||
};
|
||||
|
||||
function walkDay(placeId: number | null, fixedDayId: number | null = null, map: AssignmentsMap = assignments) {
|
||||
return renderHook(({ id, fixed, entries }) => useWalkDay(id, days, entries, fixed), {
|
||||
initialProps: { id: placeId, fixed: fixedDayId, entries: map },
|
||||
});
|
||||
}
|
||||
|
||||
describe('useWalkDay', () => {
|
||||
it("lists the walk's days in the trip's order with their start, the day's own time before the place's", () => {
|
||||
const { result } = walkDay(42);
|
||||
expect(result.current.options.map((option) => [option.day.id, option.index, option.start])).toEqual([
|
||||
[1, 0, null],
|
||||
[2, 1, '21:30'],
|
||||
[3, 2, '11:15'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes the first day with a start time, and the first day where none has one', () => {
|
||||
expect(walkDay(42).result.current).toMatchObject({ fixed: false, day: { day: { id: 2 } } });
|
||||
const untimed = { '3': [entry(3, 42, null)], '1': [entry(1, 42, 'later')] };
|
||||
expect(walkDay(42, null, untimed).result.current.day?.day.id).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the day picked, falls back once the walk left it, and starts over for another walk', () => {
|
||||
const view = walkDay(42);
|
||||
act(() => view.result.current.choose(3));
|
||||
expect(view.result.current.day?.day.id).toBe(3);
|
||||
|
||||
view.rerender({ id: 42, fixed: null, entries: { ...assignments, '3': [] } });
|
||||
expect(view.result.current.day?.day.id).toBe(2);
|
||||
|
||||
const other = { '2': [entry(2, 43, '07:00')], '3': [entry(3, 43, '08:00')] };
|
||||
view.rerender({ id: 43, fixed: null, entries: other });
|
||||
expect(view.result.current.day?.day.id).toBe(2);
|
||||
});
|
||||
|
||||
it('holds the day it was opened from, with or without a time, and never another once the walk left it', () => {
|
||||
expect(walkDay(42, 1).result.current).toMatchObject({ fixed: true, day: { day: { id: 1 }, start: null } });
|
||||
const gone = walkDay(42, 1, { '2': assignments['2'] ?? [] });
|
||||
expect(gone.result.current).toMatchObject({ fixed: true, day: null });
|
||||
act(() => gone.result.current.choose(2));
|
||||
expect(gone.result.current.day).toBeNull();
|
||||
});
|
||||
|
||||
it('has no day for no walk, and nothing to pick', () => {
|
||||
const { result } = walkDay(null);
|
||||
expect(result.current).toMatchObject({ options: [], day: null, fixed: false });
|
||||
act(() => result.current.choose(2));
|
||||
expect(result.current.day).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { clockOf } from '@trek/shared';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { AssignmentsMap, Day } from '../../../types';
|
||||
import { walkStartOf } from './walkSchedule';
|
||||
|
||||
/** A day the walk is on, with its place in the trip and the walk's start that day. */
|
||||
export interface WalkDayOption {
|
||||
day: Day;
|
||||
/** The day's position in the trip, so `index + 1` is the number the plan shows. */
|
||||
index: number;
|
||||
/** `HH:MM` the walk starts that day; null when none is set or it cannot be read. */
|
||||
start: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02: the days a walk is on, in the order of the trip, each with its start
|
||||
* on that day (`walkStartOf`). A time that cannot be read is no start.
|
||||
*/
|
||||
function walkDayOptions(placeId: number, days: readonly Day[], assignments: AssignmentsMap): WalkDayOption[] {
|
||||
return days.flatMap((day, index) => {
|
||||
const assignment = (assignments[String(day.id)] ?? []).find((entry) => entry.place_id === placeId);
|
||||
if (!assignment) return [];
|
||||
const minutes = walkStartOf(assignment);
|
||||
return [{ day, index, start: minutes === null ? null : clockOf(minutes) }];
|
||||
});
|
||||
}
|
||||
|
||||
/** The day the times are worked out for, and how it is picked. */
|
||||
export interface WalkDayChoice {
|
||||
options: WalkDayOption[];
|
||||
day: WalkDayOption | null;
|
||||
/** Opened from one of the walk's days: that day holds and cannot be switched. */
|
||||
fixed: boolean;
|
||||
choose: (dayId: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02, CW-04: the day a walk's arrivals and opening hours are shown for.
|
||||
* Opened from one of its days, that day holds, with or without a time, and is
|
||||
* gone rather than replaced once the walk leaves it: another day never takes
|
||||
* over its time quietly. Otherwise the day picked here, or the first with a
|
||||
* start time, else the first. The choice is only what is looked at, never
|
||||
* saved, and starts over for another walk.
|
||||
*/
|
||||
export function useWalkDay(
|
||||
placeId: number | null,
|
||||
days: readonly Day[],
|
||||
assignments: AssignmentsMap,
|
||||
fixedDayId: number | null
|
||||
): WalkDayChoice {
|
||||
const [chosen, setChosen] = useState<{ placeId: number; dayId: number } | null>(null);
|
||||
const options = useMemo(
|
||||
() => (placeId === null ? [] : walkDayOptions(placeId, days, assignments)),
|
||||
[assignments, days, placeId]
|
||||
);
|
||||
const choose = (dayId: number) => {
|
||||
if (placeId !== null) setChosen({ placeId, dayId });
|
||||
};
|
||||
if (fixedDayId !== null) {
|
||||
return { options, day: options.find((option) => option.day.id === fixedDayId) ?? null, fixed: true, choose };
|
||||
}
|
||||
const picked = chosen?.placeId === placeId ? options.find((option) => option.day.id === chosen.dayId) : undefined;
|
||||
const day = picked ?? options.find((option) => option.start !== null) ?? options[0] ?? null;
|
||||
return { options, day, fixed: false, choose };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { WalkImportPreviewResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourWalkImportRepo } from '../../../repo/tourWalkImportRepo';
|
||||
import { useWalkImport } from './useWalkImport';
|
||||
|
||||
// FE-WALK-IMPORT-001 to FE-WALK-IMPORT-007
|
||||
|
||||
const ROUTE = 'https://www.google.com/maps/dir/Cathedral/Market/Park/data=!3e2';
|
||||
const PREVIEW: WalkImportPreviewResponse = {
|
||||
stations: [
|
||||
{ name: 'Cathedral', lat: 48.1, lng: 11.5 },
|
||||
{ name: 'Market', lat: null, lng: null },
|
||||
{ name: null, lat: 48.12, lng: 11.52 },
|
||||
{ name: 'Park', lat: 48.13, lng: 11.53 },
|
||||
],
|
||||
resolved_url: ROUTE,
|
||||
};
|
||||
|
||||
let preview: ReturnType<typeof vi.spyOn>;
|
||||
const planner = () => ({ tripId: 5, startDerivedTour: vi.fn(() => true) });
|
||||
|
||||
function setup(p = planner()) {
|
||||
const hook = renderHook(() => useWalkImport({ planner: p }), { wrapper: TranslationProvider });
|
||||
return { ...hook, planner: p };
|
||||
}
|
||||
|
||||
async function readLink(result: ReturnType<typeof setup>['result'], text: string) {
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.setUrl(text));
|
||||
await act(() => result.current.read());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
preview = vi.spyOn(tourWalkImportRepo, 'preview').mockResolvedValue(PREVIEW);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('useWalkImport (CW-07)', () => {
|
||||
it('FE-WALK-IMPORT-001: reads the link and shows its stops, counting the ones without a position', async () => {
|
||||
const { result } = setup();
|
||||
expect(result.current.open).toBe(false);
|
||||
act(() => result.current.show());
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(result.current.canRead).toBe(false);
|
||||
act(() => result.current.setUrl(' https://maps.app.goo.gl/abc '));
|
||||
expect(result.current.canRead).toBe(true);
|
||||
await act(() => result.current.read());
|
||||
expect(preview).toHaveBeenCalledWith(5, 'https://maps.app.goo.gl/abc', expect.any(AbortSignal));
|
||||
expect(result.current.phase).toBe('preview');
|
||||
expect(result.current.preview).toBe(PREVIEW);
|
||||
expect(result.current.unplaced).toBe(1);
|
||||
expect(result.current.canLoad).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-002: takes the one link out of the share text of the Google Maps app, and sends anything else as typed', async () => {
|
||||
const { result } = setup();
|
||||
await readLink(result, 'Walk through the old town\nhttps://maps.app.goo.gl/abc');
|
||||
expect(preview).toHaveBeenLastCalledWith(5, 'https://maps.app.goo.gl/abc', expect.any(AbortSignal));
|
||||
await readLink(result, 'https://maps.app.goo.gl/a https://maps.app.goo.gl/b');
|
||||
expect(preview).toHaveBeenLastCalledWith(
|
||||
5,
|
||||
'https://maps.app.goo.gl/a https://maps.app.goo.gl/b',
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
preview.mockClear();
|
||||
act(() => result.current.setUrl(' '));
|
||||
await act(() => result.current.read());
|
||||
expect(preview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-003: a refusal is said in the reader’s language, offline and other failures too', async () => {
|
||||
const { result } = setup();
|
||||
preview.mockRejectedValueOnce({
|
||||
response: { status: 400, data: { error: 'Not on foot.', reason: 'not_walking' } },
|
||||
});
|
||||
await readLink(result, ROUTE);
|
||||
expect(result.current.phase).toBe('link');
|
||||
expect(result.current.failure).toBe(
|
||||
'That route is not planned on foot. Switch Google Maps to walking and share the route again.'
|
||||
);
|
||||
preview.mockRejectedValueOnce(new OnlineOnlyError());
|
||||
await act(() => result.current.read());
|
||||
expect(result.current.failure).toBe('Reading a link needs a connection.');
|
||||
preview.mockRejectedValueOnce(new Error('Network Error'));
|
||||
await act(() => result.current.read());
|
||||
expect(result.current.failure).toBe('The link could not be read just now. Try again in a moment.');
|
||||
// The link stays as typed for another try.
|
||||
expect(result.current.url).toBe(ROUTE);
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-004: a newer read and closing drop the request before it answers', async () => {
|
||||
const answers: Array<(value: WalkImportPreviewResponse) => void> = [];
|
||||
preview.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
|
||||
const { result } = setup();
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.setUrl(ROUTE));
|
||||
let first: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
first = result.current.read();
|
||||
});
|
||||
expect(result.current.phase).toBe('reading');
|
||||
expect(result.current.canRead).toBe(false);
|
||||
const firstSignal = preview.mock.calls[0][2] as AbortSignal;
|
||||
let second: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
second = result.current.read();
|
||||
});
|
||||
expect(firstSignal.aborted).toBe(true);
|
||||
const secondSignal = preview.mock.calls[1][2] as AbortSignal;
|
||||
act(() => result.current.close());
|
||||
expect(secondSignal.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
answers.forEach((resolve) => resolve(PREVIEW));
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.preview).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-005: a failure that comes after closing is not shown, and unmounting drops the request', async () => {
|
||||
let fail: (reason: unknown) => void = () => undefined;
|
||||
preview.mockImplementation(() => new Promise((_resolve, reject) => (fail = reject)));
|
||||
const { result, unmount } = setup();
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.setUrl(ROUTE));
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
pending = result.current.read();
|
||||
});
|
||||
act(() => result.current.close());
|
||||
await act(async () => {
|
||||
fail(new Error('late'));
|
||||
await pending;
|
||||
});
|
||||
expect(result.current.failure).toBeNull();
|
||||
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.setUrl(ROUTE));
|
||||
act(() => {
|
||||
void result.current.read();
|
||||
});
|
||||
const signal = preview.mock.calls[1][2] as AbortSignal;
|
||||
unmount();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-006: loads only the stops with a position, in order and with their names, as a walk from Google Maps', async () => {
|
||||
const { result, planner: p } = setup();
|
||||
await readLink(result, ROUTE);
|
||||
act(() => result.current.load());
|
||||
expect(p.startDerivedTour).toHaveBeenCalledExactlyOnceWith({
|
||||
name: 'Walk from Google Maps',
|
||||
difficulty: 2,
|
||||
points: [
|
||||
{ lat: 48.1, lng: 11.5, name: 'Cathedral' },
|
||||
{ lat: 48.12, lng: 11.52, name: null },
|
||||
{ lat: 48.13, lng: 11.53, name: 'Park' },
|
||||
],
|
||||
inPlace: false,
|
||||
tourType: 'walk',
|
||||
source: 'google_maps',
|
||||
});
|
||||
expect(result.current.open).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-007: stays open when the planner does not take it, and loads nothing with fewer than two positions', async () => {
|
||||
const refusing = { tripId: 5, startDerivedTour: vi.fn(() => false) };
|
||||
const { result } = setup(refusing);
|
||||
await readLink(result, ROUTE);
|
||||
act(() => result.current.load());
|
||||
expect(refusing.startDerivedTour).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.open).toBe(true);
|
||||
|
||||
act(() => result.current.back());
|
||||
expect(result.current.phase).toBe('link');
|
||||
expect(result.current.preview).toBeNull();
|
||||
expect(result.current.url).toBe(ROUTE);
|
||||
|
||||
preview.mockResolvedValueOnce({
|
||||
...PREVIEW,
|
||||
stations: [PREVIEW.stations[0], PREVIEW.stations[1]],
|
||||
});
|
||||
await act(() => result.current.read());
|
||||
expect(result.current.canLoad).toBe(false);
|
||||
act(() => result.current.load());
|
||||
expect(refusing.startDerivedTour).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { WalkImportPreviewResponse, WalkImportStation } from '@trek/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourWalkImportRepo, walkImportRefusalOf } from '../../../repo/tourWalkImportRepo';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
|
||||
type Phase = 'link' | 'reading' | 'preview';
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/** A stop the link gives a position. */
|
||||
type PlacedStation = WalkImportStation & { lat: number; lng: number };
|
||||
|
||||
const isPlaced = (station: WalkImportStation): station is PlacedStation => station.lat !== null && station.lng !== null;
|
||||
|
||||
const WORD = /\s+/;
|
||||
const HTTPS_LINK = /^https:\/\/\S+$/i;
|
||||
|
||||
/**
|
||||
* The link in what was pasted. The Google Maps app shares a route as its
|
||||
* title and the link; a text holding exactly one https link among other words
|
||||
* is taken as that link. Anything else goes to the server as typed.
|
||||
*/
|
||||
function linkIn(text: string): string {
|
||||
const typed = text.trim();
|
||||
const links = typed.split(WORD).filter((word) => HTTPS_LINK.test(word));
|
||||
return links.length === 1 ? links[0] : typed;
|
||||
}
|
||||
|
||||
/** What the dialog says when a link was not read, in the reader's language. */
|
||||
function failureOf(err: unknown, t: Translate): string {
|
||||
if (err instanceof OnlineOnlyError) return t('tours.walkImport.offline');
|
||||
const reason = walkImportRefusalOf(err);
|
||||
return reason ? t(`tours.walkImport.refusal.${reason}`) : t('tours.walkImport.failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-07: a city walk from a Google Maps walking route. The dialog reads the
|
||||
* link pasted into it (the server follows a short link and refuses a route not
|
||||
* on foot), shows the stops in their order, those without a position marked,
|
||||
* and only on an explicit load hands the positioned stops to the planner as a
|
||||
* new, unsaved walk with their names, which TREK then routes on foot itself.
|
||||
* Closing, going back or a failure leaves the planner as it was.
|
||||
*/
|
||||
export function useWalkImport({ planner }: { planner: Pick<TourPlannerController, 'tripId' | 'startDerivedTour'> }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [phase, setPhase] = useState<Phase>('link');
|
||||
const [preview, setPreview] = useState<WalkImportPreviewResponse | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
const requestRef = useRef<AbortController | null>(null);
|
||||
|
||||
// A read still under way when the planner goes is dropped with it.
|
||||
useEffect(() => () => requestRef.current?.abort(), []);
|
||||
|
||||
const stations = preview?.stations ?? [];
|
||||
const placed = stations.filter(isPlaced);
|
||||
|
||||
const reset = () => {
|
||||
requestRef.current?.abort();
|
||||
requestRef.current = null;
|
||||
setPhase('link');
|
||||
setPreview(null);
|
||||
setFailure(null);
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
reset();
|
||||
setUrl('');
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const read = async () => {
|
||||
const link = linkIn(url);
|
||||
if (!link) return;
|
||||
requestRef.current?.abort();
|
||||
const request = new AbortController();
|
||||
requestRef.current = request;
|
||||
setPhase('reading');
|
||||
setFailure(null);
|
||||
try {
|
||||
const answer = await tourWalkImportRepo.preview(planner.tripId, link, request.signal);
|
||||
if (request.signal.aborted) return;
|
||||
setPreview(answer);
|
||||
setPhase('preview');
|
||||
} catch (err: unknown) {
|
||||
if (request.signal.aborted) return;
|
||||
setFailure(failureOf(err, t));
|
||||
setPhase('link');
|
||||
}
|
||||
};
|
||||
|
||||
const load = () => {
|
||||
if (placed.length < 2) return;
|
||||
const started = planner.startDerivedTour({
|
||||
name: t('tours.walkImport.defaultName'),
|
||||
difficulty: 2,
|
||||
points: placed.map(({ lat, lng, name }) => ({ lat, lng, name })),
|
||||
inPlace: false,
|
||||
tourType: 'walk',
|
||||
source: 'google_maps',
|
||||
});
|
||||
if (started) close();
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
show,
|
||||
close,
|
||||
url,
|
||||
setUrl,
|
||||
phase,
|
||||
preview,
|
||||
failure,
|
||||
/** How many stops the link leaves without a position; they are left out on load. */
|
||||
unplaced: stations.length - placed.length,
|
||||
canRead: phase !== 'reading' && url.trim().length > 0,
|
||||
canLoad: phase === 'preview' && placed.length >= 2,
|
||||
read,
|
||||
/** Back to the link, which stays as typed. */
|
||||
back: reset,
|
||||
load,
|
||||
};
|
||||
}
|
||||
|
||||
export type WalkImport = ReturnType<typeof useWalkImport>;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { walkStationsOf } from '@trek/shared';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import type { AssignmentsMap, Day, Place } from '../../../types';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { useWalkDay, type WalkDayChoice } from './useWalkDay';
|
||||
import { walkScheduleOf, type LinkedPlaceOf, type WalkSchedule, type WalkScheduleInput } from './walkSchedule';
|
||||
|
||||
const NO_PLACES: Place[] = [];
|
||||
const NO_DAYS: Day[] = [];
|
||||
const NO_ASSIGNMENTS: AssignmentsMap = {};
|
||||
const NO_STATIONS: TourPlannerWaypoint[] = [];
|
||||
|
||||
/** PR1-10: the trip place a point stands for, while the trip still has it. */
|
||||
export function useLinkedPlaceOf(): (point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>) => Place | null {
|
||||
const places = useTripStore((state) => state.places) ?? NO_PLACES;
|
||||
const byId = useMemo(() => new Map(places.map((place) => [place.id, place])), [places]);
|
||||
return useCallback(
|
||||
(point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>) =>
|
||||
point.linkedPlaceId ? (byId.get(point.linkedPlaceId) ?? null) : null,
|
||||
[byId]
|
||||
);
|
||||
}
|
||||
|
||||
/** CW-04: `walkScheduleOf`, worked out again only when one of its parts changes. */
|
||||
export function useWalkSchedule({ stations, route, walkingMinutes, day, placeOf }: WalkScheduleInput): WalkSchedule {
|
||||
return useMemo(
|
||||
() => walkScheduleOf({ stations, route, walkingMinutes, day, placeOf }),
|
||||
[day, placeOf, route, stations, walkingMinutes]
|
||||
);
|
||||
}
|
||||
|
||||
/** A walk's times in the planner: whether it is one, the day they are for, the schedule and how many stations. */
|
||||
export interface PlannerWalkSchedule {
|
||||
enabled: boolean;
|
||||
choice: WalkDayChoice;
|
||||
schedule: WalkSchedule;
|
||||
stationCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02, CW-04: the planner's walk against the days it is on. The day is the
|
||||
* one picked here, else the first with a start time; nothing of it is saved.
|
||||
* Only a route that belongs to the stations as they are now counts, so a stop
|
||||
* moved since the last routing shows no arrival until the route is planned again.
|
||||
*/
|
||||
export function usePlannerWalkSchedule(
|
||||
planner: Pick<
|
||||
TourPlannerController,
|
||||
'tourType' | 'editingPlaceId' | 'waypoints' | 'route' | 'routeCurrent' | 'walkingDurationMinutes'
|
||||
>,
|
||||
placeOf: LinkedPlaceOf
|
||||
): PlannerWalkSchedule {
|
||||
const days = useTripStore((state) => state.days) ?? NO_DAYS;
|
||||
const assignments = useTripStore((state) => state.assignments) ?? NO_ASSIGNMENTS;
|
||||
const { tourType, editingPlaceId, waypoints, route, routeCurrent, walkingDurationMinutes } = planner;
|
||||
const enabled = tourType === 'walk';
|
||||
const choice = useWalkDay(enabled ? (editingPlaceId ?? null) : null, days, assignments, null);
|
||||
const stations = useMemo(() => (enabled ? walkStationsOf(waypoints) : NO_STATIONS), [enabled, waypoints]);
|
||||
const schedule = useWalkSchedule({
|
||||
stations,
|
||||
route: routeCurrent ? (route ?? null) : null,
|
||||
walkingMinutes: walkingDurationMinutes ?? null,
|
||||
day: choice.day,
|
||||
placeOf,
|
||||
});
|
||||
return { enabled, choice, schedule, stationCount: stations.length };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { readWalkStations } from './walkStationsRead';
|
||||
|
||||
/**
|
||||
* CW-03: a saved walk's stations in order: null while they load, none where
|
||||
* they could not be read. Read again when the walk is saved (its revision
|
||||
* moves on), here or by someone else, so the detail never shows the stations
|
||||
* of before; the ones shown stay until the new ones are in.
|
||||
*/
|
||||
export function useWalkStations(
|
||||
tripId: number | string,
|
||||
placeId: number,
|
||||
enabled: boolean,
|
||||
revision?: number
|
||||
): TourPlannerWaypoint[] | null {
|
||||
const [stations, setStations] = useState<{ placeId: number; points: TourPlannerWaypoint[] } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
readWalkStations(tripId, placeId, controller.signal)
|
||||
.then((points) => {
|
||||
if (!controller.signal.aborted) setStations({ placeId, points });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setStations({ placeId, points: [] });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [enabled, placeId, revision, tripId]);
|
||||
return enabled && stations?.placeId === placeId ? stations.points : null;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// CW-03: a city walk's progress on one of its days, read alike by the day rows of the desktop and the phone.
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useTourVisitStore } from '../../../store/tourVisitStore';
|
||||
import { useDayVisits, useWalkProgress, walkProgressOf } from './walkProgress';
|
||||
|
||||
const walk = { tour_type: 'walk', station_count: 5 } as const;
|
||||
|
||||
beforeEach(() => useTourVisitStore.setState({ tripId: null, byAssignment: {} }));
|
||||
|
||||
describe('walkProgressOf', () => {
|
||||
it('says nothing for a hike, a walk without stops, or a day without marks', () => {
|
||||
const visits = { 'stop-01': 'visited' } as const;
|
||||
expect(walkProgressOf({ tour_type: 'hike', station_count: 5 }, visits)).toBeNull();
|
||||
expect(walkProgressOf({ ...walk, station_count: 0 }, visits)).toBeNull();
|
||||
expect(walkProgressOf({ ...walk, station_count: null }, visits)).toBeNull();
|
||||
expect(walkProgressOf({ tour_type: 'walk' }, visits)).toBeNull();
|
||||
expect(walkProgressOf(walk, undefined)).toBeNull();
|
||||
expect(walkProgressOf(walk, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the visited stops of all of them, a skip as marked but not visited', () => {
|
||||
const visits = { 'stop-01': 'visited', 'stop-02': 'visited', 'stop-03': 'skipped' } as const;
|
||||
expect(walkProgressOf(walk, visits)).toEqual({ visited: 2, total: 5 });
|
||||
expect(walkProgressOf(walk, { 'stop-03': 'skipped' })).toEqual({ visited: 0, total: 5 });
|
||||
});
|
||||
|
||||
it('never counts more visited stops than the walk has', () => {
|
||||
const visits = { 'stop-01': 'visited', 'stop-02': 'visited', 'stop-03': 'visited' } as const;
|
||||
expect(walkProgressOf({ ...walk, station_count: 2 }, visits)).toEqual({ visited: 2, total: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWalkProgress', () => {
|
||||
it("reads the day's visits from the store, and nothing without a day", () => {
|
||||
useTourVisitStore.setState({ byAssignment: { 9: { 'stop-01': 'visited' }, 10: { 'stop-02': 'skipped' } } });
|
||||
expect(renderHook(() => useWalkProgress(walk, 9)).result.current).toEqual({ visited: 1, total: 5 });
|
||||
expect(renderHook(() => useWalkProgress(walk, 10)).result.current).toEqual({ visited: 0, total: 5 });
|
||||
expect(renderHook(() => useWalkProgress(walk, 11)).result.current).toBeNull();
|
||||
expect(renderHook(() => useWalkProgress(walk, undefined)).result.current).toBeNull();
|
||||
expect(renderHook(() => useDayVisits(null)).result.current).toBeUndefined();
|
||||
expect(renderHook(() => useDayVisits(9)).result.current).toEqual({ 'stop-01': 'visited' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { useTourVisitStore, type VisitsByStation } from '../../../store/tourVisitStore';
|
||||
|
||||
/** The day's status of each station of a walk, by its key; undefined before any is marked or without a day. */
|
||||
export function useDayVisits(assignmentId: number | null | undefined): VisitsByStation | undefined {
|
||||
return useTourVisitStore((state) => (assignmentId == null ? undefined : state.byAssignment[assignmentId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03: how many of a walk's stations were visited on one day, out of all of
|
||||
* them, or null where there is nothing to say yet: not a walk, no stations, or
|
||||
* none marked on that day. A skip counts as marked but not as visited.
|
||||
*/
|
||||
export function walkProgressOf(
|
||||
tour: Pick<TourListItem, 'tour_type' | 'station_count'>,
|
||||
visits: VisitsByStation | undefined
|
||||
): { visited: number; total: number } | null {
|
||||
const total = tour.station_count ?? 0;
|
||||
if (tour.tour_type !== 'walk' || total === 0 || !visits) return null;
|
||||
const statuses = Object.values(visits);
|
||||
if (statuses.length === 0) return null;
|
||||
const visited = statuses.filter((status) => status === 'visited').length;
|
||||
return { visited: Math.min(visited, total), total };
|
||||
}
|
||||
|
||||
/** A walk's progress on the day its row stands for, shared by the day rows of the desktop and the phone. */
|
||||
export function useWalkProgress(
|
||||
tour: Pick<TourListItem, 'tour_type' | 'station_count'>,
|
||||
assignmentId: number | null | undefined
|
||||
): { visited: number; total: number } | null {
|
||||
return walkProgressOf(tour, useDayVisits(assignmentId));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// CW-02, CW-04: a city walk's stations on one day: what is missing for their
|
||||
// arrivals, and their arrivals, stays and opening hours once nothing is.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { walkScheduleOf, type LinkedPlaceOf, type WalkScheduleInput } from './walkSchedule';
|
||||
|
||||
const ROUTE: Array<[number, number]> = [
|
||||
[48, 11],
|
||||
[48.001, 11],
|
||||
[48.002, 11],
|
||||
];
|
||||
|
||||
const museum: TourPlannerWaypoint = {
|
||||
id: 'museum',
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
role: 'start',
|
||||
name: 'Museum',
|
||||
stayMinutes: 90,
|
||||
openingHours: 'Mo-Fr 10:00-18:00',
|
||||
};
|
||||
const square: TourPlannerWaypoint = { id: 'square', lat: 48.001, lng: 11, role: 'via', linkedPlaceId: 5 };
|
||||
const cafe: TourPlannerWaypoint = { id: 'cafe', lat: 48.002, lng: 11, role: 'end', name: 'Cafe', stayMinutes: 30 };
|
||||
|
||||
// 2026-10-19 is a Monday.
|
||||
const MONDAY = { date: '2026-10-19' };
|
||||
const noPlace: LinkedPlaceOf = () => null;
|
||||
|
||||
function input(overrides: Partial<WalkScheduleInput> = {}): WalkScheduleInput {
|
||||
return {
|
||||
stations: [museum, square, cafe],
|
||||
route: ROUTE,
|
||||
walkingMinutes: 40,
|
||||
day: { day: MONDAY, start: '09:30' },
|
||||
placeOf: noPlace,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('walkScheduleOf', () => {
|
||||
it('says what is missing, in order: a day, its date, a start time, a route with its walking time', () => {
|
||||
expect(walkScheduleOf(input({ day: null })).state).toBe('noDay');
|
||||
expect(walkScheduleOf(input({ day: { day: { date: null }, start: '09:30' } })).state).toBe('noDate');
|
||||
expect(walkScheduleOf(input({ day: { day: MONDAY, start: null } })).state).toBe('noStart');
|
||||
expect(walkScheduleOf(input({ day: { day: MONDAY, start: 'soon' } })).state).toBe('noStart');
|
||||
expect(walkScheduleOf(input({ stations: null })).state).toBe('noRoute');
|
||||
expect(walkScheduleOf(input({ route: null })).state).toBe('noRoute');
|
||||
expect(walkScheduleOf(input({ route: ROUTE.slice(0, 1) })).state).toBe('noRoute');
|
||||
const unknown = walkScheduleOf(input({ walkingMinutes: null }));
|
||||
expect(unknown).toEqual({ state: 'noRoute', byStation: new Map(), anyHours: false });
|
||||
});
|
||||
|
||||
it('gives each station its arrival, the end of its stay and what its hours say', () => {
|
||||
const schedule = walkScheduleOf(input());
|
||||
expect(schedule.state).toBe('ready');
|
||||
expect(schedule.anyHours).toBe(true);
|
||||
expect(schedule.byStation.get('museum')).toEqual({
|
||||
arrival: 570,
|
||||
atLeast: false,
|
||||
leave: 660,
|
||||
fit: { kind: 'closed', source: 'osm', windows: [[600, 1080]] },
|
||||
});
|
||||
// No stay entered at the square: its end is not known, and the cafe after it comes at the earliest.
|
||||
expect(schedule.byStation.get('square')).toEqual({
|
||||
arrival: 680,
|
||||
atLeast: false,
|
||||
leave: null,
|
||||
fit: { kind: 'unknown' },
|
||||
});
|
||||
expect(schedule.byStation.get('cafe')).toMatchObject({ arrival: 700, atLeast: true, leave: 730 });
|
||||
});
|
||||
|
||||
it("reads a start typed in twelve hours, and a trip place's own hours before the station's", () => {
|
||||
const week = JSON.stringify(Array.from({ length: 7 }, () => ({ closed: false, open: '09:00', close: '23:30' })));
|
||||
const placeOf: LinkedPlaceOf = (point) =>
|
||||
point.linkedPlaceId === 5 ? { name: 'Square', opening_hours: week } : null;
|
||||
const stations = [museum, { ...square, openingHours: 'Mo off' }, cafe];
|
||||
const schedule = walkScheduleOf(input({ stations, day: { day: MONDAY, start: '9:30 PM' }, placeOf }));
|
||||
expect(schedule.byStation.get('museum')?.arrival).toBe(1290);
|
||||
expect(schedule.byStation.get('square')?.fit).toEqual({ kind: 'open', source: 'place', window: [540, 1410] });
|
||||
});
|
||||
|
||||
it('knows no hours where no station has any', () => {
|
||||
const schedule = walkScheduleOf(input({ stations: [{ ...museum, openingHours: null }, square, cafe] }));
|
||||
expect(schedule.state).toBe('ready');
|
||||
expect(schedule.anyHours).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { minutesOf, stationArrivals, stationFit, stationHoursOn, type StationFit } from '@trek/shared';
|
||||
import type { Assignment, Day, Place } from '../../../types';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
|
||||
/**
|
||||
* CW-02, CW-04: a city walk's stations on the day it is looked at: the arrival
|
||||
* at each, the end of its stay and what its opening hours say, worked out by
|
||||
* the rules in `@trek/shared` (`walk-hours.ts`). Nothing is said without a
|
||||
* day, a date, a start time and a route with its walking time, so no estimate
|
||||
* looks more exact than what it is made of.
|
||||
*/
|
||||
|
||||
/** What a schedule still misses, or `ready`. */
|
||||
export type WalkScheduleState = 'noDay' | 'noDate' | 'noStart' | 'noRoute' | 'ready';
|
||||
|
||||
/** One station on that day: arrival and end of stay in minutes after midnight, and what its hours say. */
|
||||
export interface StationSchedule {
|
||||
arrival: number;
|
||||
/** A stay before it is not known, so the arrival may come later. */
|
||||
atLeast: boolean;
|
||||
/** The end of the stay; null while the stay is not known. */
|
||||
leave: number | null;
|
||||
fit: StationFit;
|
||||
}
|
||||
|
||||
export interface WalkSchedule {
|
||||
state: WalkScheduleState;
|
||||
/** Keyed by the waypoint's id; empty unless `ready`. */
|
||||
byStation: ReadonlyMap<string, StationSchedule>;
|
||||
/** Some station has opening hours to go by. */
|
||||
anyHours: boolean;
|
||||
}
|
||||
|
||||
/** The trip place a waypoint stands for, as far as the hours and the name need it. */
|
||||
export type LinkedPlaceOf = (
|
||||
point: Pick<TourPlannerWaypoint, 'linkedPlaceId'>
|
||||
) => Pick<Place, 'name' | 'opening_hours'> | null;
|
||||
|
||||
export interface WalkScheduleInput {
|
||||
/** The walk's stations in order: no shaping point, no way back of a loop. Null while they load. */
|
||||
stations: readonly TourPlannerWaypoint[] | null;
|
||||
route: ReadonlyArray<readonly [number, number]> | null;
|
||||
walkingMinutes: number | null;
|
||||
/** The day it is looked at, with the walk's start that day; null when it is on none. */
|
||||
day: { day: Pick<Day, 'date'>; start: string | null } | null;
|
||||
placeOf: LinkedPlaceOf;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02, CW-04, CW-08: when a walk starts on a day, in minutes after midnight:
|
||||
* the time set on that day's entry, else the place's own time, as the plan
|
||||
* shows it. Null where neither is set or it cannot be read.
|
||||
*/
|
||||
export function walkStartOf(assignment: Pick<Assignment, 'assignment_time' | 'place'>): number | null {
|
||||
return minutesOf(assignment.assignment_time ?? assignment.place?.place_time);
|
||||
}
|
||||
|
||||
function emptySchedule(state: WalkScheduleState): WalkSchedule {
|
||||
return { state, byStation: new Map(), anyHours: false };
|
||||
}
|
||||
|
||||
/** The date and the start in minutes the arrivals count from, or what is missing for them. */
|
||||
function startOf(day: WalkScheduleInput['day']): { date: string; start: number } | WalkScheduleState {
|
||||
if (!day) return 'noDay';
|
||||
if (!day.day.date) return 'noDate';
|
||||
const start = minutesOf(day.start);
|
||||
return start === null ? 'noStart' : { date: day.day.date, start };
|
||||
}
|
||||
|
||||
export function walkScheduleOf({ stations, route, walkingMinutes, day, placeOf }: WalkScheduleInput): WalkSchedule {
|
||||
const from = startOf(day);
|
||||
if (typeof from === 'string') return emptySchedule(from);
|
||||
if (!stations || !route) return emptySchedule('noRoute');
|
||||
const arrivals = stationArrivals(stations, route, from.start, walkingMinutes);
|
||||
if (!arrivals) return emptySchedule('noRoute');
|
||||
const byStation = new Map<string, StationSchedule>();
|
||||
let anyHours = false;
|
||||
stations.forEach((station, index) => {
|
||||
const at = arrivals[index];
|
||||
const hours = stationHoursOn({ place: placeOf(station)?.opening_hours, osm: station.openingHours }, from.date);
|
||||
if (hours) anyHours = true;
|
||||
const stay = station.stayMinutes ?? null;
|
||||
byStation.set(station.id, {
|
||||
...at,
|
||||
leave: stay === null ? null : at.arrival + stay,
|
||||
fit: stationFit(hours, at, stay),
|
||||
});
|
||||
});
|
||||
return { state: 'ready', byStation, anyHours };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { walkStationsOf } from '@trek/shared';
|
||||
import { tourRepo } from '../../../repo/tourRepo';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
|
||||
/**
|
||||
* CW-03: a synced walk's stations all carry a key. Points without any are the
|
||||
* route's ends, which this device reads in their place while it holds none of
|
||||
* the stations, so they are not listed as the walk's. A walk saved offline has
|
||||
* no keys before it synced, and its stations stay.
|
||||
*/
|
||||
function ownStations(points: TourPlannerWaypoint[], placeId: number): TourPlannerWaypoint[] {
|
||||
return placeId < 0 || points.some((point) => point.key) ? points : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03, CW-08: a saved walk's stations in order, as this device can read
|
||||
* them: from the server, else from its offline copy. Rejects where neither
|
||||
* has the walk.
|
||||
*/
|
||||
export async function readWalkStations(
|
||||
tripId: number | string,
|
||||
placeId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<TourPlannerWaypoint[]> {
|
||||
const detail = await tourRepo.detail(tripId, placeId, signal);
|
||||
return ownStations(walkStationsOf(plannerWaypointsOf(detail.waypoints, randomId)), placeId);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// FE-WALK-TIME-FIELDS-001 to FE-WALK-TIME-FIELDS-005: a city walk's walking time, stays and total in one
|
||||
// wording for the planner and the tour detail (CW-02).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { walkStaysText, walkTimeFields } from './walkTimeFields';
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>) =>
|
||||
({
|
||||
'tours.planner.walkingTimeUnknown': 'Not calculated',
|
||||
'tours.planner.plannedShort': 'Planned',
|
||||
'tours.planner.plannedTotalManual': 'Manual override',
|
||||
'tours.walk.stays': 'Stays',
|
||||
'tours.walk.station.stayUnknown': 'Not known yet',
|
||||
'tours.walk.totalComputed': 'Computed',
|
||||
})[key] ?? (key === 'tours.walk.totalAtLeast' ? `at least ${String(params?.time)}` : key);
|
||||
|
||||
const walking = { label: 'Walking' };
|
||||
const fieldsOf = (
|
||||
walkingMinutes: number | null,
|
||||
stays: { stayMinutes: number; unknownStays: number; stations: number },
|
||||
manualMinutes: number | null = null
|
||||
) => walkTimeFields({ walking, walkingMinutes, stays, manualMinutes, t }).map((field) => [field.label, field.value]);
|
||||
|
||||
describe('walkTimeFields', () => {
|
||||
it('FE-WALK-TIME-FIELDS-001: 45 min of walking, a museum of 90 min and a cafe of 30 min make 2 h 45 min', () => {
|
||||
expect(fieldsOf(45, { stayMinutes: 120, unknownStays: 0, stations: 3 })).toEqual([
|
||||
['Walking', '45 min'],
|
||||
['Stays', '2 h'],
|
||||
['Planned', '2 h 45 min'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-WALK-TIME-FIELDS-002: a stay not entered makes the stays and the total at least what is known', () => {
|
||||
expect(fieldsOf(45, { stayMinutes: 90, unknownStays: 1, stations: 3 })).toEqual([
|
||||
['Walking', '45 min'],
|
||||
['Stays', 'at least 1 h 30 min'],
|
||||
['Planned', 'at least 2 h 15 min'],
|
||||
]);
|
||||
// No stay entered at all plans the walking time alone, with no stays field to call them unknown.
|
||||
expect(fieldsOf(45, { stayMinutes: 0, unknownStays: 3, stations: 3 })).toEqual([
|
||||
['Walking', '45 min'],
|
||||
['Planned', '45 min'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-WALK-TIME-FIELDS-003: stays all entered as 0 are 0 min, passed through on purpose', () => {
|
||||
expect(fieldsOf(45, { stayMinutes: 0, unknownStays: 0, stations: 2 })).toEqual([
|
||||
['Walking', '45 min'],
|
||||
['Stays', '0 min'],
|
||||
['Planned', '45 min'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-WALK-TIME-FIELDS-004: without a walking time the stays entered are a part of the total, and nothing known none', () => {
|
||||
expect(fieldsOf(null, { stayMinutes: 120, unknownStays: 0, stations: 3 })).toEqual([
|
||||
['Walking', 'Not calculated'],
|
||||
['Stays', '2 h'],
|
||||
['Planned', 'at least 2 h'],
|
||||
]);
|
||||
expect(fieldsOf(null, { stayMinutes: 0, unknownStays: 2, stations: 2 })).toEqual([
|
||||
['Walking', 'Not calculated'],
|
||||
['Planned', 'Not calculated'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-WALK-TIME-FIELDS-005: a total entered by hand keeps its label, and the computed one shows beside it', () => {
|
||||
expect(fieldsOf(45, { stayMinutes: 90, unknownStays: 1, stations: 3 }, 240)).toEqual([
|
||||
['Walking', '45 min'],
|
||||
['Stays', 'at least 1 h 30 min'],
|
||||
['Planned (Manual override)', '4 h'],
|
||||
['Computed', 'at least 2 h 15 min'],
|
||||
]);
|
||||
expect(fieldsOf(null, { stayMinutes: 0, unknownStays: 1, stations: 1 }, 60)[2]).toEqual([
|
||||
'Computed',
|
||||
'Not calculated',
|
||||
]);
|
||||
// A column too narrow for the full label names the manual total by what sets it apart.
|
||||
const manual = walkTimeFields({
|
||||
walking,
|
||||
walkingMinutes: 45,
|
||||
stays: { stayMinutes: 0, unknownStays: 0, stations: 2 },
|
||||
manualMinutes: 240,
|
||||
t,
|
||||
})[2];
|
||||
expect(manual).toMatchObject({ label: 'Planned (Manual override)', shortLabel: 'Manual override' });
|
||||
});
|
||||
|
||||
it('keeps the walking label and its explanation as the planner hands them in', () => {
|
||||
const [first] = walkTimeFields({
|
||||
walking: { label: 'Est. walking time', shortLabel: 'Walking', info: { label: 'How', description: 'Router' } },
|
||||
walkingMinutes: 30,
|
||||
stays: { stayMinutes: 0, unknownStays: 0, stations: 2 },
|
||||
manualMinutes: null,
|
||||
t,
|
||||
});
|
||||
expect(first).toEqual({
|
||||
label: 'Est. walking time',
|
||||
shortLabel: 'Walking',
|
||||
info: { label: 'How', description: 'Router' },
|
||||
value: '30 min',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('walkStaysText', () => {
|
||||
it('says nothing of the stays of a walk without stations, or with none entered', () => {
|
||||
expect(walkStaysText({ stayMinutes: 0, unknownStays: 0, stations: 0 }, t)).toBeNull();
|
||||
expect(walkStaysText({ stayMinutes: 0, unknownStays: 2, stations: 2 }, t)).toBeNull();
|
||||
expect(walkStaysText({ stayMinutes: 30, unknownStays: 1, stations: 2 }, t)).toBe('at least 30 min');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { someStaysUnknown, walkTimesOf, type WalkTimes } from '../planner/stationModel';
|
||||
import type { TourMetricField } from '../tourParts';
|
||||
import { formatPlannedTourDuration, plannedTotalText } from '../tourPresentation';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** What a city walk's stays are known of: how many, how many have no stay entered, and the stays entered. */
|
||||
type WalkStays = Pick<WalkTimes, 'stayMinutes' | 'unknownStays' | 'stations'>;
|
||||
|
||||
/**
|
||||
* A city walk's stays as said: their sum, and at least the sum while some
|
||||
* stay is not entered yet. Nothing while no stay is entered at all: the walk
|
||||
* is then planned as its walking time, and saying "unknown" at every turn
|
||||
* would only be noise. A stop passed through counts as the 0 entered for it,
|
||||
* so stays all entered as 0 read "0 min".
|
||||
*/
|
||||
export function walkStaysText(stays: WalkStays, t: Translate): string | null {
|
||||
if (stays.unknownStays >= stays.stations) return null;
|
||||
const time = formatPlannedTourDuration(stays.stayMinutes);
|
||||
return someStaysUnknown(stays) ? t('tours.walk.totalAtLeast', { time }) : time;
|
||||
}
|
||||
|
||||
export interface WalkTimeFieldsInput {
|
||||
/** How the walking time is named: with its explanation in the planner, short in the tour detail. */
|
||||
walking: Omit<TourMetricField, 'value'>;
|
||||
walkingMinutes: number | null;
|
||||
stays: WalkStays;
|
||||
/** A total entered by hand, which stays apart from the one worked out; null when there is none. */
|
||||
manualMinutes: number | null;
|
||||
t: Translate;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-02: a city walk's times as fields, in one wording for the planner and the
|
||||
* tour detail: the walking time, the stays, and the planned total of both. A
|
||||
* total entered by hand keeps its own label, and the total worked out shows
|
||||
* beside it as computed, so the two never read as one. 45 min of walking, a
|
||||
* museum of 90 min and a cafe of 30 min read 45 min, 2 h and 2 h 45 min.
|
||||
*/
|
||||
export function walkTimeFields({
|
||||
walking,
|
||||
walkingMinutes,
|
||||
stays,
|
||||
manualMinutes,
|
||||
t,
|
||||
}: WalkTimeFieldsInput): TourMetricField[] {
|
||||
const notCalculated = t('tours.planner.walkingTimeUnknown');
|
||||
const { stayMinutes, unknownStays, stations } = stays;
|
||||
const times = walkTimesOf({ walkingMinutes, stayMinutes, unknownStays, stations });
|
||||
const computed =
|
||||
plannedTotalText({ plannedTotalMinutes: times.totalMinutes, atLeast: times.partial }, t) ?? notCalculated;
|
||||
const staysText = walkStaysText(stays, t);
|
||||
const parts: TourMetricField[] = [
|
||||
{ ...walking, value: walkingMinutes == null ? notCalculated : formatPlannedTourDuration(walkingMinutes) },
|
||||
...(staysText ? [{ label: t('tours.walk.stays'), value: staysText }] : []),
|
||||
];
|
||||
const planned = t('tours.planner.plannedShort');
|
||||
if (manualMinutes == null) return [...parts, { label: planned, value: computed }];
|
||||
return [
|
||||
...parts,
|
||||
{
|
||||
label: `${planned} (${t('tours.planner.plannedTotalManual')})`,
|
||||
// A narrow column names it by what sets it apart from the computed total beside it.
|
||||
shortLabel: t('tours.planner.plannedTotalManual'),
|
||||
value: formatPlannedTourDuration(manualMinutes),
|
||||
},
|
||||
{ label: t('tours.walk.totalComputed'), value: computed },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Assignment } from '../../../types';
|
||||
import type { MergedItem } from '../../../utils/dayMerge';
|
||||
import type { DayItineraryItem } from '../../Map/dayTourProjection';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { walkStartOf } from './walkSchedule';
|
||||
import {
|
||||
arrivesAfterStart,
|
||||
journeyLate,
|
||||
lateForWalk,
|
||||
walkEndOf,
|
||||
walkPairAt,
|
||||
walkTransitLeg,
|
||||
type WalkPair,
|
||||
type WalkTransitContext,
|
||||
} from './walkTransit';
|
||||
|
||||
// CW-08: public transit between two city walks on one day.
|
||||
|
||||
type Spot = { lat: number; lng: number };
|
||||
|
||||
function assignment(placeId: number, time: { assignment?: string | null; place?: string | null } = {}): Assignment {
|
||||
return {
|
||||
id: placeId + 100,
|
||||
day_id: 10,
|
||||
place_id: placeId,
|
||||
order_index: 0,
|
||||
tour_place_id: placeId,
|
||||
assignment_time: time.assignment ?? null,
|
||||
place: { id: placeId, name: `Place ${placeId}`, place_time: time.place ?? null },
|
||||
} as unknown as Assignment;
|
||||
}
|
||||
|
||||
function tourItem(
|
||||
placeId: number,
|
||||
start: Spot,
|
||||
end: Spot,
|
||||
time: { assignment?: string | null; place?: string | null } = {}
|
||||
): Extract<DayItineraryItem, { kind: 'tour' }> {
|
||||
const loop = start.lat === end.lat && start.lng === end.lng;
|
||||
return { kind: 'tour', assignment: assignment(placeId, time), placeId, start, end, geometry: [], loop, valid: true };
|
||||
}
|
||||
|
||||
function tour(placeId: number, name: string, overrides: Partial<TourListItem> = {}): TourListItem {
|
||||
return {
|
||||
place_id: placeId,
|
||||
name,
|
||||
tour_type: 'walk',
|
||||
duration: 90,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
...overrides,
|
||||
} as TourListItem;
|
||||
}
|
||||
|
||||
const OLD_TOWN = tour(1, 'Old town');
|
||||
const HARBOUR = tour(2, 'Harbour');
|
||||
const HIKE = tour(3, 'Ridge', { tour_type: 'hike' });
|
||||
const tours = new Map([OLD_TOWN, HARBOUR, HIKE].map((item) => [item.place_id, item]));
|
||||
const tourOf = (placeId: number) => tours.get(placeId);
|
||||
|
||||
const A_START = { lat: 52.52, lng: 13.4 };
|
||||
const A_END = { lat: 52.51, lng: 13.42 };
|
||||
const B_START = { lat: 52.5, lng: 13.45 };
|
||||
const B_END = { lat: 52.49, lng: 13.46 };
|
||||
const leg = (from: Spot, to: Spot) => ({ from: { name: '', ...from }, to: { name: '', ...to }, time: '10:00' });
|
||||
const stop = (id: string, name?: string, linkedPlaceId?: number): TourPlannerWaypoint => ({
|
||||
id,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
role: 'via',
|
||||
...(name ? { name } : {}),
|
||||
...(linkedPlaceId ? { linkedPlaceId } : {}),
|
||||
});
|
||||
const t = (key: string, params?: Record<string, string | number | null>) => `${key} ${JSON.stringify(params ?? {})}`;
|
||||
|
||||
describe('walkPairAt', () => {
|
||||
const first = tourItem(1, A_START, A_END, { place: '10:00' });
|
||||
const next = tourItem(2, B_START, B_END, { place: '13:00' });
|
||||
const located = (placeId: number, at: Spot, extra: Partial<Assignment> = {}): DayItineraryItem => ({
|
||||
kind: 'stop',
|
||||
assignment: { ...assignment(placeId), tour_place_id: null, place: { id: placeId, ...at }, ...extra } as Assignment,
|
||||
});
|
||||
|
||||
it('finds the walk the leg leaves at its end and the next one it reaches at its start', () => {
|
||||
const pair = walkPairAt([first, next], leg(A_END, B_START), tourOf, first.assignment.id);
|
||||
expect(pair?.first.tour.name).toBe('Old town');
|
||||
expect(pair?.next.tour.name).toBe('Harbour');
|
||||
});
|
||||
|
||||
it('reads past an item the route does not go to: a stop without a place, out of the route, or a booked night', () => {
|
||||
const unlocated: DayItineraryItem = { kind: 'stop', assignment: assignment(9) };
|
||||
const excluded = located(8, A_END, { route_excluded: true });
|
||||
const night = located(7, A_END, { accommodation_id: 3 });
|
||||
expect(walkPairAt([first, unlocated, excluded, night, next], leg(A_END, B_START), tourOf, 101)?.next.item).toBe(
|
||||
next
|
||||
);
|
||||
});
|
||||
|
||||
it('is no pair where the route goes to a stop first, even one at the walk end or the next walk start', () => {
|
||||
expect(walkPairAt([first, located(9, A_END), next], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
expect(walkPairAt([first, located(9, B_START), next], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
});
|
||||
|
||||
it('pairs a leg with the walk it leaves where two walks of the day end at one spot', () => {
|
||||
// Two loops from the hotel, each followed by a walk that starts at the same square.
|
||||
const hotel = { lat: 52.53, lng: 13.38 };
|
||||
const anyWalk = (placeId: number) => tour(placeId, `Walk ${placeId}`);
|
||||
const morning = tourItem(11, hotel, hotel, { place: '09:00' });
|
||||
const afternoon = tourItem(13, hotel, hotel, { place: '14:00' });
|
||||
const items = [morning, tourItem(12, B_START, B_END), afternoon, tourItem(14, B_START, B_END)];
|
||||
|
||||
const pair = walkPairAt(items, leg(hotel, B_START), anyWalk, afternoon.assignment.id);
|
||||
expect(pair?.first.item).toBe(afternoon);
|
||||
expect(pair?.next.item).toBe(items[3]);
|
||||
if (!pair) throw new Error('no pair');
|
||||
const searched = walkTransitLeg({
|
||||
leg: leg(hotel, B_START),
|
||||
dayId: 10,
|
||||
pair,
|
||||
stops: { first: [], next: [] },
|
||||
places: [],
|
||||
t,
|
||||
});
|
||||
expect(searched.time).toBe('16:15');
|
||||
expect(walkPairAt(items, leg(hotel, B_START), anyWalk, morning.assignment.id)?.next.item).toBe(items[1]);
|
||||
});
|
||||
|
||||
it('is no pair for a leg that leaves elsewhere or reaches no walk', () => {
|
||||
const hike = tourItem(3, B_START, B_END);
|
||||
const track = tourItem(4, B_START, B_END);
|
||||
expect(walkPairAt([first, hike], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
// A place with a track of its own is no tour of the list.
|
||||
expect(walkPairAt([first, track], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
expect(walkPairAt([hike, next], leg(B_END, B_START), tourOf, 103)).toBeNull();
|
||||
// The leg leaves the walk's start, not its end.
|
||||
expect(walkPairAt([first, next], leg(A_START, B_START), tourOf, 101)).toBeNull();
|
||||
// A leg from the walk to a place, from a walk with nothing after it, or from no entry of the day.
|
||||
expect(walkPairAt([first, located(9, { lat: 1, lng: 1 })], leg(A_END, { lat: 1, lng: 1 }), tourOf, 101)).toBeNull();
|
||||
expect(walkPairAt([first], leg(A_END, B_START), tourOf, 101)).toBeNull();
|
||||
expect(walkPairAt([first, next], leg(A_END, B_START), tourOf, undefined)).toBeNull();
|
||||
expect(walkPairAt([first, next], leg(A_END, B_START), tourOf, 102)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('walkEndOf', () => {
|
||||
it('adds the walking time and the stays to the start', () => {
|
||||
expect(walkEndOf(600, OLD_TOWN)).toEqual({ minutes: 735, atLeast: false, nextDay: false });
|
||||
});
|
||||
|
||||
it('is at the earliest while a stay is not known, or the list says nothing of the stops', () => {
|
||||
expect(walkEndOf(600, tour(1, 'A', { station_stays_unknown: 1 }))).toMatchObject({ minutes: 735, atLeast: true });
|
||||
expect(walkEndOf(600, tour(1, 'A', { station_stay_minutes: null }))).toMatchObject({ minutes: 690, atLeast: true });
|
||||
});
|
||||
|
||||
it('takes a total entered by hand before the one worked out', () => {
|
||||
expect(walkEndOf(600, tour(1, 'A', { planned_duration_minutes: 240, station_stays_unknown: 1 }))).toMatchObject({
|
||||
minutes: 840,
|
||||
atLeast: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('is not known without a start or without any time', () => {
|
||||
expect(walkEndOf(null, OLD_TOWN)).toBeNull();
|
||||
expect(walkEndOf(600, tour(1, 'A', { duration: null, station_stay_minutes: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('ends on the next day from midnight on', () => {
|
||||
expect(walkEndOf(23 * 60, OLD_TOWN)).toEqual({ minutes: 1515, atLeast: false, nextDay: true });
|
||||
expect(walkEndOf(24 * 60 - 135, OLD_TOWN)).toMatchObject({ nextDay: true });
|
||||
expect(walkEndOf(24 * 60 - 136, OLD_TOWN)).toMatchObject({ nextDay: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('walkStartOf', () => {
|
||||
it('takes the time on the day before the place time, and reads neither when none is set', () => {
|
||||
expect(walkStartOf(assignment(1, { assignment: '9:05', place: '11:00' }))).toBe(545);
|
||||
expect(walkStartOf(assignment(1, { place: '11:00' }))).toBe(660);
|
||||
expect(walkStartOf(assignment(1))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('walkTransitLeg', () => {
|
||||
const pair = (firstTime: { place?: string | null } = { place: '10:00' }, loop = false): WalkPair => ({
|
||||
first: { item: tourItem(1, A_START, loop ? A_START : A_END, firstTime), tour: OLD_TOWN },
|
||||
next: { item: tourItem(2, B_START, B_END, { assignment: '9:05' }), tour: HARBOUR },
|
||||
});
|
||||
const places = [{ id: 7, name: 'Fish market' }];
|
||||
|
||||
it('runs from the last stop to the first, departing when the first walk ends', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_END, B_START),
|
||||
dayId: 10,
|
||||
pair: pair(),
|
||||
stops: { first: [stop('a', 'Town hall'), stop('b', 'Old bridge')], next: [stop('c', undefined, 7)] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
from: { name: 'Old bridge', ...A_END },
|
||||
to: { name: 'Fish market', ...B_START },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '09:05',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('names an end after its walk where the stops are unread or unnamed, and makes up no time', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_END, B_START),
|
||||
dayId: 10,
|
||||
pair: pair({ place: null }),
|
||||
stops: { first: null, next: [stop('c')] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result.from.name).toBe('tours.walk.transit.endOf {"walk":"Old town"}');
|
||||
expect(result.to.name).toBe('tours.walk.transit.startOf {"walk":"Harbour"}');
|
||||
// The leg's own time is the walk's start, never when it ends.
|
||||
expect(result.time).toBeNull();
|
||||
expect(result.walk).toMatchObject({ ends: null, endsNextDay: false, endsAtLeast: false });
|
||||
});
|
||||
|
||||
it('says a walk ending after midnight ends on the next day, and makes up no time on this one', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_END, B_START),
|
||||
dayId: 10,
|
||||
pair: pair({ place: '23:00' }),
|
||||
stops: { first: [], next: [] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result.time).toBeNull();
|
||||
expect(result.walk).toMatchObject({ ends: null, endsNextDay: true });
|
||||
});
|
||||
|
||||
it('says nothing of the next start, nor of an arrival after it, where the next walk has no time', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_END, B_START),
|
||||
dayId: 10,
|
||||
pair: { ...pair(), next: { item: tourItem(2, B_START, B_END), tour: HARBOUR } },
|
||||
stops: { first: [], next: [] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result.walk.nextStarts).toBeNull();
|
||||
expect(lateForWalk(result.walk, '2025-06-01', { date: '2025-06-01', time: '23:50' })).toBeNull();
|
||||
});
|
||||
|
||||
it('ends a loop at its first stop again', () => {
|
||||
const result = walkTransitLeg({
|
||||
leg: leg(A_START, B_START),
|
||||
dayId: 10,
|
||||
pair: pair({ place: '10:00' }, true),
|
||||
stops: { first: [stop('a', 'Town hall'), stop('b', 'Old bridge')], next: [] },
|
||||
places,
|
||||
t,
|
||||
});
|
||||
expect(result.from).toEqual({ name: 'Town hall', ...A_START });
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrivesAfterStart', () => {
|
||||
it('compares the clock on the day and counts a later date as late', () => {
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:10' }, '2025-06-01', '13:00')).toBe(true);
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:00' }, '2025-06-01', '13:00')).toBe(false);
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '12:50' }, '2025-06-01', '13:00')).toBe(false);
|
||||
expect(arrivesAfterStart({ date: '2025-06-02', time: '00:10' }, '2025-06-01', '13:00')).toBe(true);
|
||||
expect(arrivesAfterStart({ date: '2025-05-31', time: '23:50' }, '2025-06-01', '13:00')).toBe(false);
|
||||
expect(arrivesAfterStart({ date: null, time: '13:10' }, '2025-06-01', '13:00')).toBe(true);
|
||||
});
|
||||
|
||||
it('says nothing without a start, a date or an arrival time', () => {
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:10' }, '2025-06-01', null)).toBe(false);
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: '13:10' }, null, '13:00')).toBe(false);
|
||||
expect(arrivesAfterStart({ date: '2025-06-01', time: null }, '2025-06-01', '13:00')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lateForWalk', () => {
|
||||
const walk: WalkTransitContext = {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
};
|
||||
|
||||
it('names the next walk a connection arrives too late for', () => {
|
||||
expect(lateForWalk(walk, '2025-06-01', { date: '2025-06-01', time: '13:10' })).toEqual({
|
||||
walk: 'Harbour',
|
||||
time: '13:00',
|
||||
});
|
||||
expect(lateForWalk(walk, '2025-06-01', { date: '2025-06-01', time: '12:50' })).toBeNull();
|
||||
expect(lateForWalk({ ...walk, nextStarts: null }, '2025-06-01', { date: '2025-06-01', time: '13:10' })).toBeNull();
|
||||
expect(lateForWalk(null, '2025-06-01', { date: '2025-06-01', time: '13:10' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('journeyLate', () => {
|
||||
const journey = { type: 'transit', reservation_end_time: '2025-06-01T13:10' };
|
||||
const walkAt = (placeId: number, time: string | null) =>
|
||||
({ type: 'place', sortKey: 1, data: assignment(placeId, { place: time }) }) as MergedItem;
|
||||
const note = { type: 'note', sortKey: 0, data: {} } as MergedItem;
|
||||
const late = (following: MergedItem[], reservation = journey) =>
|
||||
journeyLate({ reservation, following, date: '2025-06-01', tourOf });
|
||||
|
||||
it('names the walk a saved journey now arrives too late for, notes aside', () => {
|
||||
expect(late([note, walkAt(2, '13:00')])).toEqual({ walk: 'Harbour', time: '13:00' });
|
||||
expect(late([walkAt(2, '13:30')])).toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing for another booking, another kind of tour, a walk without a start or a stop between', () => {
|
||||
expect(late([walkAt(2, '13:00')], { type: 'train', reservation_end_time: '2025-06-01T13:10' })).toBeNull();
|
||||
expect(late([walkAt(3, '13:00')])).toBeNull();
|
||||
expect(late([walkAt(2, null)])).toBeNull();
|
||||
expect(late([{ type: 'transport', sortKey: 1, data: {} } as MergedItem, walkAt(2, '13:00')])).toBeNull();
|
||||
expect(late([])).toBeNull();
|
||||
const plainPlace = {
|
||||
type: 'place',
|
||||
sortKey: 1,
|
||||
data: { ...assignment(2, { place: '13:00' }), tour_place_id: null },
|
||||
};
|
||||
expect(late([plainPlace as MergedItem])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { clockOf, minutesOf, type TourListItem } from '@trek/shared';
|
||||
import type { Assignment, Place, Reservation, TranslationFn } from '../../../types';
|
||||
import type { MergedItem } from '../../../utils/dayMerge';
|
||||
import { splitReservationDateTime } from '../../../utils/formatters';
|
||||
import type { DayItineraryItem } from '../../Map/dayTourProjection';
|
||||
import type { TransitLeg } from '../../Planner/transitLeg';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { tourPlannedTimes } from '../tourPresentation';
|
||||
import { walkStartOf } from './walkSchedule';
|
||||
|
||||
/**
|
||||
* CW-08: public transit between two city walks on one day. The leg runs from
|
||||
* where the first walk really ends to where the next one starts, and departs
|
||||
* when the first one ends: its start that day and its planned total, a total
|
||||
* entered by hand before the one worked out. Where that cannot be worked out
|
||||
* no time is made up. The walks stay two walks on foot; nothing here is saved.
|
||||
*/
|
||||
|
||||
const DAY_MINUTES = 24 * 60;
|
||||
/** Closer than this, in degrees, a leg's end is the walk's end: the route copies the coordinates. */
|
||||
const SAME_SPOT_DEG = 1e-9;
|
||||
|
||||
/** Which tour a place is, as the trip's tour list knows it. */
|
||||
export type TourOf = (placeId: number) => TourListItem | undefined;
|
||||
|
||||
/** What the transit search says of the two walks a leg runs between. */
|
||||
export interface WalkTransitContext {
|
||||
/** The day both walks are on: on another day the search no longer speaks of them. */
|
||||
dayId: number;
|
||||
fromWalk: string;
|
||||
toWalk: string;
|
||||
/** `HH:MM` the first walk ends that day; null where it cannot be worked out or ends on the next day. */
|
||||
ends: string | null;
|
||||
/** The first walk ends after midnight, on another day than the one searched on. */
|
||||
endsNextDay: boolean;
|
||||
/** The end leaves out a time not known yet, so the walk ends then at the earliest. */
|
||||
endsAtLeast: boolean;
|
||||
/** `HH:MM` the next walk starts that day; null without a start time. */
|
||||
nextStarts: string | null;
|
||||
}
|
||||
|
||||
type TourItem = Extract<DayItineraryItem, { kind: 'tour' }>;
|
||||
|
||||
/** A walk in the day's route: its item there and the tour it is. */
|
||||
interface DayWalk {
|
||||
item: TourItem;
|
||||
tour: TourListItem;
|
||||
}
|
||||
|
||||
/** The two walks a leg runs between. */
|
||||
export interface WalkPair {
|
||||
first: DayWalk;
|
||||
next: DayWalk;
|
||||
}
|
||||
|
||||
/** A walk the arrival comes too late for: its name and the `HH:MM` it starts. */
|
||||
export interface WalkLate {
|
||||
walk: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
function sameSpot(point: { lat: number; lng: number } | null, other: { lat: number; lng: number }): boolean {
|
||||
return !!point && Math.abs(point.lat - other.lat) < SAME_SPOT_DEG && Math.abs(point.lng - other.lng) < SAME_SPOT_DEG;
|
||||
}
|
||||
|
||||
function walkOf(item: DayItineraryItem | undefined, tourOf: TourOf): DayWalk | null {
|
||||
if (item?.kind !== 'tour') return null;
|
||||
const tour = tourOf(item.placeId);
|
||||
return tour?.tour_type === 'walk' ? { item, tour } : null;
|
||||
}
|
||||
|
||||
/** Whether the day's route goes to an item: in the route, and a walk or a stop with a location. */
|
||||
function routeGoesTo(item: DayItineraryItem): boolean {
|
||||
const { assignment } = item;
|
||||
if (assignment.route_excluded || assignment.accommodation_id != null) return false;
|
||||
return item.kind === 'tour' || (!!assignment.place?.lat && !!assignment.place?.lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* The walk a leg leaves from its end and the walk it reaches at its start, or
|
||||
* null where the leg is no such leg. The leg is told by the entry it leaves,
|
||||
* as the connector carries it, since two walks of a day can end at one spot;
|
||||
* it reaches the next point the route goes to, so that must be the next walk.
|
||||
*/
|
||||
export function walkPairAt(
|
||||
items: readonly DayItineraryItem[],
|
||||
leg: Pick<TransitLeg, 'from' | 'to'>,
|
||||
tourOf: TourOf,
|
||||
originId: number | undefined
|
||||
): WalkPair | null {
|
||||
const index = items.findIndex((item) => item.assignment.id === originId);
|
||||
const first = walkOf(items[index], tourOf);
|
||||
if (!first || !sameSpot(first.item.end, leg.from)) return null;
|
||||
const next = walkOf(items.slice(index + 1).find(routeGoesTo), tourOf);
|
||||
return next && sameSpot(next.item.start, leg.to) ? { first, next } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a walk ends after a start, in minutes after midnight, or null without
|
||||
* a start or a total. Past midnight it ends on the next day. A list that says
|
||||
* nothing of the stops leaves the stays out, so that end is a lower bound too.
|
||||
*/
|
||||
export function walkEndOf(
|
||||
start: number | null,
|
||||
tour: TourListItem
|
||||
): { minutes: number; atLeast: boolean; nextDay: boolean } | null {
|
||||
const times = tourPlannedTimes(tour);
|
||||
if (start === null || times.plannedTotalMinutes === null) return null;
|
||||
const minutes = start + times.plannedTotalMinutes;
|
||||
return {
|
||||
minutes,
|
||||
atLeast: times.atLeast || (!times.manuallyOverridden && times.walk === null),
|
||||
nextDay: minutes >= DAY_MINUTES,
|
||||
};
|
||||
}
|
||||
|
||||
/** A stop as named: its own name, else the trip place it stands for. */
|
||||
function stopName(stop: TourPlannerWaypoint | undefined, places: readonly Pick<Place, 'id' | 'name'>[]): string {
|
||||
if (!stop) return '';
|
||||
return stop.name || places.find((place) => place.id === stop.linkedPlaceId)?.name || '';
|
||||
}
|
||||
|
||||
export interface WalkTransitInput {
|
||||
leg: TransitLeg;
|
||||
dayId: number;
|
||||
pair: WalkPair;
|
||||
/** Each walk's stops as read; null where they could not be read. */
|
||||
stops: { first: readonly TourPlannerWaypoint[] | null; next: readonly TourPlannerWaypoint[] | null };
|
||||
places: readonly Pick<Place, 'id' | 'name'>[];
|
||||
t: TranslationFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* The search for a leg between two walks: from the first walk's last stop to
|
||||
* the next one's first, each named, else said as the end or start of its walk,
|
||||
* at the coordinates the day's route draws, so a journey saved there joins it.
|
||||
* It departs when the first walk ends, and never at the time the leg's own
|
||||
* point carries: that is when the walk starts.
|
||||
*/
|
||||
export function walkTransitLeg({ leg, dayId, pair, stops, places, t }: WalkTransitInput): TransitLeg & {
|
||||
walk: WalkTransitContext;
|
||||
} {
|
||||
const { first, next } = pair;
|
||||
const firstStops = stops.first ?? [];
|
||||
// A loop ends where it starts, at its first stop again.
|
||||
const endStop = first.item.loop ? firstStops[0] : firstStops[firstStops.length - 1];
|
||||
const end = walkEndOf(walkStartOf(first.item.assignment), first.tour);
|
||||
// Past midnight no time on the search's day is made up.
|
||||
const ends = end && !end.nextDay ? clockOf(end.minutes) : null;
|
||||
const nextStart = walkStartOf(next.item.assignment);
|
||||
return {
|
||||
from: { ...leg.from, name: stopName(endStop, places) || t('tours.walk.transit.endOf', { walk: first.tour.name }) },
|
||||
to: {
|
||||
...leg.to,
|
||||
name: stopName(stops.next?.[0], places) || t('tours.walk.transit.startOf', { walk: next.tour.name }),
|
||||
},
|
||||
time: ends,
|
||||
walk: {
|
||||
dayId,
|
||||
fromWalk: first.tour.name,
|
||||
toWalk: next.tour.name,
|
||||
ends,
|
||||
endsNextDay: end?.nextDay ?? false,
|
||||
endsAtLeast: end?.atLeast ?? false,
|
||||
nextStarts: nextStart === null ? null : clockOf(nextStart),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an arrival comes after a start on a day: a later date always, the
|
||||
* same date by the clock. An arrival without a date is on that day. Nothing is
|
||||
* said without a date, a start or an arrival time.
|
||||
*/
|
||||
export function arrivesAfterStart(
|
||||
arrival: { date: string | null; time: string | null },
|
||||
date: string | null,
|
||||
start: string | null
|
||||
): boolean {
|
||||
const startMinutes = minutesOf(start);
|
||||
const arrivalMinutes = minutesOf(arrival.time);
|
||||
if (!date || startMinutes === null || arrivalMinutes === null) return false;
|
||||
const arrivalDate = arrival.date ?? date;
|
||||
return arrivalDate === date ? arrivalMinutes > startMinutes : arrivalDate > date;
|
||||
}
|
||||
|
||||
/** The next walk a connection found in the search arrives too late for, or null. */
|
||||
export function lateForWalk(
|
||||
walk: WalkTransitContext | null,
|
||||
date: string | null,
|
||||
arrival: { date: string | null; time: string | null }
|
||||
): WalkLate | null {
|
||||
if (!walk?.nextStarts || !arrivesAfterStart(arrival, date, walk.nextStarts)) return null;
|
||||
return { walk: walk.toWalk, time: walk.nextStarts };
|
||||
}
|
||||
|
||||
export interface JourneyLateInput {
|
||||
reservation: Pick<Reservation, 'type' | 'reservation_end_time'>;
|
||||
/** The day's items after the journey, in their order. */
|
||||
following: readonly MergedItem[];
|
||||
date: string | null;
|
||||
tourOf: TourOf;
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved journey that now arrives after the walk it leads to starts, said
|
||||
* while the plan is looked at and never written: a walk moved or a journey
|
||||
* changed since shows here, and nothing is replaced. Only a public transit
|
||||
* journey, followed by a walk with a start time, notes aside.
|
||||
*/
|
||||
export function journeyLate({ reservation, following, date, tourOf }: JourneyLateInput): WalkLate | null {
|
||||
if (reservation.type !== 'transit') return null;
|
||||
const next = following.find((item) => item.type !== 'note');
|
||||
if (next?.type !== 'place') return null;
|
||||
const assignment: Assignment = next.data;
|
||||
const tour = assignment.tour_place_id === assignment.place_id ? tourOf(assignment.place_id) : undefined;
|
||||
const start = walkStartOf(assignment);
|
||||
if (tour?.tour_type !== 'walk' || start === null) return null;
|
||||
const time = clockOf(start);
|
||||
const arrival = splitReservationDateTime(reservation.reservation_end_time);
|
||||
return arrivesAfterStart(arrival, date, time) ? { walk: tour.name, time } : null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TourVisit } from '@trek/shared';
|
||||
import { offlineDb, type CachedTourStationVisit } from './offlineDb';
|
||||
|
||||
function rowOf(tripId: number, visit: TourVisit): CachedTourStationVisit {
|
||||
return { assignment_id: visit.assignment_id, station_key: visit.station_key, status: visit.status, trip_id: tripId };
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03: a trip's walk visits as the server listed them. The rows it no longer
|
||||
* lists go, so a stop taken back on another device does not come back offline.
|
||||
*/
|
||||
export async function replaceTripVisits(tripId: number, visits: readonly TourVisit[]): Promise<void> {
|
||||
await offlineDb.transaction('rw', offlineDb.tourStationVisits, async () => {
|
||||
await offlineDb.tourStationVisits.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.tourStationVisits.bulkPut(visits.map((visit) => rowOf(tripId, visit)));
|
||||
});
|
||||
}
|
||||
|
||||
/** One day's visits of a walk as the server answered a mark of it; the other days stay as they are. */
|
||||
export async function replaceAssignmentVisits(
|
||||
tripId: number,
|
||||
assignmentId: number,
|
||||
visits: readonly TourVisit[]
|
||||
): Promise<void> {
|
||||
const own = visits.filter((visit) => visit.assignment_id === assignmentId);
|
||||
await offlineDb.transaction('rw', offlineDb.tourStationVisits, async () => {
|
||||
await offlineDb.tourStationVisits.where('assignment_id').equals(assignmentId).delete();
|
||||
await offlineDb.tourStationVisits.bulkPut(own.map((visit) => rowOf(tripId, visit)));
|
||||
});
|
||||
}
|
||||
|
||||
/** The trip's walk visits as last cached, without the trip they were filed under. */
|
||||
export async function cachedTripVisits(tripId: number): Promise<TourVisit[]> {
|
||||
const rows = await offlineDb.tourStationVisits.where('trip_id').equals(tripId).toArray();
|
||||
return rows.map((row) => ({ assignment_id: row.assignment_id, station_key: row.station_key, status: row.status }));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user