From 085c5b426304f5b75c32e2fd9b5b75ee01a669cd Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 15:50:47 +0200 Subject: [PATCH] feat(assistant): hike, walk and route cards with their lines on the map, and "Plan this" --- .../Assistant/cards/EntityActions.test.tsx | 23 +- .../Assistant/cards/EntityActions.tsx | 10 +- .../components/Assistant/cards/cardTypes.ts | 2 + .../cards/tour/TourListCard.test.tsx | 81 ++++ .../Assistant/cards/tour/TourListCard.tsx | 128 +++++ .../Assistant/cards/tour/TourRouteActions.tsx | 83 ++++ .../cards/tour/TourRouteBlock.test.tsx | 120 +++++ .../Assistant/cards/tour/TourRouteBlock.tsx | 23 + .../cards/tour/TourRouteCard.test.tsx | 346 +++++++++++++ .../Assistant/cards/tour/TourRouteCard.tsx | 76 +++ .../Assistant/cards/tour/TourRouteDay.tsx | 115 +++++ .../Assistant/cards/tour/TourRouteHead.tsx | 164 +++++++ .../cards/tour/TourRouteInRow.test.tsx | 74 +++ .../Assistant/cards/tour/TourRouteInRow.tsx | 76 +++ .../Assistant/cards/tour/TourRouteNotes.tsx | 55 +++ .../Assistant/cards/tour/TourRouteProfile.tsx | 134 +++++ .../Assistant/cards/tour/TourRouteStops.tsx | 164 +++++++ .../Assistant/cards/tour/TourWays.tsx | 121 +++++ .../Assistant/cards/tour/tourLook.test.ts | 90 ++++ .../Assistant/cards/tour/tourLook.ts | 131 ++++- .../cards/tour/tourRouteDayModel.test.ts | 50 ++ .../Assistant/cards/tour/tourRouteDayModel.ts | 85 ++++ .../cards/tour/tourRouteModel.test.ts | 212 ++++++++ .../Assistant/cards/tour/tourRouteModel.ts | 240 +++++++++ .../cards/tour/tourRouteSketchModel.ts | 60 +++ .../Assistant/cards/tour/tourRouteWords.ts | 101 ++++ .../cards/tour/tourTakeModel.test.ts | 59 +++ .../Assistant/cards/tour/tourTakeModel.ts | 58 +++ .../Assistant/cards/tour/tourViews.tsx | 22 +- .../Assistant/cards/tour/useTourRouteCard.ts | 146 ++++++ .../Assistant/map/RoutePeek.test.tsx | 179 +++++++ .../components/Assistant/map/RoutePeek.tsx | 127 +++++ .../Assistant/map/RoutePeekLazy.tsx | 22 + .../Assistant/map/routePeekControl.ts | 18 + .../Assistant/map/routePeekModel.ts | 36 ++ .../Map/AssistantGhostLineLayer.test.tsx | 160 ++++++ .../Map/AssistantGhostLineLayer.tsx | 134 +++++ .../components/Map/AssistantMapLeaflet.tsx | 12 +- client/src/components/Map/MapViewGL.tsx | 3 +- client/src/components/Map/assistantGhost.css | 13 + .../Map/assistantGhostMarker.test.ts | 26 +- .../components/Map/assistantGhostMarker.ts | 18 +- client/src/components/Map/mapViewContract.ts | 20 +- .../src/components/Map/placeClusterLayers.ts | 7 + .../Map/useAssistantGhostLinesGL.test.ts | 218 +++++++++ .../Map/useAssistantGhostLinesGL.ts | 276 +++++++++++ .../src/components/Map/useAssistantMapGL.ts | 14 +- .../components/Tours/hike/HikeWaysList.tsx | 17 +- .../Tours/hike/SacStepChip.test.tsx | 31 ++ .../src/components/Tours/hike/SacStepChip.tsx | 46 ++ .../Tours/packing/TourPackingSection.tsx | 36 +- .../Tours/packing/packingLinkParts.tsx | 65 ++- .../Tours/planner/LegVariantsCard.tsx | 12 +- .../Tours/planner/TourPlannerPanels.tsx | 13 +- .../Tours/planner/legVariantPaint.test.ts | 15 +- .../Tours/planner/legVariantPaint.ts | 7 +- .../components/Tours/planner/mapCardParts.tsx | 2 +- .../components/Tours/tourPresentation.test.ts | 9 +- .../src/components/Tours/tourPresentation.ts | 14 + .../shared/ElevationProfile.test.tsx | 47 ++ .../components/shared/ElevationProfile.tsx | 41 +- .../shared/elevationProfileModel.ts | 38 +- .../trip/assistant/MAssistantScreen.tsx | 22 +- .../src/mobile/screens/trip/map/MMapArea.tsx | 4 +- .../deepLinks/useTourDeepLink.test.ts | 123 +++++ .../tripPlanner/deepLinks/useTourDeepLink.ts | 101 +++- .../tripPlanner/useAssistantMapLink.test.ts | 39 +- .../pages/tripPlanner/useAssistantMapLink.ts | 49 +- .../useAssistantRouteLines.test.ts | 180 +++++++ .../tripPlanner/useAssistantRouteLines.ts | 293 +++++++++++ .../src/pages/tripPlanner/useTourWorkspace.ts | 2 + client/src/store/assistantMapModel.ts | 3 +- client/src/store/assistantRouteModel.test.ts | 183 +++++++ client/src/store/assistantRouteModel.ts | 259 ++++++++++ client/src/store/assistantStore.cards.test.ts | 21 +- client/src/store/assistantStore.ts | 26 +- client/src/types/assistantMap.ts | 27 ++ client/tests/helpers/assistantTour.ts | 156 ++++++ .../mobile/trip/MAssistantScreen.test.tsx | 27 +- .../tests/unit/mobile/trip/MMapArea.test.tsx | 19 +- .../nest/assistant/assistant-cards.helpers.ts | 8 +- .../src/nest/assistant/assistant-history.ts | 15 + .../assistant/assistant-proposals.service.ts | 311 ++++++++++++ .../assistant/assistant-tools.constants.ts | 26 +- .../nest/assistant/assistant.controller.ts | 16 + server/src/nest/assistant/assistant.dto.ts | 2 + server/src/nest/assistant/assistant.module.ts | 2 + .../src/nest/assistant/assistant.service.ts | 7 +- server/src/nest/assistant/cards/tour/index.ts | 86 +++- .../assistant/cards/tour/tour-card-values.ts | 246 ++++++++++ .../assistant/cards/tour/tour-change.cards.ts | 234 +++++++++ .../assistant/cards/tour/tour-describe.ts | 15 +- .../assistant/cards/tour/tour-lists.cards.ts | 49 ++ .../assistant/cards/tour/tour-route.cards.ts | 344 +++++++++++++ .../assistant/cards/tour/tour-sport.cards.ts | 277 +++++++++++ .../nest/assistant/cards/tour/tour-undo.ts | 59 ++- server/tests/e2e/assistant-tours.e2e.test.ts | 322 +++++++++++++ server/tests/helpers/assistant-tour-cards.ts | 95 ++++ .../assistant/assistant-cards.helpers.test.ts | 35 ++ .../nest/assistant/assistant-history.test.ts | 25 + .../assistant-proposals.service.test.ts | 305 ++++++++++++ .../assistant/assistant.controller.test.ts | 20 +- .../cards/tour/tour-change.cards.test.ts | 367 ++++++++++++++ .../cards/tour/tour-route.cards.test.ts | 456 ++++++++++++++++++ .../cards/tour/tour-sport.cards.test.ts | 391 +++++++++++++++ shared/src/assistant/cards/tour-list.card.ts | 36 +- .../assistant/cards/tour-route.card.spec.ts | 182 +++++++ shared/src/assistant/cards/tour-route.card.ts | 172 ++++++- shared/src/i18n/ar/assistantTour.ts | 87 +++- shared/src/i18n/az/assistantTour.ts | 76 ++- shared/src/i18n/br/assistantTour.ts | 76 ++- shared/src/i18n/ca/assistantTour.ts | 77 ++- shared/src/i18n/cs/assistantTour.ts | 79 ++- shared/src/i18n/de/assistantTour.ts | 76 ++- shared/src/i18n/en/assistantTour.ts | 75 ++- shared/src/i18n/es/assistantTour.ts | 77 ++- shared/src/i18n/et/assistantTour.ts | 76 ++- shared/src/i18n/fr/assistantTour.ts | 76 ++- shared/src/i18n/gr/assistantTour.ts | 76 ++- shared/src/i18n/hu/assistantTour.ts | 76 ++- shared/src/i18n/id/assistantTour.ts | 73 ++- shared/src/i18n/it/assistantTour.ts | 77 ++- shared/src/i18n/ja/assistantTour.ts | 71 ++- shared/src/i18n/ko/assistantTour.ts | 71 ++- shared/src/i18n/nl/assistantTour.ts | 76 ++- shared/src/i18n/pl/assistantTour.ts | 82 +++- shared/src/i18n/ru/assistantTour.ts | 82 +++- shared/src/i18n/sk/assistantTour.ts | 79 ++- shared/src/i18n/sv/assistantTour.ts | 75 ++- shared/src/i18n/th/assistantTour.ts | 72 ++- shared/src/i18n/tr/assistantTour.ts | 76 ++- shared/src/i18n/uk/assistantTour.ts | 82 +++- shared/src/i18n/vi/assistantTour.ts | 72 ++- shared/src/i18n/zh-TW/assistantTour.ts | 71 ++- shared/src/i18n/zh/assistantTour.ts | 71 ++- 135 files changed, 12185 insertions(+), 234 deletions(-) create mode 100644 client/src/components/Assistant/cards/tour/TourListCard.test.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourListCard.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteActions.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteBlock.test.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteBlock.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteCard.test.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteCard.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteDay.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteHead.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteInRow.test.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteInRow.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteNotes.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteProfile.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourRouteStops.tsx create mode 100644 client/src/components/Assistant/cards/tour/TourWays.tsx create mode 100644 client/src/components/Assistant/cards/tour/tourLook.test.ts create mode 100644 client/src/components/Assistant/cards/tour/tourRouteDayModel.test.ts create mode 100644 client/src/components/Assistant/cards/tour/tourRouteDayModel.ts create mode 100644 client/src/components/Assistant/cards/tour/tourRouteModel.test.ts create mode 100644 client/src/components/Assistant/cards/tour/tourRouteModel.ts create mode 100644 client/src/components/Assistant/cards/tour/tourRouteSketchModel.ts create mode 100644 client/src/components/Assistant/cards/tour/tourRouteWords.ts create mode 100644 client/src/components/Assistant/cards/tour/tourTakeModel.test.ts create mode 100644 client/src/components/Assistant/cards/tour/tourTakeModel.ts create mode 100644 client/src/components/Assistant/cards/tour/useTourRouteCard.ts create mode 100644 client/src/components/Assistant/map/RoutePeek.test.tsx create mode 100644 client/src/components/Assistant/map/RoutePeek.tsx create mode 100644 client/src/components/Assistant/map/RoutePeekLazy.tsx create mode 100644 client/src/components/Assistant/map/routePeekControl.ts create mode 100644 client/src/components/Assistant/map/routePeekModel.ts create mode 100644 client/src/components/Map/AssistantGhostLineLayer.test.tsx create mode 100644 client/src/components/Map/AssistantGhostLineLayer.tsx create mode 100644 client/src/components/Map/placeClusterLayers.ts create mode 100644 client/src/components/Map/useAssistantGhostLinesGL.test.ts create mode 100644 client/src/components/Map/useAssistantGhostLinesGL.ts create mode 100644 client/src/components/Tours/hike/SacStepChip.test.tsx create mode 100644 client/src/components/Tours/hike/SacStepChip.tsx create mode 100644 client/src/pages/tripPlanner/deepLinks/useTourDeepLink.test.ts create mode 100644 client/src/pages/tripPlanner/useAssistantRouteLines.test.ts create mode 100644 client/src/pages/tripPlanner/useAssistantRouteLines.ts create mode 100644 client/src/store/assistantRouteModel.test.ts create mode 100644 client/src/store/assistantRouteModel.ts create mode 100644 client/tests/helpers/assistantTour.ts create mode 100644 server/src/nest/assistant/assistant-proposals.service.ts create mode 100644 server/src/nest/assistant/cards/tour/tour-card-values.ts create mode 100644 server/src/nest/assistant/cards/tour/tour-change.cards.ts create mode 100644 server/src/nest/assistant/cards/tour/tour-lists.cards.ts create mode 100644 server/src/nest/assistant/cards/tour/tour-route.cards.ts create mode 100644 server/src/nest/assistant/cards/tour/tour-sport.cards.ts create mode 100644 server/tests/e2e/assistant-tours.e2e.test.ts create mode 100644 server/tests/helpers/assistant-tour-cards.ts create mode 100644 server/tests/unit/nest/assistant/assistant-proposals.service.test.ts create mode 100644 server/tests/unit/nest/assistant/cards/tour/tour-change.cards.test.ts create mode 100644 server/tests/unit/nest/assistant/cards/tour/tour-route.cards.test.ts create mode 100644 server/tests/unit/nest/assistant/cards/tour/tour-sport.cards.test.ts create mode 100644 shared/src/assistant/cards/tour-route.card.spec.ts diff --git a/client/src/components/Assistant/cards/EntityActions.test.tsx b/client/src/components/Assistant/cards/EntityActions.test.tsx index 41f2b700a..417d04761 100644 --- a/client/src/components/Assistant/cards/EntityActions.test.tsx +++ b/client/src/components/Assistant/cards/EntityActions.test.tsx @@ -1,5 +1,5 @@ /** - * FE-AI-ENTITY-001 to FE-AI-ENTITY-004 + * FE-AI-ENTITY-001 to FE-AI-ENTITY-005 * * What a receipt offers for the thing it changed, read off the entity's * look: the map pin where the plan can show it, "Open" where it lives in @@ -20,6 +20,8 @@ vi.mock('./cardLooks', async (importOriginal) => { ...actual.ENTITY_LOOK.tour, openLabelKey: 'assistant.change.viewOnMap', phoneOpenLabelKey: 'assistant.history.open', + onMap: undefined, + phoneOpen: undefined, open: (applied: AssistantApplied) => ({ kind: 'tab', tab: 'tour-planner', @@ -27,6 +29,12 @@ vi.mock('./cardLooks', async (importOriginal) => { }), }, budget: { ...actual.ENTITY_LOOK.budget, open: () => ({ kind: 'tab', tab: 'finanzplan' }) }, + reservation: { + ...actual.ENTITY_LOOK.reservation, + openLabelKey: undefined, + open: () => ({ kind: 'tab', tab: 'buchungen' }), + phoneOpen: () => null, + }, }; return { ...actual, ENTITY_LOOK: looks, entityLookOf: (entity: AssistantApplied['entity']) => looks[entity] }; }); @@ -67,7 +75,7 @@ describe('EntityActions', () => { }); it('FE-AI-ENTITY-003: what has no place on the map gets no pin', () => { - const { container } = renderActions(applied('packing', [2], null), { onOpen: vi.fn() }); + const { container } = renderActions(applied('memory', [2], null), { onOpen: vi.fn() }); expect(container).toBeEmptyDOMElement(); }); @@ -87,4 +95,15 @@ describe('EntityActions', () => { fireEvent.click(screen.getByRole('button', { name: 'Open' })); expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan' }); }); + + it('FE-AI-ENTITY-005: on the phone "Open" goes where the look names for the phone, and shows none where it names nothing', () => { + const onOpen = vi.fn(); + const desktop = renderActions(applied('reservation', [5], null), { onOpen }); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'buchungen' }); + desktop.unmount(); + + renderActions(applied('reservation', [5], null), { onOpen, variant: 'fullscreen' }); + expect(screen.queryByRole('button', { name: 'Open' })).toBeNull(); + }); }); diff --git a/client/src/components/Assistant/cards/EntityActions.tsx b/client/src/components/Assistant/cards/EntityActions.tsx index 0d0859098..523800169 100644 --- a/client/src/components/Assistant/cards/EntityActions.tsx +++ b/client/src/components/Assistant/cards/EntityActions.tsx @@ -11,8 +11,9 @@ import { ICON_BUTTON } from './parts/assistantTones'; /** * What a receipt offers for the thing it changed, as its entity's look says: * the map pin where the plan's map can show it, and "Open" where it lives - * somewhere else in TREK (a tab of the trip, another page). Neither shows - * where the shell around the chat cannot open anything. + * somewhere else in TREK (a tab of the trip, another page; on the phone what + * its look names for it there). Neither shows where the shell around the + * chat cannot open anything. */ export default function EntityActions({ applied, card }: { applied: AssistantApplied; card: TrekCard | null }) { const { t } = useTranslation(); @@ -20,8 +21,9 @@ export default function EntityActions({ applied, card }: { applied: AssistantApp if (!onOpen) return null; const look = entityLookOf(applied.entity); const onMap = showOnMapTarget(applied, card); - const open = look.open?.(applied, card) ?? null; - const openKey = variant === 'fullscreen' ? (look.phoneOpenLabelKey ?? look.openLabelKey) : look.openLabelKey; + const phone = variant === 'fullscreen'; + const open = (phone && look.phoneOpen ? look.phoneOpen(applied, card) : look.open?.(applied, card)) ?? null; + const openKey = phone ? (look.phoneOpenLabelKey ?? look.openLabelKey) : look.openLabelKey; const openLabel = openKey ? t(openKey) : t('assistant.card.common.open'); return ( <> diff --git a/client/src/components/Assistant/cards/cardTypes.ts b/client/src/components/Assistant/cards/cardTypes.ts index 80a52135f..11e51ff12 100644 --- a/client/src/components/Assistant/cards/cardTypes.ts +++ b/client/src/components/Assistant/cards/cardTypes.ts @@ -49,6 +49,8 @@ export interface EntityLook { phoneOpenLabelKey?: string; onMap?(applied: AssistantApplied, card: TrekCard | null): { placeId: number | null; dayId: number | null } | null; open?(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null; + /** Where "Open" goes on the phone instead, where the tab `open` names may not exist; null shows none there. */ + phoneOpen?(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null; } /** diff --git a/client/src/components/Assistant/cards/tour/TourListCard.test.tsx b/client/src/components/Assistant/cards/tour/TourListCard.test.tsx new file mode 100644 index 000000000..48ce58152 --- /dev/null +++ b/client/src/components/Assistant/cards/tour/TourListCard.test.tsx @@ -0,0 +1,81 @@ +/** + * FE-AI-TOUR-035 to FE-AI-TOUR-036, FE-AI-TOUR-044 + * + * The saved tours of a trip as the Tours sidebar lists them: the avatar, the + * name, time, length and climb, the days a tour is on or that it is on none + * yet, the rest counted as a way to the Tours tab (on the phone, to the plan); + * resting on a row lights the tour's line on the map, a tap shows it there, + * and a card that goes puts out the line it lit. + */ +import type { TourListCard as TourList } from '@trek/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TOUR_LIST } from '../../../../../tests/helpers/assistantTour'; +import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { useAssistantStore } from '../../../../store/assistantStore'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import TourListCard from './TourListCard'; + +const ASSISTANT = useAssistantStore.getState(); + +afterEach(() => { + useAssistantStore.setState(ASSISTANT, true); +}); + +function renderList(view: Partial = {}, card: TourList = TOUR_LIST) { + const value: AssistantView = { tripId: 4, variant: 'window', ...view }; + return render( + + + + ); +} + +describe('TourListCard', () => { + it('FE-AI-TOUR-035: lists the tours with their figures and days, and counts the rest as a way to the Tours tab', () => { + const onOpen = vi.fn(); + renderList({ onOpen }); + expect(screen.getByRole('region', { name: 'Tours' })).toBeInTheDocument(); + expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument(); + expect(screen.getByText('8.2 km')).toBeInTheDocument(); + expect(screen.getByText('640 m')).toBeInTheDocument(); + expect(screen.getByText('Day 2')).toBeInTheDocument(); + expect(screen.getByText('Not on a day yet')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '2 more' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'tour-planner' }); + }); + + it('FE-AI-TOUR-036: resting on a row lights the tour on the map, a tap shows it there', () => { + const onOpen = vi.fn(); + renderList({ onOpen }); + const row = screen.getByRole('button', { name: /Chiemsee by kayak/ }); + fireEvent.mouseEnter(row); + expect(useAssistantStore.getState().mapLink).toMatchObject({ tripId: 4, hoveredId: 'tour:42', source: 'card' }); + fireEvent.mouseLeave(row); + expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull(); + fireEvent.click(row); + expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 42, dayId: null }); + }); + + it('FE-AI-TOUR-044: names several days at once, counts the rest towards the plan on the phone, and puts out its light when it goes', () => { + const onOpen = vi.fn(); + const first = TOUR_LIST.rows[0] as TourList['rows'][number]; + const card: TourList = { ...TOUR_LIST, dayId: 22, rows: [{ ...first, days: [2, 4] }] }; + const view = renderList({ onOpen, variant: 'fullscreen' }, card); + expect(screen.getByText('Days 2, 4')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '2 more' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: null, dayId: 22 }); + + fireEvent.mouseEnter(screen.getByRole('button', { name: /Hochfelln from Bergen/ })); + expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('tour:41'); + view.unmount(); + expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull(); + + // A line lit from the map is the map's: a card that goes leaves it lit. + const again = renderList({ onOpen }, card); + act(() => useAssistantStore.getState().setMapHover(4, 'tour:41', 'map')); + again.unmount(); + expect(useAssistantStore.getState().mapLink).toMatchObject({ hoveredId: 'tour:41', source: 'map' }); + }); +}); diff --git a/client/src/components/Assistant/cards/tour/TourListCard.tsx b/client/src/components/Assistant/cards/tour/TourListCard.tsx new file mode 100644 index 000000000..512f54ee2 --- /dev/null +++ b/client/src/components/Assistant/cards/tour/TourListCard.tsx @@ -0,0 +1,128 @@ +import type { TourListCard as TourList, TourListRow } from '@trek/shared'; +import { CalendarDays, Clock, MoveHorizontal, Route, TrendingUp } from 'lucide-react'; +import { useEffect } from 'react'; + +import { useTranslation } from '../../../../i18n'; +import { useAssistantStore } from '../../../../store/assistantStore'; +import { SoftPill, WhiteBadge } from '../../../Planner/planParts'; +import { formatTourDuration } from '../../../Tours/planner/tourDurationInput'; +import { TourDayRowAvatar } from '../../../Tours/TourDayRowFacts'; +import { useAssistantView } from '../../assistantViewContext'; +import type { CardEntry, EntityTarget } from '../cardTypes'; +import CardFrame from '../parts/CardFrame'; +import CardHeader from '../parts/CardHeader'; +import { CardMore, CardRow, CardRows } from '../parts/CardRows'; +import { useCardFormat } from '../parts/useCardFormat'; +import { useLineHover } from './useTourRouteCard'; + +/** + * The saved tours of a trip, or of one of its days, as the Tours sidebar + * lists them: the tour's avatar with its grade, its name, its time, length + * and climb, and the days it is on or that it is on none yet. Resting on a + * row lights the tour's line on the map; a tap shows it there. What the card + * leaves out is counted at its end, a way to the Tours tab (on the phone, to + * the plan). + */ + +/** The map's key for a saved tour's line, the one the plan draws it under. */ +const tourRef = (placeId: number) => `tour:${placeId}`; + +/** Where the tours the card leaves out are listed; the phone has no Tours tab and shows them on the plan. */ +const TOURS_TAB: EntityTarget = { kind: 'tab', tab: 'tour-planner' }; + +/** The days a tour is on, "Day 2" or "Days 2, 4", or that it is on none yet. */ +function dayWords(days: readonly number[], t: (key: string, params?: Record) => string) { + if (days.length === 0) return t('assistant.card.tour.unplanned'); + if (days.length === 1) return t('assistant.card.tour.day', { day: days[0] ?? '' }); + return t('assistant.card.tour.days', { days: days.join(', ') }); +} + +function TourRow({ row, lit, onHover }: { row: TourListRow; lit: boolean; onHover: (on: boolean) => void }) { + const { t } = useTranslation(); + const format = useCardFormat(); + const { onOpen } = useAssistantView(); + const glyph = 'flex-none text-content-faint'; + const facts = ( + + {row.minutes !== null && ( + }> + {formatTourDuration(row.minutes)} + + )} + {row.distanceM !== null && ( + }> + {format.distance(row.distanceM / 1000)} + + )} + {row.ascentM !== null && ( + }> + {format.elevation(row.ascentM)} + + )} + + + ); + return ( + } + title={{row.name}} + facts={facts} + lit={lit} + onHover={onHover} + onActivate={onOpen ? () => onOpen({ kind: 'map', placeId: row.placeId, dayId: null }) : undefined} + /> + ); +} + +function TourListBody({ card }: { card: TourList }) { + const { t } = useTranslation(); + const { tripId, variant } = useAssistantView(); + const hovered = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null)); + const setMapHover = useAssistantStore((s) => s.setMapHover); + const hover = useLineHover(); + + // A card that goes puts out the line it lit. + useEffect( + () => () => { + const link = useAssistantStore.getState().mapLink; + const lit = link?.tripId === tripId && link.source === 'card' && link.hoveredId?.startsWith('tour:'); + if (lit) setMapHover(tripId, null, 'card'); + }, + [tripId, setMapHover] + ); + + return ( + + +
+ + {card.rows.map((row) => ( + hover(tourRef(row.placeId), on)} + /> + ))} + + +
+
+ ); +} + +export default function TourListCard({ cards }: { cards: readonly CardEntry[] }) { + return ( +
+ {cards.map(({ toolCallId, card }) => + card.kind === 'tourList' ? : null + )} +
+ ); +} diff --git a/client/src/components/Assistant/cards/tour/TourRouteActions.tsx b/client/src/components/Assistant/cards/tour/TourRouteActions.tsx new file mode 100644 index 000000000..a042de7c3 --- /dev/null +++ b/client/src/components/Assistant/cards/tour/TourRouteActions.tsx @@ -0,0 +1,83 @@ +import type { TourRouteCard } from '@trek/shared'; +import { Check, Loader2, MapPin, Plus } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { SoftPill } from '../../../Planner/planParts'; +import { useAssistantView } from '../../assistantViewContext'; +import { CardOpenButton } from '../parts/CardOpen'; +import { tourTarget } from './tourLook'; +import { useTakeProposal } from './useTourRouteCard'; + +/** + * The foot of a route card: "On map" always; "Plan this" on a proposed hike + * or city walk for someone who may add to the trip, off with its reason said + * while it cannot be taken, and once taken a note that the tour waits below + * for a decision (or, once applied, that it was added); "Open in Tours" on a saved tour. Taller on the phone, as + * tall as the card's own "Open"; the phone has no Tours tab, so there a + * saved tour opens as the place it is on the plan. + */ +export default function TourRouteActions({ + toolCallId, + card, + letter, + onShow, +}: { + toolCallId: string; + card: TourRouteCard; + /** The chosen way's letter in a ways card: "Plan way B". */ + letter?: string; + onShow: () => void; +}) { + const { t } = useTranslation(); + const { variant } = useAssistantView(); + const take = useTakeProposal(toolCallId, card); + const phone = variant === 'fullscreen'; + const tall = phone ? 'h-10' : 'h-7'; + const planLabel = letter ? t('assistant.card.tour.planWay', { letter }) : t('assistant.card.tour.plan'); + let caption: string | null = null; + if (take.state === 'failed') caption = t('assistant.card.tour.askAgain'); + else if (take.state !== 'taken' && take.reasonKey) caption = t(take.reasonKey); + return ( +
+
+ + {card.source === 'saved' && card.placeId !== null && ( + + )} + {take.visible && take.state === 'taken' && ( + + )} + {take.visible && take.state !== 'taken' && ( + + )} +
+ {take.visible && caption && ( +

{caption}

+ )} +
+ ); +} diff --git a/client/src/components/Assistant/cards/tour/TourRouteBlock.test.tsx b/client/src/components/Assistant/cards/tour/TourRouteBlock.test.tsx new file mode 100644 index 000000000..706234fbd --- /dev/null +++ b/client/src/components/Assistant/cards/tour/TourRouteBlock.test.tsx @@ -0,0 +1,120 @@ +/** + * FE-AI-TOUR-029 to FE-AI-TOUR-032, FE-AI-TOUR-043 + * + * The ways of one answer as the planner offers a section's ways: one choice + * per way with how it differs from the chosen one, one profile with the + * others drawn thin, the choice kept per answer and lit on the map, side by + * side where the window is roomy, and any other route as a card of its own; + * the card lit with its chosen line both ways, and "On map" framing them all. + */ +import type { TourRouteCard } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GENTLE, HIKE, SAVED } from '../../../../../tests/helpers/assistantTour'; +import { buildTrip, buildUser } from '../../../../../tests/helpers/factories'; +import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { chatOf, useAssistantStore } from '../../../../store/assistantStore'; +import { useAuthStore } from '../../../../store/authStore'; +import { useTripStore } from '../../../../store/tripStore'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import TourRouteBlock from './TourRouteBlock'; + +const TRIP = 4; +const ASSISTANT = useAssistantStore.getState(); +const TRIPS = useTripStore.getState(); +const AUTH = useAuthStore.getState(); + +beforeEach(() => { + useAuthStore.setState({ user: buildUser({ id: 1 }) }); + useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) }); + useAssistantStore.setState({ + byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7 } }, + }); +}); + +afterEach(() => { + useAssistantStore.setState(ASSISTANT, true); + useTripStore.setState(TRIPS, true); + useAuthStore.setState(AUTH, true); +}); + +function renderBlock(cards: { toolCallId: string; card: TourRouteCard }[], view: Partial = {}) { + const value: AssistantView = { tripId: TRIP, variant: 'window', density: 'compact', onOpen: vi.fn(), ...view }; + return render( + + + + ); +} + +const WAYS = [ + { toolCallId: 'call_1', card: { ...HIKE, label: 'Short and steep' } }, + { toolCallId: 'call_2', card: GENTLE }, +]; + +describe('TourRouteBlock', () => { + it('FE-AI-TOUR-029: two ways read as one choice, the other way compared with the chosen one on one profile', () => { + const { container } = renderBlock(WAYS); + const group = screen.getByRole('radiogroup', { name: '2 ways' }); + const ways = group.querySelectorAll('[role="radio"]'); + expect(ways).toHaveLength(2); + expect(ways[0]).toHaveAttribute('aria-checked', 'true'); + expect(ways[0]).toHaveAccessibleName('Short and steep'); + expect(ways[1]).toHaveAccessibleName('Long and gentle'); + expect(ways[1]?.querySelector('[data-testid="leg-variant-delta"]')).not.toBeNull(); + expect(ways[0]?.querySelector('[data-testid="leg-variant-delta"]')).toBeNull(); + expect(container.querySelector('[data-profile-compare="call_2"]')).not.toBeNull(); + expect(screen.getByRole('button', { name: 'Plan way A' })).toBeInTheDocument(); + }); + + it('FE-AI-TOUR-030: choosing a way keeps it for the answer and plans it; pointing at a way lights its line', () => { + renderBlock(WAYS); + const second = screen.getByRole('radio', { name: 'Long and gentle' }); + fireEvent.mouseEnter(second); + expect(useAssistantStore.getState().mapLink).toMatchObject({ tripId: TRIP, hoveredId: 'call_2', source: 'card' }); + fireEvent.mouseLeave(second); + expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull(); + fireEvent.click(second); + expect(chatOf(useAssistantStore.getState(), TRIP).picks).toEqual({ 'tour:call_1': 'call_2' }); + expect(second).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('button', { name: 'Plan way B' })).toBeInTheDocument(); + expect(document.querySelector('[data-profile-compare="call_1"]')).not.toBeNull(); + }); + + it('FE-AI-TOUR-031: the ways stand side by side in a roomy window and stay rows on the phone', () => { + renderBlock(WAYS, { density: 'roomy' }); + expect(screen.getByRole('radiogroup')).toHaveClass('grid-cols-2'); + renderBlock(WAYS, { density: 'roomy', variant: 'fullscreen' }); + expect(screen.getAllByRole('radiogroup')[1]).not.toHaveClass('grid-cols-2'); + }); + + it('FE-AI-TOUR-032: a saved tour beside a proposal, or one proposal alone, is a card of its own', () => { + renderBlock([ + { toolCallId: 'call_1', card: SAVED }, + { toolCallId: 'call_2', card: { ...HIKE, name: 'Wank' } }, + ]); + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Hochfelln from Bergen' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Wank' })).toBeInTheDocument(); + }); + + it('FE-AI-TOUR-043: resting on the ways card lights the chosen line, a line lit on the map rings it, "On map" frames every way', () => { + const requestCamera = vi.fn(); + useAssistantStore.setState({ requestCamera }); + const far: TourRouteCard = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] }; + renderBlock([WAYS[0] as (typeof WAYS)[number], { toolCallId: 'call_2', card: far }]); + const card = screen.getByRole('region', { name: '2 ways' }); + fireEvent.mouseEnter(card); + expect(useAssistantStore.getState().mapLink).toMatchObject({ tripId: TRIP, hoveredId: 'call_1', source: 'card' }); + fireEvent.mouseLeave(card); + expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull(); + + act(() => useAssistantStore.getState().setMapHover(TRIP, 'call_1', 'map')); + expect(card.parentElement).toHaveClass('ring-1'); + + fireEvent.click(screen.getByRole('button', { name: 'On map' })); + const points = requestCamera.mock.calls[0]?.[1] as [number, number][]; + expect(points).toHaveLength(HIKE.line.length + far.line.length); + expect(points).toContainEqual([47.9, 12.8]); + }); +}); diff --git a/client/src/components/Assistant/cards/tour/TourRouteBlock.tsx b/client/src/components/Assistant/cards/tour/TourRouteBlock.tsx new file mode 100644 index 000000000..ef89c9c03 --- /dev/null +++ b/client/src/components/Assistant/cards/tour/TourRouteBlock.tsx @@ -0,0 +1,23 @@ +import type { CardEntry } from '../cardTypes'; +import TourRouteCard from './TourRouteCard'; +import { routeEntries, wayGroups } from './tourRouteModel'; +import TourWays from './TourWays'; + +/** + * The routes of one answer: two or three ways of one route proposed together + * as one card to choose from, every other route (a saved tour, a paddle or a + * ski plan, a single proposal) as a card of its own. + */ +export default function TourRouteBlock({ cards }: { cards: readonly CardEntry[] }) { + const groups = wayGroups(routeEntries(cards)); + return ( +
+ {groups.map((group) => { + const first = group[0]; + if (!first) return null; + if (group.length >= 2) return ; + return ; + })} +
+ ); +} diff --git a/client/src/components/Assistant/cards/tour/TourRouteCard.test.tsx b/client/src/components/Assistant/cards/tour/TourRouteCard.test.tsx new file mode 100644 index 000000000..e63e96ee3 --- /dev/null +++ b/client/src/components/Assistant/cards/tour/TourRouteCard.test.tsx @@ -0,0 +1,346 @@ +/** + * FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042 + * + * A route in the chat as the Tours tab shows a hike: its figures and grade, + * a profile only where the heights are known, the profile, the stop chips + * and the map moving one spot, the day with the sun, the hours and the + * warnings, "Plan this" with its gate (the right to add, the thread already + * holding the tour, what came of a take), "Open in Tours" on a saved tour, + * and the phone's readout row, taller buttons and open on the plan. + */ +import type { AssistantMessage, AssistantMessagePart, TourRouteCard as Route } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GENTLE, HIKE, HIKE_LINE, SAVED } from '../../../../../tests/helpers/assistantTour'; +import { buildTrip, buildUser } from '../../../../../tests/helpers/factories'; +import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render'; +import { chatOf, type HoldOutcome, useAssistantStore } from '../../../../store/assistantStore'; +import { useAuthStore } from '../../../../store/authStore'; +import { usePermissionsStore } from '../../../../store/permissionsStore'; +import { useTripStore } from '../../../../store/tripStore'; +import { setForcedOffline } from '../../../../sync/networkMode'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import TourRouteCard from './TourRouteCard'; + +const TRIP = 4; +const ASSISTANT = useAssistantStore.getState(); +const TRIPS = useTripStore.getState(); +const AUTH = useAuthStore.getState(); +const PERMISSIONS = usePermissionsStore.getState(); + +const takeProposal = vi.fn(async (): Promise => 'held'); +const requestCamera = vi.fn(); +const onRevealMap = vi.fn(); + +/** A waiting change in another answer, which holds "Plan this" back. */ +const UNDECIDED: AssistantMessage = { + id: 'msg-9', + role: 'assistant', + status: 'awaiting_approval', + createdAt: '2026-10-21T08:00:00.000Z', + parts: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } }], +}; + +/** The answer that proposed the hike under `call_1`, as the thread keeps it. */ +function proposed(): AssistantMessage { + return { + id: 'msg-1', + role: 'assistant', + status: 'complete', + createdAt: '2026-10-21T07:00:00.000Z', + parts: [{ type: 'data-trek-card', data: { toolCallId: 'call_1', card: HIKE } }], + }; +} + +/** The change "Plan this" held for that hike: applied (`true`) or discarded (`false`). */ +function heldTake(approved: boolean): AssistantMessage { + const parts: AssistantMessagePart[] = [ + { type: 'tool-call', toolCallId: 't1', toolName: 'take_proposal', args: {}, approval: { id: 't1', approved } }, + { + type: 'data-trek-change', + data: { + toolCallId: 't1', + verbKey: 'addTour', + subject: 'Hochfelln from Bergen', + rows: [], + dayId: 22, + placeIds: [], + undoable: true, + card: { ...HIKE, source: 'pending' }, + }, + }, + ]; + return { id: 'msg-2', role: 'assistant', status: 'complete', createdAt: '2026-10-21T07:05:00.000Z', parts }; +} + +function seed(messages: AssistantMessage[] = []) { + useAuthStore.setState({ user: buildUser({ id: 1 }) }); + useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) }); + useAssistantStore.setState({ + takeProposal, + requestCamera, + byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7, messages } }, + }); +} + +function renderCard(card: Route, view: Partial = {}) { + const value: AssistantView = { + tripId: TRIP, + variant: 'window', + density: 'compact', + onRevealMap, + onOpen: vi.fn(), + ...view, + }; + return render( + + + + ); +} + +beforeEach(() => { + setForcedOffline(false); + seed(); +}); + +afterEach(() => { + useAssistantStore.setState(ASSISTANT, true); + useTripStore.setState(TRIPS, true); + useAuthStore.setState(AUTH, true); + usePermissionsStore.setState(PERMISSIONS, true); + setForcedOffline(false); + vi.clearAllMocks(); +}); + +describe('TourRouteCard', () => { + it('FE-AI-TOUR-021: states the name, the figures as badges, the day and the hardest grade, uncertain when little is graded', () => { + const { container } = renderCard(HIKE); + expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument(); + for (const figure of ['time', 'distance', 'ascent', 'descent']) { + expect(container.querySelector(`[data-figure="${figure}"]`)).not.toBeNull(); + } + expect(screen.getByText('8.2 km')).toBeInTheDocument(); + expect(screen.getAllByText('640 m')).toHaveLength(2); + expect(screen.getByText(/^Day 2, /)).toBeInTheDocument(); + expect(screen.getByTestId('tour-grade')).toHaveTextContent('T3'); + expect(screen.getByTestId('tour-grade')).toHaveAccessibleName(/Highest known: T3 Demanding mountain hiking/); + renderCard({ ...GENTLE, name: 'Gentle way' }); + expect(screen.getAllByTestId('tour-grade')[1]).toHaveTextContent('T2?'); + }); + + it('FE-AI-TOUR-022: draws a profile only where every height is known, and says why a route is drawn straight', () => { + renderCard(HIKE); + expect(screen.getByRole('group', { name: 'Elevation profile of Hochfelln from Bergen' })).toBeInTheDocument(); + const gap = HIKE_LINE.map((point, index): [number, number, number | null] => + index === 2 ? [point[0], point[1], null] : point + ); + renderCard({ ...HIKE, line: gap }); + expect(screen.getByText('No heights for this route, so there is no profile')).toBeInTheDocument(); + renderCard({ ...HIKE, routed: false, problem: { why: 'no_way', position: null, snappedM: null } }); + expect(screen.getByText('No path within T3 joins these points')).toBeInTheDocument(); + expect(screen.getAllByRole('group', { name: /Elevation profile/ })).toHaveLength(1); + renderCard({ ...HIKE, routed: false, limit: null, problem: { why: 'no_way', position: null, snappedM: null } }); + expect(screen.getByText('No path joins these points')).toBeInTheDocument(); + }); + + it('FE-AI-TOUR-023: the profile sets the spot in focus for the map, and a spot from the map is read with its clock', () => { + const { container } = renderCard(HIKE); + const profile = screen.getByRole('group', { name: 'Elevation profile of Hochfelln from Bergen' }); + fireEvent.focus(profile); + expect(useAssistantStore.getState().routeFocus).toMatchObject({ tripId: TRIP, ref: 'call_1', source: 'card' }); + expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBe(0); + const focus = { distanceMeters: 4100, elevationMeters: 1400, lat: 47.795, lng: 12.625, sampleIndex: 5 }; + act(() => useAssistantStore.getState().setRouteFocus({ tripId: TRIP, ref: 'call_1', focus, source: 'map' })); + const readout = container.querySelector('[data-profile-focus-readout]'); + expect(readout).toHaveTextContent('At 4.1 km: 1400 m, about 09:40, T3 Demanding mountain hiking'); + }); + + it('FE-AI-TOUR-024: a stop chip moves the spot in focus to the stop while pointed at', () => { + renderCard(HIKE); + const hut = screen.getByRole('button', { name: 'Strausshütte, 09:40' }); + fireEvent.mouseEnter(hut); + expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBeCloseTo(2400, 0); + fireEvent.mouseLeave(hut); + expect(useAssistantStore.getState().routeFocus).toBeNull(); + expect(screen.getByRole('button', { name: 'Water, 80 m off the way' })).toBeInTheDocument(); + expect(screen.getByRole('list', { name: 'Stops along the way' })).toBeInTheDocument(); + }); + + it('FE-AI-TOUR-025: lays out the day with the sun and the walk, the forecast hours with likely rain, and the warnings', () => { + renderCard(HIKE); + expect( + screen.getByRole('img', { + name: 'Daylight and walking time: Sunrise 07:00, Walking 08:00 to 11:20, Sunset 18:00', + }) + ).toBeInTheDocument(); + const hours = screen.getByRole('list', { name: 'Weather near the highest point' }); + expect(hours.querySelectorAll('li')).toHaveLength(4); + expect(hours).toHaveTextContent('40 %'); + expect(hours).not.toHaveTextContent('10 %'); + expect(screen.getByText('Thunderstorms possible from 11:00')).toBeInTheDocument(); + renderCard({ ...HIKE, schedule: { ...(HIKE.schedule as NonNullable), weather: 'climate' } }); + expect(screen.getByText('Typical weather for the date, no forecast yet')).toBeInTheDocument(); + }); + + it('FE-AI-TOUR-026: "Plan this" holds the hike below, waits with its reason, and asks to ask again when refused', async () => { + renderCard(HIKE); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Plan this' })); + }); + expect(takeProposal).toHaveBeenCalledWith(TRIP, 'call_1'); + expect(screen.getByText('Added below for you to confirm')).toBeInTheDocument(); + + takeProposal.mockImplementationOnce(async () => { + const chat = chatOf(useAssistantStore.getState(), TRIP); + useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, error: 'generic' } } }); + return 'refused'; + }); + renderCard({ ...HIKE, name: 'Second' }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Plan this' })); + }); + expect(screen.getByText('This can no longer be planned from here. Ask for it again.')).toBeInTheDocument(); + + // Any other refusal is the chat's to say: the button is offered again. + takeProposal.mockImplementationOnce(async () => { + const chat = chatOf(useAssistantStore.getState(), TRIP); + useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, error: 'too_many_requests' } } }); + return 'refused'; + }); + renderCard({ ...HIKE, name: 'Third' }); + const third = screen.getAllByRole('button', { name: 'Plan this' })[1] as HTMLElement; + await act(async () => { + fireEvent.click(third); + }); + expect(third).toBeEnabled(); + expect(screen.getAllByText('This can no longer be planned from here. Ask for it again.')).toHaveLength(1); + }); + + it('FE-AI-TOUR-027: "Plan this" is off while offline or while another change waits, and absent where nothing can be taken', () => { + seed([UNDECIDED]); + renderCard(HIKE); + expect(screen.getByRole('button', { name: 'Plan this' })).toBeDisabled(); + expect(screen.getByText('Apply or discard the changes above first.')).toBeInTheDocument(); + act(() => setForcedOffline(true)); + expect(screen.getByText('Offline. The assistant needs internet.')).toBeInTheDocument(); + renderCard({ ...HIKE, routed: false, name: 'Straight' }); + expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1); + }); + + it('FE-AI-TOUR-028: a saved tour opens in Tours; on the phone the readout is a row, buttons are taller and the map peeks', () => { + const onOpen = vi.fn(); + renderCard(SAVED, { onOpen }); + fireEvent.click(screen.getByRole('button', { name: /Open in Tours/ })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'tour-planner', params: { tour: '41' } }); + expect(screen.queryByRole('button', { name: 'Plan this' })).not.toBeInTheDocument(); + + renderCard({ ...HIKE, name: 'On the phone' }, { variant: 'fullscreen' }); + expect(screen.getByText('Slide along the profile to read it')).toBeInTheDocument(); + const onMap = screen.getAllByRole('button', { name: 'On map' })[1] as HTMLElement; + expect(onMap).toHaveClass('h-10'); + expect(screen.getByRole('button', { name: 'Plan this' })).toHaveClass('h-10'); + fireEvent.click(onMap); + expect(onRevealMap).toHaveBeenCalled(); + expect(requestCamera).toHaveBeenCalledWith(TRIP, expect.any(Array), 'fit', 'user'); + expect(useAssistantStore.getState().routePeek).toEqual({ tripId: TRIP, toolCallId: 'call_1' }); + const focus = { distanceMeters: 2400, elevationMeters: 920, lat: 47.78, lng: 12.61, sampleIndex: 2 }; + act(() => useAssistantStore.getState().setRouteFocus({ tripId: TRIP, ref: 'call_1', focus, source: 'map' })); + expect(screen.getByTestId('tour-profile-readout')).toHaveTextContent('920 m'); + expect(screen.getByTestId('tour-profile-readout')).toHaveTextContent('T2'); + }); + + it('FE-AI-TOUR-037: on the phone a saved tour opens as its place on the plan, since the phone has no Tours tab', () => { + const onOpen = vi.fn(); + renderCard(SAVED, { onOpen, variant: 'fullscreen' }); + const open = screen.getByRole('button', { name: /Open tour/ }); + expect(open).toHaveClass('h-10'); + fireEvent.click(open); + expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 41, dayId: null }); + }); + + it('FE-AI-TOUR-038: "Plan this" is absent for a member who may not add places to the trip', () => { + usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } }); + useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 2 }) }); + renderCard(HIKE); + expect(screen.queryByRole('button', { name: 'Plan this' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'On map' })).toBeInTheDocument(); + }); + + it('FE-AI-TOUR-039: a card drawn again does not offer a tour the thread already holds or applied, only one discarded', () => { + const view = renderCard(HIKE); + expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled(); + view.unmount(); + + seed([proposed(), heldTake(true)]); + const again = renderCard(HIKE); + expect(screen.queryByRole('button', { name: 'Plan this' })).not.toBeInTheDocument(); + expect(screen.getByText('Added to the trip')).toBeInTheDocument(); + again.unmount(); + + seed([proposed(), heldTake(false)]); + renderCard(HIKE); + expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled(); + }); + + it('FE-AI-TOUR-040: what came of a take decides the card, not an error the chat still shows from before', async () => { + const chat = chatOf(useAssistantStore.getState(), TRIP); + useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, error: 'generic' } } }); + renderCard(HIKE); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Plan this' })); + }); + expect(screen.getByText('Added below for you to confirm')).toBeInTheDocument(); + + // Held nothing (a turn started, the thread changed): the button is offered again, nothing said taken. + takeProposal.mockResolvedValueOnce('skipped'); + renderCard({ ...HIKE, name: 'Skipped' }); + const second = screen.getByRole('button', { name: 'Plan this' }); + await act(async () => { + fireEvent.click(second); + }); + expect(second).toBeEnabled(); + expect(screen.getAllByText('Added below for you to confirm')).toHaveLength(1); + }); + + it('FE-AI-TOUR-041: says why a route is drawn straight for each reason, and keeps two warnings of one kind', () => { + const straight = (why: NonNullable['why'], position: number | null = null): Route => ({ + ...HIKE, + name: why, + routed: false, + problem: { why, position, snappedM: null }, + }); + renderCard(straight('point_off_the_way', 2)); + expect(screen.getByText('Point 2 is too far from any path')).toBeInTheDocument(); + renderCard(straight('ferry_only')); + expect( + screen.getByText('Some stations can only be reached by boat, so the walk is drawn straight') + ).toBeInTheDocument(); + renderCard(straight('too_long')); + expect(screen.getByText('This way is longer than one hike is planned for')).toBeInTheDocument(); + renderCard(straight('not_planned')); + expect(screen.queryByText('The way is planned when the tour is added')).not.toBeInTheDocument(); + + const rain = [ + { kind: 'rain' as const, at: '10:00', count: null }, + { kind: 'rain' as const, at: '14:00', count: null }, + ]; + renderCard({ ...HIKE, name: 'Wet', warnings: rain }); + expect(screen.getByText('Rain likely from 10:00')).toBeInTheDocument(); + expect(screen.getByText('Rain likely from 14:00')).toBeInTheDocument(); + }); + + it('FE-AI-TOUR-042: the day shows the latest start when none is set, and the walk in the dark in red', () => { + const schedule = HIKE.schedule as NonNullable; + renderCard({ ...HIKE, schedule: { ...schedule, start: null, arrive: null, latestStart: '10:30' } }); + expect(screen.getByText('Start by 10:30 to finish before sunset')).toBeInTheDocument(); + + const { container } = renderCard({ + ...HIKE, + name: 'Early', + schedule: { ...schedule, start: '06:00', arrive: '19:00', latestStart: '10:30' }, + }); + expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2); + expect(screen.getAllByText('Start by 10:30 to finish before sunset')).toHaveLength(1); + }); +}); diff --git a/client/src/components/Assistant/cards/tour/TourRouteCard.tsx b/client/src/components/Assistant/cards/tour/TourRouteCard.tsx new file mode 100644 index 000000000..e80b6aea6 --- /dev/null +++ b/client/src/components/Assistant/cards/tour/TourRouteCard.tsx @@ -0,0 +1,76 @@ +import type { TourRouteCard as Route } from '@trek/shared'; +import { useMemo } from 'react'; + +import { useTranslation } from '../../../../i18n'; +import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry'; +import { variantPaintCss } from '../../../Tours/planner/legVariantPaint'; +import { useAssistantView } from '../../assistantViewContext'; +import CardFrame from '../parts/CardFrame'; +import TourRouteActions from './TourRouteActions'; +import TourRouteDay from './TourRouteDay'; +import TourRouteHead from './TourRouteHead'; +import { profileSamples } from './tourRouteModel'; +import { TourRouteProblem, TourRouteWarnings } from './TourRouteNotes'; +import TourRouteProfile from './TourRouteProfile'; +import TourRouteStops from './TourRouteStops'; +import { routeName } from './tourRouteWords'; +import { useTourRouteCard } from './useTourRouteCard'; + +/** + * One route in the chat, the way the Tours tab shows a hike: drawn small + * beside its name, its grade and figures, its height profile, its stops along + * the way, its day with the sun and the weather, what to look out for, and + * the ways onward ("On map", "Plan this", "Open in Tours"). Resting on the + * card lights its line on the map; resting on the line lights the card. + */ + +/** Everything under a route's head, shared by a single route and the chosen way of a ways card. */ +export function TourRouteBody({ + toolCallId, + card, + route, + color, + compare, + letter, +}: { + toolCallId: string; + card: Route; + route: ReturnType; + color: string; + compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>; + letter?: string; +}) { + const samples = useMemo(() => profileSamples(card), [card]); + return ( + <> + + + + + + + + ); +} + +export default function TourRouteCard({ toolCallId, card }: { toolCallId: string; card: Route }) { + const { t } = useTranslation(); + const { density } = useAssistantView(); + const route = useTourRouteCard(toolCallId, card); + return ( +
+ + + + +
+ ); +} diff --git a/client/src/components/Assistant/cards/tour/TourRouteDay.tsx b/client/src/components/Assistant/cards/tour/TourRouteDay.tsx new file mode 100644 index 000000000..8622fdd0b --- /dev/null +++ b/client/src/components/Assistant/cards/tour/TourRouteDay.tsx @@ -0,0 +1,115 @@ +import type { TourRouteCard, TourRouteSchedule } from '@trek/shared'; +import { Clock, Droplet, Sunrise, Sunset } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { SoftPill } from '../../../Planner/planParts'; +import { weatherIconFor } from '../../../Weather/weatherIcons'; +import { useCardFormat } from '../parts/useCardFormat'; +import { type BarSpan, dayBarOf, forecastHours, RAIN_SHOWN_PCT } from './tourRouteDayModel'; + +/** + * A tour's day in its card: one bar with the daylight and the walk on it + * (the walk in the dark, before sunrise or after sunset, in red), sunrise and + * sunset at its ends, the latest start when no start is set, and the + * forecast hour by hour for the walk near its highest point. A day only a + * climate is known for says so and shows no hours, since those are last + * year's. + */ + +/** A stretch of the bar, placed along the reading direction. */ +function Span({ span, className }: { span: BarSpan; className: string }) { + return ( +